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:
- Spray N copies of a target object — fills available pool slots.
- Free every other one — leaves holes between objects.
- Allocate the
0x1F8HEVD chunk — lands in a hole. - Adjacent chunk is still the target object.
- 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):
IoCompletionReserveobjects — 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:
- Locate the
nt!HalDispatchTable(a table of function pointers, used byNtQueryIntervalProfile). - Overwrite
HalDispatchTable+8with your shellcode address. - 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 (
NtQueryIntervalProfilereturns 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
- HEVD PoolOverflow on Win10 21H1 (older, easier).
- HEVD PoolOverflow on Win11 22H2 (modern, harder grooming).
- Real-world driver pool overflow (find one via fuzzing-windows-drivers / patched-binary-diffing-for-vulnid).
References
- HEVD source
- Connor McGarr — pool feng shui
- Tarjei Mandt — kernel pool research (historic but foundational)
- Pavel Yosifovich — Windows Internals
- See also: hevd-stack-overflow-walkthrough, hevd-uaf-walkthrough, windows-pool-grooming-techniques, arbitrary-read-write-primitives, heap-exploitation-windows