Decrypting CryptProtectMemory without code injection

CryptProtectMemory is a function used to encrypt sensitive memory in an application, without providing any key. In particular, this function has a flag, CRYPTPROTECTMEMORY_SAME_PROCESS, which should ensure that the encrypted memory can only be decrypted from within the process where it was encrypted.

I’ve come across this function in multiple contexts, being used both as security mechanism (for instance it is used by KeePass or the default RDP client to protect masterkeys in memory), as well as by offensive applications to hide payloads in memory. For example, this means tools that try to decrypt the masterkey of KeePass, must inject code into the KeePass process.

All this got me wondering how the encryption worked, and if it was possible to decrypt encrypted blobs without injecting code into the victim process. Let’s dive into the implementation !

Finding the actual implementation

CryptProtectMemory is exported bydpapi.dll, with the following prototype:

DPAPI_IMP BOOL CryptProtectMemory(
  [in, out] LPVOID pDataIn,
  [in]      DWORD  cbDataIn,
  [in]      DWORD  dwFlags
);

Decompiling the function, it is just a thin wrapper around cryptbase!SystemFunction040:

BOOL CryptProtectMemory(LPVOID pDataIn,dword cbDataIn,dword dwFlags)
{
  NTSTATUS status;
  ULONG ErrorCode;
  
  status = SystemFunction040(pDataIn,cbDataIn,dwFlags);
  if (status < 0) {
    ErrorCode = RtlNtStatusToDosError(status);
    SetLastError(ErrorCode);
  }
  return status >= 0;
}

Let’s see what SystemFunction040 does:

ULONG SystemFunction040(LPVOID pDataIn, dword cbDataIn, dword dwFlags)
{
  ULONG IoctlCode, status;
  IO_STATUS_BLOCK IoStatusBlock;
  
  if ((g_hKsecDD != 0) || EncryptMemoryInitialize()) {
    switch (dwFlags) {
    case CRYPTPROTECTMEMORY_SAME_PROCESS:
      IoctlCode = 0x39000e;
      break;
    case CRYPTPROTECTMEMORY_CROSS_PROCESS:
      IoctlCode = 0x390016;
      break;
    case CRYPTPROTECTMEMORY_SAME_LOGON:
      IoctlCode = 0x39001e;
      break;
    case 4:
      IoctlCode = 0x39007a;
      break;
    default:
      return STATUS_INVALID_PARAMETER;
    }

    status = NtDeviceIoControlFile(
      g_hKsecDD,
      0,
      0,
      0,
      &IoStatusBlock,
      IoctlCode,
      pDataIn,
      cbDataIn,
      pDataIn,
      cbDataIn
    );
  }
  else {
    status = STATUS_UNSUCCESSFUL;
  }
  return status;
}

Again, the real work is done deeper, the function is simply a wrapper around an ioctl made to the \\Device\\KsecDD device driver (which is opened in EncryptMemoryInitialize).

Depending on the flags provided to CryptProtectMemory, a different ioctl code will be used. Since the CRYPTPROTECTMEMORY_SAME_PROCESS flag is the one of interest, the corresponding ioctl code is 0x39000e.

Additionally, it is interesting to note that there is an undocumented flag with a value of 4 and an ioctl code of 0x39007a.

Inside the DriverEntry function of ksecdd.sys, the ioctl handler is registered with a generic function that handles all I/O operations for the driver:

DriverEntry(PDRIVER_OBJECT DriverObject) {
  ...
  DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = KsecDispatch;
  ...
}

Then, the control is handed to the function responsible for the ioctl I/O operations:

ulonglong KsecDispatch(undefined8 param_1,PIRP Irp,undefined8 param_3,undefined8 param_4)
{
  ...
  MajorFunction = CurrentStackLocation->MajorFunction;

  if (MajorFunction == IRP_MJ_DEVICE_CONTROL) {
    ...
    result = KsecDeviceControl(
      Irp->AssociatedIrp.SystemBuffer, InputBufferLen, OutputBuffer, OutputBufferLen, IoctlCode, Irp
    );
  }
}

As one might expect, KsecDeviceControl calls methods corresponding to the ioctl code. However, there is no special case for 0x39000e. If the ioctl code is not among the hardcoded ones, a function pointer from a global array, gKsecDeviceControlExtension, is invoked.

status = (**gKsecpDeviceControlExtension)(
InputBuffer, InLength, OutputBuffer, OutputBufferLen, IoctlCode, IRP->RequestorMode
);

I was too lazy to statically find out how this global variable was initialized, so I simply looked in an already initialized example from a memory dump:

0: kd> ln poi(poi(ksecdd!gKsecpDeviceControlExtension))
Browse module
Set bu breakpoint

(fffff803`0e93e900)   cng!CngDeviceControl   |  (fffff803`0e93ebd8)   cng!VerifyRegistryAccess
Exact matches:
    cng!CngDeviceControl (void)

It seems that the driver that will be handling our ioctl is the Windows Kernel Cryptography Driver cng.sys, which makes sense since the operation is related to cryptography.

Let’s review cng!CngDeviceControl. This time, a special case is present for ioctl 0x39000e, among others:

if ((((IoctlCode == 0x39000e) || (IoctlCode == 0x390012)) || (IoctlCode == 0x390016)) ||
    (((IoctlCode == 0x39001a || (IoctlCode == 0x39001e)) || (IoctlCode == 0x390022)))) {
...
    status = CngEncryptMemoryEx(OutputBuffer, *Length, OperationIsEncryption, CipherType);
...
}

CngEncryptMemoryEx is where the real work is done, which includes two flags, one to indicate whether we are encrypting or decrypting the memory, and the other to indicate which cipher to use (this function is also used by other ioctl, for instance when using a flag different than CRYPTPROTECTMEMORY_SAME_PROCESS, or simply when decrypting memory). The value of these two flags depends on the ioctl code. In SystemFunction040, the memory is being encrypted, so OperationIsEncryption is equals to 1, and CipherType has a value of 0.

void CngEncryptMemoryEx(undefined (*OutputBuffer) [16],uint Length,int OperationIsEncryption, int OperationType) {
...
    if ((Length & 0xf) == 0) {
        UseAES = true;
    } else {
        UseAES = false;
    }
    else if ((Length & 7) != 0) {
        LogError();
        return;
    }
...
    if (TypeOfOperation == 0) {
        Cookie = 0;
        status = ZwQueryInformationProcess(GetCurrentProcess(), 0x24, &Cookie);
        if (status < 0) {
            DebugTraceError(status,"Status", "onecore\\ds\\security\\cryptoapi\\ncrypt\\crypt\\kernel\\encmem.cxx");
            goto error;
        }
        CurrentEprocess = PsGetCurrentProcess();
        CurrentProcessCreateTime = PsGetProcessCreateTimeQuadPart(CurrentEprocess);
	
        KeyData.Cookie = Cookie;
        KeyData.CreateTime = CurrentProcessCreateTime;

        Iv = RandomSalt;

        if (UseAES) {
            GenerateAESKey(aesKey, &KeyData, sizeof(KeyData));
            if (OperationIsEncryption == 0) {
                SymCryptAesCbcDecrypt(AesKey, Iv, OutputBuffer, OutputBuffer, BufferLength);
            } else {
                SymCryptAesCbcEncrypt(AesKey, Iv, OutputBuffer, OutputBuffer, BufferLength);
            }
        } else {
            GenerateKey(DesKey, &KeyData, sizeof(KeyData));
            if (OperationIsEncryption == 0) {
                SymCryptCbcDecrypt(
                    SymCrypt3DesBlockCipher_default, DesKey, Iv, OutputBuffer, OutputBuffer, BufferLength
                );
            } else {
                SymCryptCbcEncrypt(
                    SymCrypt3DesBlockCipher_default, desKey, Iv, OutputBuffer, OutputBuffer, BufferLength
                );
            }
        }
    }
}

This is where the actual encryption happens. Something interesting to note is that although the documentation states that The number of bytes must be a multiple of the CRYPTPROTECTMEMORY_BLOCK_SIZE constant, which is equal to 0x10, however, if the length is only a multiple of 8, it is still accepted (probably for backward compatibility). If that’s the case, the cipher used will be different.

If the length is a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, AES128 will be used, else, it will be 3DES. This makes sense as the block size of AES128 is 0x10 bytes, and 8 bytes for 3DES. The library used for encryption is SymCrypt, an open-source library. Both ciphers use the CBC mode of operation.

In both cases, the initialization vector used by the CBC mode is a global variable, cng!RandomSalt (but only half of the data is used with 3DES since the block size is only 64 bits).

Depending on the cipher, the key is generated using the GenerateAESKey and GenerateKey, for AES and 3DES, respectively. In both cases, a struct containing both the process creation time and the process Cookie (a randomly generated value stored in the EPROCESS structure associated with the current process) is given as an argument.

Now, the only thing missing, to fully understand the encryption process, is to find out how symmetric keys are derived from those two values.

Let’s start with GenerateAESKey:

void GenerateAESKey(_SYMCRYPT_AES_EXPANDED_KEY *ResultAesKey, KeyInput *KeyData,ulong DataLength)
{
    memcpy(&ShaState, &g_ShaHash, sizeof(SYMCRYPT_SHA1_STATE));
    SymCryptSha1Append(&ShaState, KeyData, DataLength);
    SymCryptSha1Result(&ShaState, ShaHash);
    SymCryptAesExpandKeyInternal(ResultAesKey, ShaHash, 0x10, '\x01');
}

