Last active
June 15, 2024 18:34
-
-
Save tijldeneut/e1c81c6f8937cd99ed957c68a3166c38 to your computer and use it in GitHub Desktop.
msaccountdec test for AzureAD
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python3 | |
# -*- coding: utf-8 -*- | |
# | |
# Copyright 2024, Photubias(c) <[email protected]> | |
# | |
## C:\Windows\System32\config\systemprofile\AppData\Local\Microsoft\Windows\CloudAPCache\MicrosoftAccount\<id>\Cache | |
## Relation between ID and account: HKLM\SOFTWARE\Microsoft\IdentityStore\LogonCache\D7F9888F-E3FC-49b0-9EA6-A85B5F392A4F\Name2Sid | |
## #> This ID is a 32byte (64 char) cloud key: it is the same on each machine, maybe this is the unique MS Live ID? | |
## #> Also the DPAPI user key is probably derived from the cleartext password because the DPAPI key is the same on multiple machines until password changes | |
# | |
# AzureAD Details: https://github.com/Gerenios/AADInternals/blob/0fa2edf5676439cd3fe7c92ed8006b63f0be9632/PRT_Utils.ps1#L1129 | |
# Licensed under the Apache License, Version 2.0 (the "License"); | |
# you may not use this file except in compliance with the License. | |
# You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, software | |
# distributed under the License is distributed on an "AS IS" BASIS, | |
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
# See the License for the specific language governing permissions and | |
# limitations under the License. | |
""" Windows Microsoft Account DPAPI key decryption utility.""" | |
import hashlib, optparse, hmac, os, sys, json | |
from dpapick3 import eater | |
from Crypto.Cipher import AES ## pip3 install pycryptodome | |
class DPAPICredKeyBlob(eater.DataStruct): | |
def __init__(self, raw): | |
eater.DataStruct.__init__(self, raw) | |
def parse(self, data): | |
self.dwBlobSize = data.eat("L") | |
self.dwField4 = data.eat("L") | |
self.dwCredKeyOffset = data.eat("L") | |
self.dwCredKeySize = data.eat("L") | |
self.Guid = "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x" % data.eat("L2H8B") | |
assert data.ofs == self.dwCredKeyOffset | |
self.CredKey = data.eat_string(self.dwCredKeySize) | |
def checkParams(options, args): | |
if not options.cachedatafile or not options.password: | |
sys.exit('You must provide cleartext password and cachedata file.') | |
if not os.path.isfile(options.cachedatafile): | |
sys.exit('File not found: {}.'.format(options.cachedatafile)) | |
return | |
def reverseByte(bByteInput): | |
sReversed = '' | |
sHexInput = bByteInput.hex() | |
for x in range(-1, -len(str(sHexInput)), -2): sReversed += sHexInput[x-1] + sHexInput[x] | |
return bytes.fromhex(sReversed) | |
def parseGUID(bData): | |
return inverseHex(bData[:4]).hex() + '-' + inverseHex(bData[4:6]).hex() + '-' + inverseHex(bData[6:8]).hex() + '-' + bData[8:10].hex() + '-' + bData[10:].hex() | |
def inverseHex(orgData): ## Invert endianness | |
if type(orgData) is bytes: sNewBytes = ''.join(map(str.__add__, orgData.hex()[-2::-2], orgData.hex()[-1::-2])) | |
else: sNewBytes = ''.join(map(str.__add__, orgData[-2::-2], orgData[-1::-2])) | |
if type(orgData) is bytes: return bytes.fromhex(sNewBytes) | |
return sNewBytes | |
def getEncryptedData(bLeftoverData): | |
## The data is divided into DataLength + Data, the DPAPI data can be the first or even the last | |
lstReturnData = [] | |
icount = 0 | |
print(f'total length: {len(bLeftoverData)}') | |
while len(bLeftoverData) > 0: | |
icount +=1 | |
print(icount) | |
iLength = int(inverseHex(bLeftoverData[:4]).hex(), 16) | |
if iLength > 0xffff: | |
bLeftoverData=bLeftoverData[4:] | |
continue | |
print(f'section length {iLength}') | |
bData = bLeftoverData[4:4+iLength] | |
print(bData.hex()) | |
if b'RSA1' in bData: | |
## The RSA1 public key does not follow the Length+Data system, just skip over (assuming 256 byte RSA1 modulus) | |
print('Found RSA1 Key, skipping 988 bytes') | |
bLeftoverData = bLeftoverData[4+0x3D8:] | |
#bLeftoverData = bLeftoverData[4+iLength:] | |
print(bLeftoverData.hex()) | |
print('------') | |
continue | |
if iLength > 48: lstReturnData.append(bData) | |
bLeftoverData = bLeftoverData[4+iLength:] | |
return lstReturnData | |
def parseAzureADCacheData(bData, boolVerbose=False): ## Source: https://github.com/Gerenios/AADInternals/blob/0fa2edf5676439cd3fe7c92ed8006b63f0be9632/PRT_Utils.ps1#L858 | |
iVersion = int(inverseHex(bData[:4]).hex(),16) | |
if boolVerbose: print('[+] CacheData version : {}'.format(iVersion)) | |
bData = bData[4:] | |
#> For Azure AD , this must be int 2 | |
if iVersion != 2: | |
print('[-] Error: CacheData version ({}) not supported, maybe not AzureAD/EntraID?'.format(iVersion)) | |
exit() | |
bHash = bData[:32] | |
if boolVerbose: print('[+] CacheData SHA256 : {}'.format(bHash.hex())) | |
bData = bData[32:] | |
bData = bData[8:] # 8 UNK Bytes | |
iDataLen = int(inverseHex(bData[:8]).hex(),16) | |
if boolVerbose: print('[+] CacheData length : {}'.format(iDataLen)) | |
bData = bData[12:] # 8 UNK Bytes | |
bGUID = bData[:16] # GUID at offset 0x38 (56 bytes) | |
if boolVerbose: print('[+] CacheData key id : {}'.format(parseGUID(bGUID))) | |
bData = bData[16:] | |
bData = bData[24:] # 24 UNK Bytes | |
iEncDataLength = int(inverseHex(bData[:4]).hex(),16) # Enc Data Length at offset 96 | |
bData = bData[4:] | |
## Create a loop to find 4 bytes (keySize) that are exactly 00000030 (48bytes) | |
iEncKeySize = None | |
while True: | |
iEncKeySize = int(inverseHex(bData[:4]).hex(),16) | |
bData = bData[4:] | |
if iEncKeySize == 48 and int(inverseHex(bData[48:48+4]).hex(),16) == iEncDataLength: | |
break | |
if not iEncKeySize: | |
print('[-] Error parsing CacheData file, is this from a full AzureAD/EntraID joined device?') | |
exit() | |
bEncKey = bData[:iEncKeySize] | |
bData = bData[iEncKeySize:] | |
iEncDataLength = int(inverseHex(bData[:4]).hex(),16) | |
bData = bData[4:] | |
bEncData = bData[:iEncDataLength] | |
bData = bData[iEncDataLength:] | |
if boolVerbose: | |
print('[+] EncryptedData length : {}'.format(iEncDataLength)) | |
print('[+] EncryptedKey length : {}'.format(iEncKeySize)) | |
return bEncKey, bEncData, bGUID | |
def parseDecryptedAzureADCache(bData, bGUID, boolVerbose=False): | |
iVersion = int(inverseHex(bData[:4]).hex(),16) | |
if boolVerbose: print('[+] CacheData blob version : {}'.format(iVersion)) | |
bData = bData[4:] | |
#> For Azure AD, this blob version must be int 0 | |
if iVersion != 0: | |
print('[-] Error: CacheData blob version ({}) not supported, maybe not AzureAD/EntraID?'.format(iVersion)) | |
exit() | |
bData = bData[2*4:] | |
iCredKeySize = int(inverseHex(bData[:4]).hex(),16) | |
bData = bData[4:] | |
bCredKeyData = bData[:iCredKeySize] | |
oCredKey = DPAPICredKeyBlob(bCredKeyData) | |
bKeyGUID = bCredKeyData[16:16+16] ## GUID of the KEY | |
bData = bData[iCredKeySize:] | |
if boolVerbose: print('[+] CacheData key id : {}'.format(parseGUID(bKeyGUID))) | |
if not bGUID == bKeyGUID: print('[-] Warning: GUIDs decrypted and they do not match!') | |
return bData, oCredKey | |
def parseDecryptedCache(bClearData, boolVerbose = True): | |
sPassword = None | |
## DPAPI Password should be at offset 48, length 88 bytes | |
try: sPassword = bClearData[48:136].decode('UTF-16LE') | |
except: return None | |
if not boolVerbose: return sPassword | |
## Offset 48+88+4 bytes (140) should be the length of the rest of the file | |
bData = bClearData[140:] | |
iLength = int(inverseHex(bData[:4]).hex(), 16) | |
bData = bData[4:4+iLength][48:] ## 48 bytes UNKnown | |
sStableUserId = bData[:32].decode('UTF-16LE') | |
bData = bData[32:] | |
## Now an XML should follow, starting with a Unicode Null Byte and ending with one | |
if bData[:2] == b'\x00\x00': | |
bXML = bData[2:].split(b'\x00\x00')[0]+b'\x00' | |
sXML = bXML.decode('UTF-16LE') | |
else: | |
print('[-] Parsing error') | |
return sPassword | |
bData = bData[2+len(bXML)+2:] | |
## Now there could be a DPAPI blob first | |
if bData[:8] == bytes.fromhex('0100000001000000'): | |
## DPAPI Blob now | |
print('[+] Found SYSTEM DPAPI blob') | |
bBlob = bData[8:].split(bytes.fromhex('010b00000000000b'))[0] | |
bData = bData[8+len(bBlob):] | |
if bData[:8] == bytes.fromhex('010b00000000000b'): | |
#print(bData[8+4+40:].hex()) | |
bAccount = bData[8+4+40:].split(b'\x00\x00')[0]+b'\x00' | |
sAccount = bAccount.decode('UTF-16LE') | |
else: return sPassword | |
print('[+] Decoded:') | |
print(' StableUserID : {} (There should be a SQLite archive at %localappdata%\\ConnectedDevicesPlatform\\{}\\)'.format(sStableUserId, sStableUserId)) | |
print(' User Account : {}'.format(sAccount)) | |
print(' XML Cipher : {}\n'.format(sXML)) | |
return sPassword | |
def walkThroughFile(bCacheDataOrg, oCipher): | |
## Let's walk through the file reading 4 bytes at a time, looking for "length" headers | |
iChunk = 4 | |
for i in range(0,len(bCacheDataOrg),iChunk): | |
iLength = int(inverseHex(bCacheDataOrg[i:i+iChunk]).hex(), 16) | |
## Let's assume the encrypted datalength is less than 65535 bytes | |
if iLength>=0xffff or iLength==0: continue | |
bEncrData = bCacheDataOrg[i+iChunk:i+iChunk+iLength] | |
try: | |
## If the datalength is a factor of 16 bytes, this always works | |
bClearData = oCipher.decrypt(bEncrData) | |
print(bClearData) | |
if b'Version' in bClearData: | |
print('!'*200) | |
exit() | |
if b'V\x00e\x00r' in bClearData: | |
print('!'*200) | |
exit() | |
#if not b'\x00\x40\x00' in bClearData: continue | |
sPassword = parseDecryptedCache(bClearData, False) | |
if sPassword: | |
print(f'[+] Success, use this as your DPAPI cleartext user password:\n {sPassword}') | |
break | |
i+=iLength | |
#except Exception as e: print(e) | |
except: pass | |
return | |
if __name__ == '__main__': | |
usage = ( | |
'usage: %prog [options] cachedata\n\n' | |
'It tries to unlock (decrypt) the Microsoft Account DPAPI password.\n' | |
'NOTE: only works when the *cleartext* password is known\n' | |
'NOTE: currently does not support the AzureAD Cache\n' | |
r' Default Location: Windows\System32\config\systemprofile\AppData\Local\Microsoft\Windows\CloudAPCache\MicrosoftAccount\<id>\Cache\CacheData') | |
parser = optparse.OptionParser(usage=usage) | |
parser.add_option('--cachedatafile', '-f', metavar='FILE', help=r'CloudAPCache CacheData', default=os.path.join('Windows','System32','config','systemprofile','AppData','Local','Microsoft','Windows','CloudAPCache','MicrosoftAccount','CacheData')) | |
parser.add_option('--password', '-p', metavar='STRING', help=r'Clear Text User Password') | |
parser.add_option('--export', '-e', action="store_true", default=False, metavar='BOOL', help=r'Export a (crackable) Hash, TODO: write Hashcat module') | |
parser.add_option('--sid', metavar='STRING', dest='sid', help=r'SID required for AzureAD accounts') | |
parser.add_option('--verbose', '-v', action = 'store_true', default = False, help='Be more verbose') | |
(options, args) = parser.parse_args() | |
boolVerbose = True if options.verbose else False | |
file = open(options.cachedatafile,'br') | |
bCacheDataOrg = file.read() | |
file.close() | |
boolAzureADAccount=False | |
if bCacheDataOrg[72:72+4] == b'\x01\x00\x00\x00': | |
print('[+] Detected AzureAD CacheData file') | |
boolAzureADAccount = True | |
elif bCacheDataOrg[72:72+4] == b'\x02\x00\x00\x00': | |
print('[+] Detected Microsoft Live Account CacheData file') | |
else: | |
print('[!] Warning: Not a verified CacheData file') | |
checkParams(options, args) | |
bDecryptionKey = hashlib.pbkdf2_hmac('sha256', options.password.encode('UTF-16LE'), b'', 10000) | |
oCipher = AES.new(bDecryptionKey, AES.MODE_CBC, b'\x00'*16) | |
bEncKey, bEncData, bGUID = parseAzureADCacheData(bCacheDataOrg, boolVerbose=True) | |
bDecryptedCacheData = oCipher.decrypt(bEncData) | |
if not bDecryptedCacheData[:4] == b'\x00\x00\x00\x00': | |
print('[-] Error decrypting, wrong password?') | |
exit() | |
iCredKeySize = int(inverseHex(bDecryptedCacheData[12:16]).hex(),16) | |
bJsonData, oCredKey = parseDecryptedAzureADCache(bDecryptedCacheData, bGUID, boolVerbose=False) | |
bCredKey = hashlib.sha1(oCredKey.CredKey).digest() | |
oJsonData = json.loads(bJsonData) | |
sUsername = oJsonData['UserName'] | |
sTenantID = oJsonData['TenantId'] | |
sSID = oJsonData['UserInfo']['PrimarySid'] | |
sPRT = oJsonData['Prt'] | |
sPOP = oJsonData['ProofOfPossesionKey']['KeyValue'] | |
bEncodedSID = (sSID + '\0').encode('UTF-16-LE') | |
sCredKey = hmac.new(bCredKey, bEncodedSID, hashlib.sha1).hexdigest() | |
print('[+] Username : {}'.format(sUsername)) | |
print('[+] User SID : {}'.format(sSID)) | |
print('[+] DPAPI Credential Key : 0x{}'.format(sCredKey)) | |
print('[+] Tenant ID : {}'.format(sTenantID)) | |
print('[+] PrimaryRequestToken : {}'.format(sPRT)) | |
print('[+] ProofOfPossesion : {}'.format(sPOP)) | |
print(' INFO: If no TPM is used, this Cred Key can be used to decrypt DPAPI Masterkeys.') | |
print(' Details about this key can be found in this folder:') | |
print(' C:\Windows\System32\config\systemprofile\AppData\Local\Microsoft\Windows\CloudAPCache\AzureAD\[User Profile Id]\Keys\CredKeyInfo') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment