NPT Shadow Pages: Serving Fake Memory to the Kernel
Nested Page Tables give me total control over what the guest sees when it reads physical memory. I can make any guest physical address resolve to any host physical address. This means I can serve fake data to the kernel -- a modified copy of a page where a single variable has been changed -- and the kernel has no way to tell it's reading a forgery.
This is the mechanism behind the VBS spoof from post 03, but it's useful for far more than that. Any kernel variable, any driver global, any data structure in physical memory can be shadowed. This post is the deep dive on how the shadow page mechanism works, including the painful parts I glossed over before.
How NPT works
NPT is a second layer of address translation. The guest has its own page tables (CR3) that translate virtual addresses to guest physical addresses (GPAs). NPT then translates those GPAs to host physical addresses (HPAs). The guest can't see NPT -- it's managed entirely by the hypervisor and enforced by the CPU.
I build NPT as identity-mapped for most of memory: GPA 0x1000 maps to HPA 0x1000. The guest accesses physical memory normally. But for specific pages, I break the identity mapping. GPA 0x12345000 might map to HPA 0x99999000 -- my shadow page.
The split: 2MB to 4KB
NPT uses the same 4-level page table format as regular x86-64 paging. For efficiency, I use 2MB large pages at the PDE level -- one PDE covers 2MB of contiguous physical memory. This keeps the table small and reduces TLB pressure.
But when I need to shadow a single 4KB page within a 2MB region, I have to split that PDE into 512 individual 4KB PTEs. Each PTE maps a 4KB chunk of the original 2MB region. Then I can change just one PTE to point to my shadow page.
fn split_2mb_to_4kb(npt: &mut NptTables, gpa: u64) {
let pde_idx = (gpa >> 21) & 0x1FF;
let pde = &mut npt.pd[pde_idx];
// allocate a new page table (512 entries)
let pt = allocate_page_table();
let base_pa = pde.pfn() << 12;
// fill 512 4KB entries covering the same 2MB region
for i in 0..512 {
pt[i] = NptEntry::new_4kb(base_pa + i * 0x1000, WB);
}
// replace the 2MB PDE with a pointer to the new PT
*pde = NptEntry::new_pde(virt_to_phys(pt));
}
Building the shadow
Once the split is done, creating a shadow is straightforward. I allocate a fresh 4KB page, copy the original page contents into it, modify whatever I need in the copy, and remap the NPT entry.
fn create_shadow(npt: &mut NptTables, target_gpa: u64,
modify: impl FnOnce(&mut [u8; 4096])) {
// step 1: split the covering 2MB page
split_2mb_to_4kb(npt, target_gpa);
// step 2: allocate shadow and copy original
let shadow = allocate_page();
let original_hpa = target_gpa; // identity mapped
copy_physical_page(original_hpa, shadow);
// step 3: apply the modification
let shadow_va = phys_to_virt(shadow);
modify(unsafe { &mut *(shadow_va as *mut [u8; 4096]) });
// step 4: remap NPT -- guest now reads shadow
let pte_idx = (target_gpa >> 12) & 0x1FF;
npt.pt[pte_idx] = NptEntry::new_4kb(shadow, WB);
}
The guest reads the shadowed GPA and gets my modified copy. The real page is still there in host memory, untouched. If I ever need to un-shadow, I just point the PTE back to the original HPA.
The npt_pa_to_va problem
Here's the part that doesn't show up in any AMD manual or hypervisor tutorial. When I allocate NPT pages using ExAllocatePool, I get virtual addresses. The NPT entries need physical addresses (PFNs). So I convert VA to PA and store the PA in the entry. Easy.
But later, when I need to modify an NPT entry, I need to go the other way -- PA to VA. I have the physical address of a page table page and I need its virtual address to write into it. MmGetVirtualForPhysical is the obvious API. But it returns null for pool-allocated memory. It only works for memory that was mapped through MmMapIoSpace or similar.
My solution is ugly but reliable: I maintain a parallel array in G_NPT.pages[] that stores both the VA and PA of every NPT page I allocate. When I need PA-to-VA, I linear-search this array. It's O(n) where n is the number of NPT pages, which is typically under 100. Not elegant. But it works, it's simple, and it runs at allocation time (not in the hot exit path), so performance doesn't matter.
What I shadow
Right now I use shadow pages for three things: g_CiOptions in CI.dll (HVCI spoof, covered in post 03), stolen pages (hiding hypervisor memory from the guest's physical map), and experimental KUSER_SHARED_DATA modifications (which turned into a disaster -- that's post 08).
The mechanism is general-purpose though. Anything in guest physical memory can be shadowed. Kernel globals, driver data, firmware tables, ACPI structures -- if it has a physical address, I can serve a fake copy. The guest reads what I want it to read.