MmMapIoSpace Returns NULL: Tracing the Real Kernel Mechanism Through ntoskrnl.exe

The documentation says MmMapIoSpace returns NULL when physical memory can’t be mapped. What it doesn’t tell you is why, and the explanation that’s been floating around kernel dev communities for years turns out to be incomplete. I traced it all the way down through ntoskrnl.exe on Windows 11 to find the actual mechanism. This is what I found.

While exploring a known physical read/write vulnerability (CVE-2025-8061), I got curious: exactly how far could I take this primitive on its own?

My goal was to weaponize it into a pure data-only attack. By avoiding execution-based vectors (and completely bypassing the modern mitigations like SMEP, SMAP, etc.), I wanted to use the physical read primitive to scan kernel memory for the EPROCESS structures. Once I find the System process and my own exploit process, I could use the write primitive to copy the System token and elevate privileges.

It was a classic Direct Kernel Object Manipulation (DKOM) strategy. It sounded flawless on paper until I actually tried it.

The Target and The Primitive

This research was conducted on a Windows 11 VM (build 26200.8328). The driver used is LnvMSRIO.sys, a version vulnerable to CVE-2025-8061.

The driver’s interaction with user-mode is straightforward. It accepts a custom structure defining the physical address, the operation type, and the size:

typedef struct {
    UINT64 PhysicalAddress;
    ULONG  OperationType;
    ULONG  HowMuch;
} PHYS_READ_INPUT;

When the driver receives this structure, it passes the physical address to MmMapIoSpace to get a virtual address. It then feeds that mapped address into a memcpy wrapper depending on the requested operation type:

BaseAddress = MmMapIoSpace((PHYSICAL_ADDRESS)addr->PhysicalAddress, NumberOfBytes, MmNonCached);
OperationType = addr->OperationType;

switch ( OperationType ) {
	case 1u:
	  memcp1(BaseAddress, dest, addr->HowMuch);
	  break;
	case 2u:
	  memcp2(BaseAddress, dest, addr->HowMuch);
	  break;
	case 8u:
	  memcp3(BaseAddress, dest, addr->HowMuch);
	  break;
}

Usermode Access Violation vs Driver Read

Usermode attempt to dereference 0x1000 results in Access Violation, read through the driver succeeds.

The Scanner and The Crash

With the primitive tested, I wrote a simple scanner to loop through physical memory in chunks:

for (UINT64 addr = START_ADDRESS; addr < MAX_ADDRESS; addr += CHUNK_SIZE) {
	if (ReadPhysicalMemory(hDevice, addr, CHUNK_SIZE, buffer)) {
		printf("[+] Success reading 0x%llX | First 8 bytes: ", addr);
		
		for(int i = 0; i < 8; ++i) {
			printf("%02X ", buffer[i]);
		}
		printf("\n");

	} else {
		printf("[-] Failed to read address 0x%llX (Error: %lu)\n", addr, GetLastError());
	}
}

Once I started the scanner, the VM crashed and threw a bugcheck.

*** Fatal System Error: 0x0000003b
(0x00000000C0000005, 0xFFFFF80754F11EA1, 0xFFFFA7004A0C2B60, 0x0000000000000000)

Bugcheck 0x3B (SYSTEM_SERVICE_EXCEPTION) with parameter 1 being 0xC0000005 means STATUS_ACCESS_VIOLATION.

Checking out the MmMapIoSpace official docs, the function is designed to map a given physical address range into non-paged virtual memory. Drivers use it to interact with memory-mapped hardware registers or, in this case, raw physical memory. But there’s a detail explicitly stated in the kernel documentation: if the physical memory cannot be mapped, MmMapIoSpace returns NULL.

Most probably, the real culprit for the bugcheck was the driver itself. The developers who wrote LnvMSRIO.sys blindly trusted the return value of MmMapIoSpace without implementing a null check.

__int64 __fastcall memcp1(const void *src, void *dst, unsigned int size) {
  qmemcpy(dst, src, size);
  return size;
}

Dereferencing a NULL pointer in Ring 0 instantly triggers an access violation, taking down the entire system.

Just to be sure, I ran the scanner and checked the RAX reg at the end of the function which was indeed 0. But here’s where things get interesting: The null was only returned on the second iteration of the loop.

1: kd> r rcx
rcx=0000000000001000
1: kd> gu
1: kd> r rax
rax=ffffb10a0c7da000
1: kd> g
0: kd> r rcx
rcx=0000000000002000
0: kd> gu
0: kd> r rax
rax=0000000000000000

windbg output with bp set on MmMapIoSpace, first iteration works fine, second one returns NULL, ultimately crashing the VM.

Two Adjacent Pages, Two Different Outcomes

Now that I knew MmMapIoSpace was intentionally returning NULL for physical address 0x2000 (while succeeding for 0x1000), the obvious next question was: why? How does the Windows kernel differentiate between these two adjacent physical pages?

To answer this, I had to look at how the memory manager tracks physical RAM. Windows divides physical memory into 4KB chunks called pages. Every physical page on the system is represented by an entry in an array called the Page Frame Number Database. These entries, represented by _MMPFN structures, track essential metadata about every page.

Because physical pages are 0x1000 bytes (4KB) in size:

  • Physical address 0x1000 corresponds to PFN 1.
  • Physical address 0x2000 corresponds to PFN 2.

I figured the debugging here would be straightforward: dump both PFN entries using WinDbg, compare their attributes, and find out where they diverged.

0: kd> !pfn 1
    PFN 00000001 at address FFFFC10000000030
    flink       00000000  blink / share count 00000001  pteaddress FFFFFBFBD5800000
    reference count 0001    used entry count  0000      Cached    color 0   Priority 0
    restore pte 00000080  containing page 000003  Active     M      
    Modified                
0: kd> !pfn 2
    PFN 00000002 at address FFFFC10000000060
    flink       00000000  blink / share count 00000004  pteaddress FFFFFBFDFEFEF560
    reference count 0001    used entry count  0003      Cached    color 0   Priority 0
    restore pte 00030080  containing page 0002B1  Active     M      
    Modified                
    WSLE age 0 : 0000 oldest leaf PTEs of WSLE age 0

The Cache Aliasing Theory (and Why It’s Irrelevant)

When I first ran into this failure, I found an old discussion on the OSR NTDEV mailing list regarding physical memory mapping. In a 2010 thread titled “Question about \Device\PhysicalMemory”, users were debating how Windows handles memory type aliasing.

The consensus in the thread was that MmMapIoSpace implements strict mitigations against cache conflicts. If a poorly written driver attempts to map a physical page as “MmNonCached” when the kernel already has it mapped as cached, it can result in undefined processor behavior. According to the discussion, MmMapIoSpace actively inspects the PFN database to prevent this aliasing, theoretically failing or overriding the request to protect the system.

It sounded like a solid hypothesis. But with the PFN dumps in hand, I can put that assumption to the test on modern Windows 11.

Notice the caching field on both of the PFN entries: both PFN 1 and PFN 2 are explicitly marked as Cached. Meanwhile, my exploit driver was passing MmNonCached as the CacheType parameter for both mapping requests.

If requesting a non-cached mapping for a cached page triggered a cache mismatch enforcement inside MmMapIoSpace, both calls should have failed. Instead, PFN 1 mapped perfectly, while PFN 2 returned NULL.

Cache aliasing wasn’t the gate. The real mechanism was somewhere deeper and more interesting.

Where the Two Pages Actually Diverge

If caching isn’t the problem, what is? Both pages exist in the OS PFN database and both are marked Active.

The key differences lie in their usage metrics:

  • Used Entry Count: PFN 1 has 0, whereas PFN 2 has 3.
  • Share Count: PFN 1 has 1, whereas PFN 2 has 4.
  • Working Set Tracking: PFN 2 explicitly includes a WSLE age 0 line.

In short: PFN 1 was largely dormant, while PFN 2 was a highly active, heavily shared physical page.

While this told me what kind of page PFN 2 was, it still didn’t give a satisfying answer. Being an active shared page doesn’t automatically explain the exact mechanism the kernel uses to reject the mapping. To find the precise logic gate rejecting PFN 2, I had to trace the disassembly of MmMapIoSpace itself.

Following the NULL Down the Call Chain

MmMapIoSpace is a thin wrapper. It translates CacheType into an internal protection mask (MmNonCached = 0 becomes 0x240) and forwards everything to MmMapIoSpaceEx. Its only explicit NULL return is for invalid CacheType values ≥ 6 which is unreachable for my input.

PVOID __stdcall MmMapIoSpace(PHYSICAL_ADDRESS PhysicalAddress, SIZE_T NumberOfBytes, MEMORY_CACHING_TYPE CacheType)
{
  int cache = CacheType;
  if ( CacheType >= MmMaximumCacheType )
    return nullptr;
  
  v4 = 64;
  if ( cache != 1 ) {
    v4 = 0x240;
    if ( cache == 2 ) v4 = 1028;
  }
  return MmMapIoSpaceEx(PhysicalAddress.QuadPart, NumberOfBytes, v4);
}

I confirmed this with breakpoints: for both PFN 1 and PFN 2, the wrapper translates identically (r8 = 0x240) and calls MmMapIoSpaceEx. PFN 1 gets a valid VA back while PFN 2 gets zero, meaning the rejection happens deeper.

MmMapIoSpaceEx has one inline failure path: a bitmask check on the output of MiMakeProtectionMask. I set breakpoints on both the failure branch and the call to MiMapContiguousMemory:

0: kd> bp fffff807`bdd015b0    ; xor eax, eax (NULL path)
0: kd> bp fffff807`bdd015a1    ; call MiMapContiguousMemory
0: kd> g
Breakpoint 2 hit
nt!MmMapIoSpaceEx+0x31:
fffff807`bdd015a1 e816000000      call    nt!MiMapContiguousMemory

The NULL path never triggers and the request passes straight through to MiMapContiguousMemory.

Finding the Actual Gate: MiFillSystemPtes

MiMapContiguousMemory is a large function, but most of it (alignment, large page optimizations, bugcheck handlers) doesn’t apply to a standard 4KB mapping. The critical logic is a two-step sequence in the middle: reserve PTEs, then fill them.

  pte = MiReservePtes(NonCachedMappingsPteInfo, alignedPageCount);
  if ( !pte )
    return 0;

  if ( MiFillSystemPtes(pte, numberOfPages, currentPfn, protMask, (flag_copy_0 & 2) != 0, &fl) < 0 )
  {
    MiReleasePtes(NonCachedMappingsPteInfo, pte, alignedPageCount);
    return 0;
  }

If MiFillSystemPtes returns a negative NTSTATUS, the reservation is rolled back and the function returns NULL. I set a breakpoint on MiFillSystemPtes and let PFN 2 hit it:

1: kd> bp MiFillSystemPtes
1: kd> g
Breakpoint 3 hit
1: kd> gu
1: kd> r eax
eax=c0000018                  ; STATUS_CONFLICTING_ADDRESSES

0xC0000018: STATUS_CONFLICTING_ADDRESSES. The sign bit is set, so the jns branch falls through into the cleanup path: MiReleasePtes frees the PTEs, EAX is zeroed, and NULL propagates all the way back up to the driver.

The memory manager isn’t passively failing. MiFillSystemPtes is actively evaluating PFN 2 and rejecting it. The next step is to find out why.

Inside MiFillSystemPtes: The Page Table Ownership Checks

To understand why the call failed, I had to reverse MiFillSystemPtes. This internal kernel function is responsible for populating newly reserved PTEs with target Physical Frame Numbers.

The function begins by calculating the memory address of the target page’s entry inside the Page Frame Number (PFN) Database:

PfnEntryBase = 48 * CurrentPfn - 0x220000000000LL;
PfnEntryFieldPtr = 48 * CurrentPfn - 0x21FFFFFFFFDELL;

In 64-bit Windows, every physical page is tracked by a 48-byte _MMPFN structure. The expression 48 * CurrentPfn calculates the byte offset into the PFN database array, using 0x220000000000 as the kernel’s internal base translation constant. For PFN 2, this calculation yields a direct pointer to PFN 2’s _MMPFN structure metadata.

if ( (*(PfnEntryBase + 40) & 0x10000000000LL) == 0 )

This checks offset +0x28. The kernel applies the mask 0x10000000000 (which is 1 << 40, targeting the 40th bit). In the _MMPFN structure, bits 39 through 48 belong to the Partition ID field.

+0x028 u4 : _MI_PFN_FLAGS4 
+0x000 Partition : 0y0000000000 (0) 
+0x000 EntireField : 0x00400000000002b1

0x00400000000002b1 & 0x10000000000 = 0x0. (PASS) The page belongs to the default system partition (Partition 0).

PfnMappedVirtualAddress = (*(48 * CurrentPfn - 0x21FFFFFFFFF8LL) << 25) >> 16;
if ( PfnMappedVirtualAddress >= 0xFFFFF68000000000uLL 
  && PfnMappedVirtualAddress <= 0xFFFFF6FFFFFFFFFFuLL
  && (*(PfnEntryBase + 40) & 0xFFFFFFFFFFLL) != 0x3FFFFFFFFELL )

Check 2A: Is This Page a Live Page Table?

This check verifies whether the page targeted by this PFN entry resides within the OS’s active Page Table region.

To perform this check, the kernel reads the PteAddress pointer at offset +0x08 of the target _MMPFN entry, strips out the kernel’s base offset using a bitwise shift trick, reconstructs the mapped Virtual Address (PfnMappedVirtualAddress), and bounds-checks it against the system’s live PTE_BASE.

The live disassembly from MiFillSystemPtes reveals these instructions executed on the Windows 11 system:

nt!MiFillSystemPtes+0x477:
mov rax, 0FFFFC10000000008h ; MmPfnDatabase base + 0x08 (PteAddress)
mov rax, rax
mov rcx, qword ptr [rax+rbx*8] ; RCX = PteAddress (0xfffffbfd'fefef560)
shl rcx, 19h                   ; RCX = PteAddress << 0x19 (25 decimal)
mov r8, 0FFFFFB8000000000h     ; R8  = Live Randomized PTE_BASE
mov rax, r8
shl rax, 19h                   ; RAX = PTE_BASE << 0x19 (25 decimal)
sub rcx, rax                   ; RCX = (PteAddress << 25) - (PTE_BASE << 25)
sar rcx, 10h                   ; RCX = RCX >> 0x10 (16 decimal) [Sign-Extended]
mov rax, r8
cmp rcx, rax                   ; Compare reconstructed VA against PTE_BASE
jae nt!MiFillSystemPtes+0x7a2  ; Jump if Above or Equal (Out of Bounds)

Here you should notice the difference between the static analysis on the ntoskrnl image, and the live windbg dump:

  • Decompiler Pseudocode: Statically displays 0xFFFFF68000000000 because it reads uninitialized static global defaults from the .data section of ntoskrnl.exe on disk.
  • Live Runtime Execution: The Windows 11 kernel boot process (PTE ASLR) randomized the system’s active base address to 0xFFFFFB8000000000, as hardcoded directly into register R8 (mov r8, 0FFFFFB8000000000h).
+0x008 PteAddress : 0xfffffbfd'fefef560 _MMPTE
  1. The CPU loads 0xfffffbfdfefef560 from MmPfnDatabase + 0x08.
  2. Reconstructing the mapped address via the shift mechanics yields an address falling within the live system page table region (>= 0xFFFFFB8000000000).
  3. Result: (PASS) PFN 2 is verified as a valid, in-bounds Page Table Frame.

Check 2B: The Valid Frame Sentinel

This again accesses offset +0x28. The mask 0xFFFFFFFFFFLL extracts the bottom 40 bits of u4, which encompasses the PteFrame (bits 0-35) and a few adjacent flags. It ensures this value does not equal 0x3FFFFFFFFE.

+0x000 PteFrame : 0y0000000000000000000000000000001010110001 (0x2b1)
+0x000 EntireField : 0x00400000000002b1

0x00400000000002b1 & 0xFFFFFFFFFF = 0x2B1. -> 0x2B1 != 0x3FFFFFFFFE. (PASS) The page has a valid parent PTE Frame (0x2B1).

Check 3: State and I/O Lock Validation

if ( ((*(48 * CurrentPfn - 0x21FFFFFFFFE0LL) & 0x200000) == 0      // 3A
   || (*(48 * CurrentPfn - 0x21FFFFFFFFE8LL) & 0x3FFFFFFFFFFFFFFFLL) != 0 // 3B
   || !*(48 * CurrentPfn - 0x21FFFFFFFFE0LL))                     // 3C
  && (*(48 * CurrentPfn - 0x21FFFFFFFFE0LL) & 0x80000) == 0 )     // 3D

This block relies on offsets +0x20 (u3) and +0x18 (u2) to verify the page is in a stable, active state and not currently locked by disk I/O operations (paging in/out).

+0x020 u3 ... EntireField : 0x560001
+0x018 u2 ... EntireField : 0n4

u3 & 0x200000: The mask 0x200000 isolates bit 21 of u3, which represents ReadInProgress. 0x560001 & 0x200000 = 0x0 (The page is not actively being read from the disk).

u2 & 0x3FFFFFFFFFFFFFFF: This extracts the lower 62 bits of u2, which holds the ShareCount (how many PTEs map this page). u2 EntireField is 4. 4 & 0x3FFFFFFFFFFFFFFF = 4. 4 != 0 is TRUE. (The page is actively shared/mapped).

u3 & 0x80000: The mask 0x80000 isolates bit 19 of u3, which represents WriteInProgress. 0x560001 & 0x80000 = 0x0. 0 == 0 is TRUE. (The page is not actively being written to the disk).

The Verdict So Far

PFN 2 passes every check. It’s a fully initialized page table inside the live, ASLR-randomized Page Table space (> 0xFFFFFB8000000000), anchored to a valid parent frame (PteFrame = 0x2B1), stable, actively shared (ShareCount = 4), and not locked by any I/O.

But there’s one final check inside MiGetPageTablePfnBuddyRaw and this is where PFN 2 fails.

The Final Gate: MiGetPageTablePfnBuddyRaw

When the Windows Memory Manager evaluates a Page Table PFN, it invokes MiGetPageTablePfnBuddyRaw to determine the identity of the owning process (_EPROCESS).

The input argument is a pointer to the target page’s _MMPFN structure in physical memory.

Based on Microsoft public symbols, the offset arithmetic inside MiGetPageTablePfnBuddyRaw maps directly to fields within the 48-byte _MMPFN structure:

  • *a1: u1.EntireField at offset +0x00
  • *(a1 + 0x24): u5.EntireField at offset +0x24

Because an _MMPFN entry is constrained to 48 bytes, the kernel cannot store a full 64-bit _EPROCESS pointer for every page table frame. Instead, it compresses an owner identifier across two separate bitfields inside _MMPFN.

MiGetPageTablePfnBuddyRaw serves as the bitwise decompression algorithm that extracts and reassembles these two segments:

                  [ Upper 10 bits ]             [ Lower 31 bits ]
Field:        u5.Active.PageTableBuddyHigh   u1.Active.PageTableBlinkLow
Bit Range:            [40 : 31]                     [30 : 0]
// Kernel Decompiled Expression
result = ((*(unsigned __int64*)a1 >> 1) & 0x7FFFFFFF) | (((*(unsigned int*)(a1 + 0x24)) & 0x3FF0000) << 15);

Once the composite integer result is reconstructed, MiGetPageTablePfnBuddyRaw resolves it into an _EPROCESS pointer via three distinct control flow paths:

// Branch 1: Unowned / Orphaned Frame Check
if (!result)
    return NULL;

// Branch 2: System Process Magic Sentinel Check
if (result == 0x10000000001LL)
    return PsInitialSystemProcess;

// Branch 3: Partition / Process Array Resolution
return (qword*)(nt!MiState + 0xC078) + (result - 1);

To prove that this bitwise restoration yields a valid _EPROCESS pointer, I located the top-level Page Table (DirBase) of an active target process (lsass.exe) and executed the kernel’s exact decompression math in WinDbg.

0: kd> !process 0 0 lsass.exe
PROCESS ffffdc84f6411080
    SessionId: none  Cid: 0344    Peb: ac5213a000  ParentCid: 02a0
    DirBase: 1611bd000  ObjectTable: ffff908bb2c25ec0  HandleCount: 1168.
    Image: lsass.exe
  • Target _EPROCESS: ffffdc84f6411080
  • Target DirBase: 1611bd000 -> PFN: 0x1611BD (0x1611BD000 >> 12)

Convert the PFN index to its corresponding _MMPFN structure in kernel memory (sizeof(_MMPFN) = 0x30):

0: kd> ? poi(nt!MmPfnDatabase) + ((0x1611bd000 >> 0xc) * 0x30)
Evaluate expression: -105003291028624 = ffffa080`04235370

