HEVD pool overflow — walkthrough

HEVD pool overflow — walkthrough

TL;DR: HEVD’s PoolOverflow IOCTL allocates a fixed-size chunk in the NonPagedPool then copies a user-controlled length into it → adjacent pool corruption. Modern exploitation requires (1) groom the pool to control what’s adjacent, (2) overwrite a target object’s vtable / function pointer / token, (3) trigger the target to use the corrupted field. The classic “grooming + targeted overflow” pattern that generalises to most kernel pool bugs. Companion to hevd-stack-overflow-walkthrough and windows-pool-grooming-techniques.

The bug

1
2
3
4
5
6
7
8
NTSTATUS TriggerPoolOverflow(PVOID UserBuffer, SIZE_T Size) {
    PVOID KernelBuffer = ExAllocatePoolWithTag(NonPagedPool, 0x1F8, (ULONG)'kcaH');
    if (!KernelBuffer) return STATUS_NO_MEMORY;
    ProbeForRead(UserBuffer, sizeof(KernelBuffer), sizeof(UCHAR));
    RtlCopyMemory(KernelBuffer, UserBuffer, Size);     // ← Size attacker-controlled
    ExFreePoolWithTag(KernelBuffer, (ULONG)'kcaH');
    return STATUS_SUCCESS;
}

0x1F8 allocation in NonPagedPool; copy of Size bytes (attacker-controlled).

Pool layout primer

NonPagedPool allocations are taken from per-CPU lookaside lists (small) or the segmented pool (larger). Each pool chunk has a header (POOL_HEADER) containing:

  • PreviousSize / BlockSize — used for pool integrity checks.
  • PoolType — pool kind.
  • PoolTag — your 'kcaH'.
  • PoolIndex, ProcessBilled (for quota chunks).

Modern Windows pool has integrity checks: corrupting headers triggers bugcheck BAD_POOL_HEADER.

For overflow exploitation: don’t corrupt headers; corrupt the content of the next chunk, which must be a known object whose contents include a pointer you control.

Step 1 — pool grooming (overview)

To make the next chunk after our 0x1F8 allocation be an object we choose:

  1. Spray N copies of a target object — fills available pool slots.
  2. Free every other one — leaves holes between objects.
  3. Allocate the 0x1F8 HEVD chunk — lands in a hole.
  4. Adjacent chunk is still the target object.
  5. Overflow into it.

See windows-pool-grooming-techniques for the detailed technique.

A common target object: TypeIndex Pipe objects / NamedPipe attributes / I/O Completion. Pre-2020 — many of these had exploitable layout. Modern Windows hardened (random pool offset, isolation).

Step 2 — choose a target object

Classic targets (build-dependent):

  • IoCompletionReserve objects — contain function pointers callable from user space.
  • Pipe Attribute List — buffer with controlled contents; modifiable from user space.
  • RegistryKey objects — vtable-ish field used in kernel paths.

Modern target: Pipe attributes via NtFsControlFile with FSCTL_PIPE_ASSIGN_EVENT. Attacker creates many named pipes, sets their attribute buffers; each is allocated at a controllable size in pool.

For HEVD on older Windows, the historical target is a tagged WNF state, but on modern builds use pipe attributes.

Step 3 — spray and free

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define SPRAY_COUNT 0x1000

HANDLE pipes[SPRAY_COUNT];
PVOID attrs[SPRAY_COUNT];

for (int i = 0; i < SPRAY_COUNT; ++i) {
    // create pipe with controlled attribute size that lands in same size class as 0x1F8
    create_pipe_with_attr(/* attr size */);
    pipes[i] = ...;
    attrs[i] = ...;
}

// Free every other pipe to create holes
for (int i = 0; i < SPRAY_COUNT; i += 2) close_pipe(pipes[i]);

// Now allocate the HEVD chunk via IOCTL

Step 4 — overflow content

The overflow writes attacker-controlled bytes into the next chunk. If next chunk is a pipe attribute buffer, you’ve changed the buffer contents — but that alone doesn’t give code execution.

You need the corrupted field to be referenced by a kernel code path. Two patterns:

Pattern A — overwrite a function pointer

If the adjacent object has a function-pointer field at a known offset, you write your own pointer there. When a kernel code path calls that function, RIP = attacker.

Pattern B — overwrite size / length to enable further OOB

Write a larger Size field into adjacent object. Subsequent legitimate access reads/writes past the object → arbitrary read/write primitive (see arbitrary-read-write-primitives).

Step 5 — chain to RIP control

Once you have arbitrary write:

  1. Locate the nt!HalDispatchTable (a table of function pointers, used by NtQueryIntervalProfile).
  2. Overwrite HalDispatchTable+8 with your shellcode address.
  3. Call NtQueryIntervalProfile(0x1234, &buf) from userland → kernel calls the overwritten pointer → your shellcode.

Modern variant: HalDispatchTable is harder to leak; use PhysicalMemoryDescriptor/PMI handler/other.

Step 6 — shellcode and return

Token-stealing shellcode (see hevd-stack-overflow-walkthrough § 5).

For swapgs; iretq clean return: preserve original RBP/RSP, swap GS register only if in kernel context, then iretq to saved userland state.

Step 7 — KASLR + pool grooming reliability

Reliability is the hard part:

  • Pool grooming success rate is < 100% per attempt.
  • Wrap exploit in a loop with limited retries.
  • Detect failure (NtQueryIntervalProfile returns instead of giving SYSTEM) and retry.

Spray sizes:

  • Larger spray = higher success rate, larger memory footprint.
  • Too large = OS notices pool exhaustion.

Common failures

Symptom Cause
BAD_POOL_HEADER overflow corrupted header; tune length down
IRQL_NOT_LESS_OR_EQUAL reference to invalid pointer after partial overflow
Bug fires but no SYSTEM target object wasn’t adjacent; re-spray
Hangs infinite loop in shellcode; check return path

Practice ladder

  1. HEVD PoolOverflow on Win10 21H1 (older, easier).
  2. HEVD PoolOverflow on Win11 22H2 (modern, harder grooming).
  3. Real-world driver pool overflow (find one via fuzzing-windows-drivers / patched-binary-diffing-for-vulnid).

References