Decrypting CR3 via MMPFN: Reading the Real Directory Base on Windows 7, 10, and 11
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
Decrypting CR3 via MMPFN: Reading the Real Directory Base on Windows 7, 10, and 11
Here’s a trick for recovering the real CR3 without pulling DirectoryTableBase out of the EPROCESS — which matters because that field is exactly what gets encrypted and hidden.
// Walk MMPFN to get the real CR3 address
for (ULONG64 i = 0; i < maxCount; ++i)
{
ULONG64 Buddy = (ULONG64)local_MMPFN[i].u1.Buddy;
if (Buddy == NULL)
continue;
// compute the decrypted EPROCESS value
ULONG64 EPROCESS = (((Buddy>>0xD)&0xFFFFFFFFFFFFFFF0)|0xFFFF800000000000);
if (Buddy == eproc || EPROCESS== eproc)
{
dribase = i<< PAGE_SHIFT; // this is your real CR3 address
break;
}
}
We don’t need to read dirbase out of EPROCESS, regardless of whether CR3 is encrypted:
0: kd> dt _KPROCESS
nt!_KPROCESS
+0x000 Header : _DISPATCHER_HEADER
+0x018 ProfileListHead : _LIST_ENTRY
+0x028 DirectoryTableBase : Uint8B
What CR3 encryption actually does
The way CR3 gets “encrypted” is that anti-cheats rewrite the value of EPROCESS->DirectoryTableBase and then take over the kernel’s global exception handling.
When KeAttachProcess runs, or when an invalid CR3 is used, it faults — and the fault gets routed into the anti-cheat’s exception handler, which either reports you or hands you back empty bytes. That’s why repeatedly attaching to a process context gets you caught (or reads nothing).
As for why the decode is (((Buddy>>0xD)&0xFFFFFFFFFFFFFFF0)|0xFFFF800000000000) — go ask Microsoft.
I won’t comment on the pile of decryption methods floating around out there; use whatever works for you.
Windows 7 through 10 use this fixed arithmetic. On Windows 7 the EPROCESS stored in MMPFN isn’t even transformed — it’s just the object address. Windows 10 needs the bit operations to reconstruct the object address. This method returns the correct EPROCESS whether or not it was encrypted.
maxCount is the maximum number of physical pages, available via MmGetPhysicalMemoryRanges.
A version-agnostic method for Windows 11 (24H2 example)
Owning Process ffffad8b1c7f3080 Image: procexp64.exe
ffffad8b1c7f3080+0x28=0x0000001146a3002 CR3 00000001`146a3002&~FFF >> 12 =1146A3 page-directory index
The MMPFN structure is a fixed 0x30 in size. +0x0 is the encrypted object address, +0x8 is PteBaseAddress (on Windows 7, +0x10 is TebBaseAddress).
So the MMPFN address is MmPfnDataBase + 0x1146A3 * 0x30:

00008000638fe613` is the encrypted object address:

Breaking in the debugger on MMPFN shows that MiGetPageTablePfnBuddyRaw is the function that decrypts the object address. Its first argument is the MMPFN address:

mov rdx, 00008000638fe613h // encrypted object address (EPROCESS)
mov ecx, 0401ffffh // value at MMPFN+0x24
and ecx, 3FF0000h
shr rdx, 1
shl rcx, 0Fh
btr edx, 1Fh
or rcx, rdx
mov rax, 0FFFFFF0000000000h
add rax, rcx
dec rcx
shl rcx, 4
add rcx, ffffad8000000000h // dynamic like PTE — changes every boot
ret
The second add reads from cs:qword_140E38538, which holds ffffad8000000000h; that value is dynamic like a PTE and changes on every boot.
uint64_t DecryptProcess(uint64_t encrypt)
{
// 0x401ffff = the ULONG at MMPFN+0x24
// 0xffffad8000000000 = dynamically read ULONG64
// both values above must be read by yourself
return ((encrypt >> 1 | ((unsigned __int64)(0x401ffff & 0x3FF0000) << 15)) - 1) * 0x10 | 0xffffad8000000000;
}
On 24H2 this algorithm decrypts the object address directly:

MiGetPageTablePfnBuddyRaw’s caller is MiGetPfnPidSafe — as the name says, it safely gets the Pfn’s PID. Let’s look inside:

MiGetPfnPidSafe(__int64 a1, unsigned int a2) // arg1 = MMPFN address, arg2 = flag (write 1)
v5 = MiGetPageTablePfnBuddyRaw(a1); // decrypted object address (EPROCESS)
return *(unsigned int *)(v5 + 0x1D0);
0: kd> dt _EPROCESS
nt!_EPROCESS
+0x000 Pcb : _KPROCESS
+0x1c8 ProcessLock : _EX_PUSH_LOCK
+0x1d0 UniqueProcessId : Ptr64 Void
Summary
On Windows 11, any version: call MiGetPageTablePfnBuddyRaw to get the decrypted object address, or call MiGetPfnPidSafe to get the PID that owns a given MMPFN.
That’s it. The layout is rough, but the method is solid.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch