Skip to content

Instantly share code, notes, and snippets.

@tijldeneut
Last active June 15, 2024 18:34
Show Gist options
  • Save tijldeneut/e1c81c6f8937cd99ed957c68a3166c38 to your computer and use it in GitHub Desktop.
Save tijldeneut/e1c81c6f8937cd99ed957c68a3166c38 to your computer and use it in GitHub Desktop.
msaccountdec test for AzureAD
#!/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