Reading EPROCESS and CR3 from MmPfnDatabase

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.


Reading EPROCESS and CR3 from MmPfnDatabase

After reading two writeups — one a “thought experiment” on bypassing CR3 protection, the other on identifying whether an arbitrary physical frame is a CR3 and decrypting its owning EPROCESS on Windows 10 1909 — I went looking for an actual implementation and couldn’t find one. So I bolted together a working version myself, borrowing a little from here, a little from there.

1. Locating MmPfnDatabase via KeCapturePersistentThreadState

In a remote windbg session, disassemble the function:

u KeCapturePersistentThreadState l 50

Disassembly of KeCapturePersistentThreadState in windbg

The code below parses out the mov target. I’ll be honest — the instruction-walking logic is cribbed, and I don’t fully follow every byte, but it reliably returns the MmPfnDatabase address:

ULONG64 EnumMmPfnDatabaseAddr() {
	// Get the KeCapturePersistentThreadState address
	UNICODE_STRING uc_funcName;
	RtlInitUnicodeString(&uc_funcName, L"KeCapturePersistentThreadState");
	ULONG64 ul_funcAddr = MmGetSystemRoutineAddress(&uc_funcName);
	if (ul_funcAddr == NULL)
	{
		return 0;
	}

	//DbgPrint("KeCapturePersistentThreadState addr = %p \n", ul_funcAddr);

	for (INT i = 0; i < 0x120; i++) {
		if (*(PUCHAR)(ul_funcAddr + i) == 0x48 && *(PUCHAR)(ul_funcAddr + i + 1) == 0x8b && *(PUCHAR)(ul_funcAddr + i + 2) == 0x05) {
			// Parse the mov displacement
			INT i_movCode = *(INT*)(ul_funcAddr + i + 3);
			ULONG64 ul_movJmp = ul_funcAddr + i + i_movCode + 7;

			// This is MmPfnDatabase
			return ul_movJmp;
		}
	}
}

2. Walking MmPfnDatabase to find the entry that matches the target EPROCESS

ULONG64 DecryptEProcess(LONG64 ListEntryFlink) {
	//DbgPrint("before: %p\n", ListEntryFlink);

	ListEntryFlink |= 0xF000000000000000;
	ListEntryFlink = ListEntryFlink >> 0xd;

	//DbgPrint("after: %p\n", ListEntryFlink);
	return ListEntryFlink;
}

// Get CR3 via MmPfnDatabase
ULONG64 EnumCR3() {
	ULONG64 MmPfnDatabase = *(PULONG64)EnumMmPfnDatabaseAddr();

	//DbgPrint("MmPfnDatabase: %p\n", MmPfnDatabase);

	INT MmNumberOfPhysicalPages = 0x3F9100;//0x7ff00;//0x3F9100;
	PMMPFN tempMMPFN = NULL;
	for (ULONG64 i = 0; i < MmNumberOfPhysicalPages; i++) {
		tempMMPFN = (PMMPFN)(MmPfnDatabase + i * sizeof(MMPFN));
		if (tempMMPFN->ListEntry.Flink != 0) {
			if (DecryptEProcess(tempMMPFN->ListEntry.Flink) == g_targetProcess) {
				return i << 12ull;
			}
		}
	}

	return 0;
}

DecryptEProcess comes straight from the second writeup. On other Windows versions you can rewrite the function via MiSetPageTablePfnBuddy.

MmNumberOfPhysicalPages throws an unresolved-symbol error in windbg:

kd> dd MmNumberOfPhysicalPages
Couldn't resolve error at 'MmNumberOfPhysicalPages'

So I just derived it from my host: 16273 * 1024 / 4 = 0x3f9100.

The MMPFN struct below is a slapdash definition — I only care about the first 8 bytes, and as long as sizeof(MMPFN) is 0x30 it works. The layout differs across Windows versions; check it with dt _MMPFN:

typedef struct _MMPFN {
	union {
		LIST_ENTRY ListEntry;          // +0x000
		RTL_BALANCED_NODE TreeNode;    // +0x000
		ULONG64 u1;						// +0x000
		ULONG64 PteAddress;            // +0x008
		ULONGLONG PteLong;            // +0x008
		ULONG64 OriginalPte;            // +0x010
	};

	ULONG64 u2;
	ULONG32 u3;
	UCHAR NodeBlinkLow[2];
	UCHAR Unused;

	union {
		UCHAR ViewCount;
		UCHAR NodeFlinkLow;
		UCHAR ModifiedListBucketIndex;
	};

	ULONG64 u4;

} MMPFN, * PMMPFN;

g_targetProcess comes from walking PspCidTable and matching the process name — that part is well documented elsewhere.

3. The result

My test account got banned, so I couldn’t verify it live in the game; notepad had to stand in. The driver:

NTSTATUS EnumProcessByPspCidTable(char* targetName) {
	PHANDLE_TABLE pHandleTable = NULL;
	pHandleTable = (PHANDLE_TABLE) * (PSIZE_T)GetPspCidTable();
	//DbgPrint("pHandleTable:%p\n", pHandleTable);

	g_targetProcess = EnumHandleTable(pHandleTable, targetName);

	if (g_targetProcess == 0)
		return g_targetProcess;

	DbgPrint("PEPROCESS %p\n", g_targetProcess);

	PULONG64 pcr3 = (LONG64)g_targetProcess + 0x28;
	g_targetCR3 = *pcr3;

	DbgPrint("g_targetCR3: %p\n", g_targetCR3);

	g_targetCR3 = EnumCR3();

	DbgPrint("g_targetCR3: %p\n", g_targetCR3);

	return STATUS_SUCCESS;
}

The two CR3 values it produced matched.

Both enumerated CR3 values match


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