This is fairly straightforward. The internal state of a global variable g_ShaHash is copied to a local variable, then the state of the SHA hash is update with the 12 bytes of the Cookie and the CreateTime fields of the associated process. Finally, the SHA1 hash is finalized, and the first 0x10 bytes of the hash are used as the symmetric key for AES (SHA1 hashes are 0x14 bytes long).

g_ShaHash is initialized once per boot in cng!CngEncryptMemoryInitialize, and remains the same afterward.

Now, let’s take a look at GenerateKey:

void GenerateKey(_SYMCRYPT_3DES_EXPANDED_KEY *DesKey, KeyInput *Data, ulong DataLen)
{
    BYTE ShaHashes[0x28]; // Holds two SHA1 hashes

    memcpy(&ShaState1, &g_ShaHash, sizeof(SYMCRYPT_SHA1_STATE));
    SymCryptSha1Append(&ShaState1, "aaa", 3);
    SymCryptSha1Append(&ShaState1, Data, DataLen);
    SymCryptSha1Result(&ShaState1, &ShaHashes[0]);

    memcpy(&ShaState2, &g_ShaHash, sizeof(SYMCRYPT_SHA1_STATE));
    SymCryptSha1Append(&ShaState2, "bbb", 3);
    SymCryptSha1Append(&ShaState2, Data, DataLen);
    SymCryptSha1Result(&ShaState2, &ShaHashes[0x14]);

    SymCrypt3DesExpandKey(DesKey, ShaHashes, 0x18);
}

This is slightly different, since a 3DES key is 0x18 bytes long, a single SHA1 hash is not enough. In order to get enough bytes, two hashes are calculated, once again starting from the initial of g_ShaHash. To get different values, the hashes are updated with b"aaa" and b"bbb". Finally, the hashes are updated with both the Cookie and CreateTime values.

The resulting 3DES key is the concatenation of all bytes from the first SHA1 hash with the four first bytes of the second hash.

The other flags

I won’t dive as deep on the other flags, but I’ll provide a quick overview:

  • Instead of the process cookie and creation time, CRYPTPROTECTMEMORY_SAME_LOGON uses SeQueryAuthenticationIdToken to get the LUID of the current logon session, to use as an input to derive the AES/DES Key.
  • CRYPTPROTECTMEMORY_CROSS_PROCESS uses the value of cng!g_AESKey to derive the AES/DES key, which is initialized with random data once per boot.
  • Finally, the undocumented flag behaves differently depending of whether the application is encrypting or decrypting memory. If the application is encrypting memory, the blob is encrypted with a key derived from a hardcoded LUID with a value of 0x3E2, which according to online resources corresponds to the PROTECTED_TO_SYSTEM_LUID logon session. However, decryption is only permitted if the current logon session matches 0x3E7, a well-known LUID for the SYSTEM logon session.

I haven’t seen any use of the undocumented flag, but this could be an interesting topic for further research.

Decrypting protected memory

To summarize, to decrypt memory that was protected with CryptProtectMemory and the CRYPTPROTECTMEMORY_SAME_PROCESS flag in another process, one must:

  • Get the encrypted data, and its length in the target process
  • Get the value of the Cookie and CreateTime value in the EPROCESS structure of the target process
  • Get the value of cng!RandomSalt and cng!g_ShaHash
    • If the length of the encrypted data is a multiple of 8, then
      • derive a 3DES key from two SHA1 hashes, starting with an initial state of g_ShaHash
      • the first one with the state updated with b"aaa" and then updated with the Cookie and CreateTime, taken in full
      • the second one with the state updated with b"bbb" and then updated with the Cookie and CreateTime, truncated to the first 4 bytes
    • Else, if the length of the encrypted data is a multiple of 16, then
      • derive an AES128 key from the SHA1 hash starting with an initial state of g_ShaHash, and updated with Cookie and CreateTime, truncated to the first 16 bytes.
  • Finally, decrypt the memory using the corresponding key and cipher, as well as an IV of cng!RandomSalt, all 16 bytes for AES128, or the first 8 bytes for 3DES.

This seems pretty easy for a driver to do, but what about a userland application ? The Cookie, CreateTime, g_ShaHash, and RandomSalt are all stored in kernel memory.

Decrypting from usermode

Remembering my previous post, the NtSystemDebugControl syscall can be called by a user holding the SeDebugPrivilege to generate a kernel livedump. This can be used to parse kernel memory, and find all the elements required!

Additionally, on up-to-date Windows 11 or 2025 Server, the IncludeUserSpaceMemoryPages flag can be specified, to also be able to read the encrypted memory in the target process, without ever opening a HANDLE to the target process.

I wrote a proof of concept tool to decrypt protected data. It is available on Github.

This could be extended to retrieve credentials from sensitive applications, such as KeePass or mstsc.exe.

Conclusion

The availability of the NtSystemDebugControl provides privileged users a view of kernel memory, which holds various secrets. While not as powerful as other techniques, this confirms once again that admin-to-kernel (even read-only) techniques can be very interesting from an offensive point of view !