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
usesSeQueryAuthenticationIdToken
to get theLUID
of the current logon session, to use as an input to derive the AES/DES Key. CRYPTPROTECTMEMORY_CROSS_PROCESS
uses the value ofcng!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 of0x3E2
, which according to online resources corresponds to thePROTECTED_TO_SYSTEM_LUID
logon session. However, decryption is only permitted if the current logon session matches0x3E7
, a well-known LUID for theSYSTEM
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
andCreateTime
value in theEPROCESS
structure of the target process - Get the value of
cng!RandomSalt
andcng!g_ShaHash
- If the length of the encrypted data is a multiple of
8
, then- derive a
3DES
key from twoSHA1
hashes, starting with an initial state ofg_ShaHash
- the first one with the state updated with
b"aaa"
and then updated with theCookie
andCreateTime
, taken in full - the second one with the state updated with
b"bbb"
and then updated with theCookie
andCreateTime
, truncated to the first4
bytes
- derive a
- Else, if the length of the encrypted data is a multiple of
16
, then- derive an
AES128
key from theSHA1
hash starting with an initial state ofg_ShaHash
, and updated withCookie
andCreateTime
, truncated to the first16
bytes.
- derive an
- If the length of the encrypted data is a multiple of
- Finally, decrypt the memory using the corresponding key and cipher, as well as an IV of
cng!RandomSalt
, all16
bytes forAES128
, or the first8
bytes for3DES
.
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 !
Related work
- A similar analysis of
CryptProtectMemory
on older Windows versions by @eleemosynator