Execute the kernel’s bitwise extraction logic using WinDbg pseudo-registers. Canonical 64-bit sign extension (| ffff000000000000) is applied to handle unsigned integer wrap-around during array offset multiplication:

0: kd> r @$t1 = (poi(ffffa080`04235370) >> 1) & 0x7fffffff
0: kd> r @$t2 = (dwo(ffffa080`04235370 + 0x24) & 0x3ff0000) << 0f
0: kd> r @$t0 = @$t2 | @$t1
0: kd> r @$t3 = poi(nt!MiState + 0xc078) + ((@$t0 - 1) << 4)
0: kd> ? (@$t3 & 0000ffff`ffffffff) | ffff0000`00000000
Evaluate expression: -39011351457664 = ffffdc84`f6411080

Pass the calculated virtual address back to !process to confirm object integrity:

0: kd> !process ((@$t3 & 0000ffff`ffffffff) | ffff0000`00000000) 0
PROCESS ffffdc84f6411080
    SessionId: none  Cid: 0344    Peb: ac5213a000  ParentCid: 02a0
    DirBase: 1611bd000  ObjectTable: ffff908bb2c25ec0  HandleCount: 1168.
    Image: lsass.exe

The decompressed pointer 0xFFFFDC84F6411080 matches the target process object ffffdc84f6411080 proving that MiGetPageTablePfnBuddyRaw decompresses compressed _MMPFN bitfields back into an active _EPROCESS pointer.

That’s the kernel’s 41-bit compression scheme fully reversed. Given any page table frame, you can walk back to its owning process entirely from memory.

The Dual Life of a 41-Bit Field

The PFN 2 had 0 for its _EPROCESS owner, but when would a PFN contain the expected value 0x10000000000?

To answer this, I had to look past the idea that the buddy field in an _MMPFN structure strictly holds a static process pointer (PEPROCESS) or partition index. While active user-mode page tables point to their owning process, the kernel also uses this compressed 41-bit field as a transient operational indicator.

When a PFN evaluates to 0x10000000000 (isolating Bit 40), it does not represent the owner. Instead, it flags a physical page that is currently undergoing an active In-Page / Pagefile I/O operation. It tells the memory manager that the page is temporarily locked while its contents are being asynchronously fetched from secondary storage into physical RAM.

Where the Sentinel Gets Set: MiReadPagefilePage

Tracing calls to MiSetPageTablePfnBuddy across ntoskrnl.exe reveals the exact kernel function responsible for setting this value: MiReadPagefilePage.

This internal routine handles faulting pages back into memory from either the pagefile on disk (IoPageReadEx) or the compressed store in RAM (SmPageRead).

// Decompiled excerpt from nt!MiReadPagefilePage
while ( 1 )
{
    // ... [Setup I/O Parameters & MDL] ...

    // STAMP TRANSIENT SENTINEL BEFORE DISPATCHING I/O
    MiSetPageTablePfnBuddy(48 * a1 - 0x220000000000LL, 0x10000000000uLL, 0);

    // DISPATCH ASYNCHRONOUS READ
    if ( v10 )
    {
        v16 = SmPageRead(*(*(v15 + 216) + 184LL), &BugCheckParameter4, &MemoryDescriptorList, &Event, &v25);
    }
    else
    {
        v16 = IoPageReadEx(*(v15 + 24), &v25, 0, 0);
    }

    // WAIT FOR I/O COMPLETION
    KeWaitForSingleObject(&Event, WrPageIn, 0, 0, nullptr);

    // ... [Validate Page Hash & Check Status] ...

    // CLEAR SENTINEL UPON COMPLETION
    result = MiSetPageTablePfnBuddy(48 * a1 - 0x220000000000LL, 0, 0);

    if ( LowPart >= 0 )
        break;
}

The In-Page Sentinel Lifecycle

The lifecycle of the 0x10000000000 sentinel follows a bracketed pattern designed to protect the page state during I/O operations:

  • Pre-I/O Stamping: Before calling IoPageReadEx or SmPageRead, MiReadPagefilePage invokes MiSetPageTablePfnBuddy with a2 = 0x10000000000.
  • As established in the analysis of MiSetPageTablePfnBuddy, the mask filter (0x10000000000 & 0xFFFFFEFFFFFFFFFE) evaluates to 0. This bypasses array index conversions.
  • Lower bits yield 0 for u1.EntireField (offset +0x00), while Bit 40 shifts into Bit 25 of u5.EntireField.
  • Execution Gate: While the storage driver reads the block off disk, any concurrent kernel thread attempting to evaluate or modify this PFN will decompress the field via MiGetPageTablePfnBuddyRaw. The function reconstructs 0x10000000000, informing the memory manager that the frame is actively being populated and cannot be altered or repurposed.
  • Post-I/O Cleanup: Once KeWaitForSingleObject unblocks and MiValidatePagefilePageHash verifies data integrity, MiReadPagefilePage calls MiSetPageTablePfnBuddy(Pfn, 0, 0). This wipes Bit 40 back to 0, releasing the transient lock.

What’s worth noting here is that the kernel reuses the same compressed bitfield for two completely different purposes: a static process owner identity during normal operation, and a transient I/O lock during page fault resolution. The same 41 bits carry different semantics depending on context. It’s an elegant design choice.

When MiGetPageTablePfnBuddyRaw later evaluates a stamped frame, the decompression math reconstructs 0x10000000000 from the packed bits (u1 = 0, u5 bit 25 set). This tells downstream routines like MiFillSystemPtes that the page is valid and actively transitioning (not orphaned).

Why Some Page Tables Have No Owner

During live memory analysis, certain active page table frames (such as PFN 2) yield 0 for both u1.EntireField and u5.EntireField. Passing these values into MiGetPageTablePfnBuddyRaw produces a composite index of 0, forcing the function down Branch 1 (return NULL;).

Setting write hardware access watchpoints (ba w8 and ba w4) on PFN 2 (0xFFFFA08000000060) during the kernel boot sequence reveals the callstacks responsible for this state.

The WinDbg execution trace captures two phases in the lifecycle of PFN 2 during kernel startup:

# Child-SP          RetAddr           Call Site
00 fffff805`5cfce408 fffff805`cc052494 nt!KeZeroPages+0x38
01 fffff805`5cfce410 fffff805`cc052278 nt!MxMapVa+0x1d0
02 fffff805`5cfce460 fffff805`cc050540 nt!MxMapPfnRange+0x1b0
03 fffff805`5cfce4d0 fffff805`cc04e152 nt!MiCreateSparsePfnDatabase+0xb8
04 fffff805`5cfce510 fffff805`cc04e575 nt!MiCreatePfnDatabase+0x142
05 fffff805`5cfce540 fffff805`cbfdd39e nt!MiInitNucleus+0x15d
06 fffff805`5cfce590 fffff805`cc00a111 nt!MmInitSystem+0xb6

During Phase 0 memory initialization MiInitNucleus, the kernel allocates physical backing memory for MmPfnDatabase. KeZeroPages fills every byte of the newly allocated _MMPFN entry with 0. At this stage, all bitfields are identically zero.

# Child-SP          RetAddr           Call Site
00 fffff805`5cfce218 fffff805`cc05191c nt!MiCopyPfnEntryRaw+0x17
01 fffff805`5cfce220 fffff805`cc051b4b nt!MxCreatePfn+0xb8
02 fffff805`5cfce280 fffff805`cb60fc1a nt!MxCreatePfnsForPtes+0x19b
03 fffff805`5cfce300 fffff805`cb60fc59 nt!MiWalkPageTablesRecursivelyNoSynch+0x132
04 fffff805`5cfce340 fffff805`cb60f9fc nt!MiWalkPageTablesRecursivelyNoSynch+0x171
05 fffff805`5cfce380 fffff805`cc050b04 nt!MiWalkPageTables+0x22c
06 fffff805`5cfce440 fffff805`cc04e58b nt!MiInitializePfnsForValidMappings+0x88
07 fffff805`5cfce540 fffff805`cbfdd39e nt!MiInitNucleus+0x173

Shortly after creating the database, MiInitNucleus calls MiInitializePfnsForValidMappings. The kernel recursively walks the pre-existing hardware page tables set up by the Windows Boot Loader.

MxCreatePfn populates state bits for valid physical frames, but because executive objects (such as PsInitialSystemProcess or user process structures) do not yet exist in Phase 0, MiCopyPfnEntryRaw copies the raw bootstrap parameters without linking an owning process or partition handle.

There are three possible architectural reasons why PFN 2 and other early page tables retain 0 in their owner bitfields:

  1. Pre-Executive Lifecycle: The page table was established before the Executive Process Manager (PsInitSystem) initialized. At the time MiInitializePfnsForValidMappings runs, no _EPROCESS object exists to register as the buddy owner.
  2. Global / Shared Bootstrap Infrastructure: Pages allocated by winload.exe or early nt!Mx* routines serve fundamental kernel mapping functions (such as HAL, early page tables, or core system nucleus) rather than isolated user-mode process spaces.
  3. Static Allocation: These page tables are permanent and non-pageable. Because the Memory Manager will never page them out to disk or trim them during working-set reduction, it does not need to maintain an _EPROCESS or _MI_PARTITION back-link for working-set management.

Connecting the Dots: Why PFN 1 Passed and PFN 2 Failed

Now, connecting this back to the exploit crash: why did PFN 2 fail while PFN 1 mapped perfectly?

Looking at the decompiled routine for MiFillSystemPtes, I could see exactly where the execution paths for the two physical pages diverged. PFN 1 didn’t succeed due to having a valid buddy owner (in fact, it didn’t even have one when I checked using WinDbg). Instead, it succeeded because it bypassed the buddy check entirely.

To understand why, I had to look at the Virtual Address boundary check that precedes the call to MiGetPageTablePfnBuddyRaw:

PfnMappedVirtualAddress = (*(48 * CurrentPfn - 0x21FFFFFFFFF8LL) << 25) >> 16;
if ( PfnMappedVirtualAddress >= 0xFFFFF68000000000uLL 
  && PfnMappedVirtualAddress <= 0xFFFFF6FFFFFFFFFFuLL
  // ...

This gate exists for one specific reason: the memory manager only cares about executing MiGetPageTablePfnBuddyRaw only if the physical page in question is actively functioning as a Page Table.

To determine if a page is a Page Table, the kernel reads its parent PteAddress, reverse-calculates the Virtual Address that this page is responsible for mapping, and checks if that reconstructed VA falls inside the system’s live Page Table region (which is >= 0xFFFFFB8000000000 with ASLR applied on the live system).

For PFN 2, this reconstructed VA fell into the live page table region, so the kernel proceeded to the MiGetPageTablePfnBuddyRaw owner check where it failed.

For PFN 1, however, its PteAddress field contained 0xFFFFFBFBD5800000. Running this through the bit-shift math yields a virtual address far outside the bounds of the live page table region. Because it didn’t look like an active Page Table, MiFillSystemPtes bypassed the strict buddy-owner checks entirely and successfully mapped it.

Conclusion

What began as a straightforward attempt to weaponize a physical read/write primitive into a classic DKOM token-swapping attack evolved into a deep dive into modern Windows 11 memory manager internals. The initial assumption, that physical memory can be linearly scanned like a continuous byte array, collided directly with undocumented kernel state checks built into ntoskrnl.exe, which effectively caused the poorly written driver to crash.

Summary of Key Findings

  • Cache Aliasing vs. Page Table Protection: While older OSR NTDEV community discussions attributed physical memory mapping failures in MmMapIoSpace primarily to cache-type aliasing enforcement (such as requesting MmNonCached on a Cached page), tracing the execution revealed a different primary gatekeeper. On modern Windows 11, the mapping of physical RAM was actively rejected by Page Table ownership checks inside MiFillSystemPtes well before any potential cache-aliasing rules could be enforced.
  • The Root-Cause Mechanism: The rejection of physical address 0x2000 (PFN 2 and other page tables) occurs deep within MiFillSystemPtes. Because PFN 2 operates within the system’s live, ASLR-randomized Page Table region, the kernel attempts to decompress its 41-bit owner identity from the _MMPFN.
  • The Unowned Page Table Edge Case: Because PFN 2 is an early-boot page table frame established before executive process structures exist, it carries no registered EPROCESS owner or transient in-page I/O constant (0x10000000000, as stamped by MiReadPagefilePage). Consequently, MiGetPageTablePfnBuddyRaw returns NULL, forcing MiFillSystemPtes to fail with STATUS_CONFLICTING_ADDRESSES.
  • The Vulnerability Impact: The kernel’s decision to return NULL from MmMapIoSpace is intentional and documented behavior. However, vulnerable drivers like LnvMSRIO.sys (CVE-2025-8061) that blindly dereference this return value without validation turn an internal memory management boundary check into a system-fatal Bugcheck 0x3B (STATUS_ACCESS_VIOLATION).

Final Takeaways

This research demonstrates why linear physical memory scanning is fundamentally unfeasible for reliable exploitation on modern Windows systems. Weaponizing physical read/write primitives requires moving away from blind scanning in favor of targeted Virtual Address (VA) to Physical Address (PA) translation.

High-level documentation tells you what a function does. Assembly tells you what it actually does. The gap between those two things is where the interesting stuff lives.

*