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:

Computing the MMPFN address

00008000638fe613` is the encrypted object address:

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:

Breakpoint on MiGetPageTablePfnBuddyRaw

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:

Decrypting the object address on 24H2

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

The MiGetPfnPidSafe caller

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