▸ On this page 6 sections
Background
Kernel pool exploits often require a heap layout you can predict. The Windows segment heap (since 21H1) and the older lookaside/list-based allocator before it both respond to spraying and grooming - deliberately allocating and freeing objects to force the kernel to place your target object adjacent to a controlled allocation.
This is a research note on the core concepts. All vulnerability context is from patched, publicly disclosed CVEs and CTF challenges.
The kernel pool allocators
Windows uses different allocators depending on the pool type and allocation size:
- NonPagedPoolNx - executable protections stripped; the primary target for data-only exploits
- PagedPool - pageable; used for many object types
- Lookaside lists - per-CPU caches for fixed-size allocations; predictable but size-class bound
- Segment heap (Win10 21H1+) - randomized VSEGs, metadata-hardened; harder to groom than the legacy allocator
!pool <address>
dt nt!_POOL_HEADER <address>
The segment heap adds pool header encoding, guard pages, and randomized segment placement. Techniques that relied on deterministic chunk adjacency in the legacy allocator require rethinking for modern builds.
Basic grooming strategy
The classic approach, still valid for fixed-size lookaside allocations:
- Spray - fill the lookaside list and relevant pool pages with controlled objects of the same size as your target
- Punch holes - free every other object, creating a predictable pattern of free chunks
- Allocate - trigger the vulnerable allocation; it lands in one of your holes, adjacent to a controlled object
// Spray 0x200 pipe attributes - each is a fixed-size pool allocation
for (int i = 0; i < 0x200; i++) {
spray_handles[i] = create_pipe_attribute(spray_buf, SPRAY_SIZE);
}
// Free alternating to create holes
for (int i = 0; i < 0x200; i += 2) {
CloseHandle(spray_handles[i]);
}
// Now trigger the vulnerable allocation - should land adjacent to a held object
trigger_vuln();
On a live engagement, kernel exploits are almost never the right tool - the noise, crash risk, and BSOD-on-failure make them unsuitable except in very specific circumstances. This is CTF and research context.
Useful spray primitives
The choice of spray object matters - it needs to be user-controllable, match the target size class, and be releasable individually. Common primitives:
| Primitive | Pool type | Notes |
|---|---|---|
NtCreateEvent |
NonPagedPoolNx | Fixed-size, individually closeable |
| Named pipe attributes | PagedPool | Controllable payload size |
IoCompletionReserve |
NonPagedPoolNx | Small, fast |
NtAllocateReserveObject |
NonPagedPoolNx | Designed for spraying |
Always verify your spray lands in the right pool type and size class with WinDbg before building the exploit around it. A mismatch in size class means the allocations go to a different lookaside list and your grooming assumptions break.
Verifying layout in WinDbg
After spraying, inspect the pool to confirm adjacency:
# Find spray objects in pool
!poolfind <tag> 0 ; 0 = NonPagedPool
# Inspect chunk layout around a known address
dt nt!_POOL_HEADER <spray_addr - 0x10>
db <spray_addr - 0x20> L0x60
Confirm the pattern: your spray object, a free chunk, another spray object. When the vulnerable allocation fills the free chunk, it lands exactly where you expect.
Takeaway
Pool grooming is fundamentally a reliability problem - a kernel bug that reliably corrupts an adjacent controlled object is far more exploitable than one that hits unpredictable targets. Modern mitigations (segment heap randomization, pool header encoding, guard pages) push the research toward more creative primitives, but the underlying principle remains: control the heap layout before you trigger the bug.