Skip to content

Pool Spray / Heap Feng Shui

You have a pool overflow. Or a use-after-free. The bytes you corrupt are random garbage, or the freed memory gets reclaimed by some unrelated kernel allocation. Either way, you get a bluescreen instead of SYSTEM. How do you make the corruption land on something useful?

That is the problem pool spray solves. It is the technique that bridges the gap between "I can corrupt N bytes at an unpredictable location" and "I can reliably overwrite a specific field in a specific object to gain a controlled primitive." Without pool spray, most kernel memory corruption vulnerabilities are denial-of-service bugs. With it, they become privilege escalation.

The core idea

The Windows kernel pool allocator divides memory into pages, and pages into buckets grouped by size class. An allocation request for 0x180 bytes and one for 0x200 bytes both land in the 0x200-byte bucket (which covers 0x181 to 0x200). Allocations in different buckets are never adjacent to each other within a page. Allocations from different pool types (NonPagedPoolNx vs. PagedPool) use entirely separate memory regions.

This bucketing behavior is the foundation of pool spray. If you know the target allocation's size class and pool type, you can fill that bucket's pages with objects whose content you control. When the vulnerable allocation lands in that bucket, it will be adjacent to your spray objects. An overflow writes into your controlled object. A freed allocation gets reclaimed by your spray. Either way, you control what the corruption touches.

The philosophy is simple: do not fight the allocator's randomness; eliminate it. If every slot in the target bucket contains your spray object, it does not matter which slot the vulnerable allocation lands in. Every neighbor is one you placed there.

The spray sequence, step by step

Pool spray follows a predictable sequence, but each step exists for a specific reason that is worth understanding.

graph TD
    subgraph "Pool Page (before spray)"
        B1["?"] ~~~ B2["?"] ~~~ B3["?"] ~~~ B4["?"] ~~~ B5["?"]
    end

    subgraph "After spray (step 4)"
        C1["Spray"] ~~~ C2["Spray"] ~~~ C3["Spray"] ~~~ C4["Spray"] ~~~ C5["Spray"]
    end

    subgraph "After holes (step 5)"
        D1["Spray"] ~~~ D2["FREE"] ~~~ D3["Spray"] ~~~ D4["FREE"] ~~~ D5["Spray"]
    end

    subgraph "After trigger (step 6+7)"
        E1["Spray"] ~~~ E2["Vuln buf\n→ overflow"] ~~~ E3["Spray\n✗ corrupted"] ~~~ E4["FREE"] ~~~ E5["Spray"]
    end

    style B1 fill:#1a2744,stroke:#334155,color:#64748b
    style B2 fill:#1a2744,stroke:#334155,color:#64748b
    style B3 fill:#1a2744,stroke:#334155,color:#64748b
    style B4 fill:#1a2744,stroke:#334155,color:#64748b
    style B5 fill:#1a2744,stroke:#334155,color:#64748b
    style C1 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style C2 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style C3 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style C4 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style C5 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style D1 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style D2 fill:#0d1320,stroke:#334155,color:#64748b
    style D3 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style D4 fill:#0d1320,stroke:#334155,color:#64748b
    style D5 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style E1 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0
    style E2 fill:#2d1b1b,stroke:#ef4444,color:#e2e8f0
    style E3 fill:#2d2a1b,stroke:#f59e0b,color:#e2e8f0
    style E4 fill:#0d1320,stroke:#334155,color:#64748b
    style E5 fill:#152a4a,stroke:#3b82f6,color:#e2e8f0

Step 1: Identify the target allocation size. This means the exact size including pool header overhead (typically 0x10 bytes on modern Windows), not just the structure size visible in Ghidra. Reverse-engineer the allocation or observe it in WinDbg with a breakpoint on ExAllocatePoolWithTag filtered by the relevant pool tag. The size class determines everything that follows.

Step 2: Select a spray object. You need a kernel allocation of the same size class, from the same pool type, whose content you can control from user mode. The spray object must also have fields that are useful when corrupted: function pointers for code redirection, data pointers for arbitrary read/write, length fields for out-of-bounds access. The choice of spray object is often the hardest decision in the exploit; we cover the common options below.

Step 3: Defragment the pool. Under normal system operation, pool pages contain a mix of allocations from many different code paths. Partially filled pages have gaps at unpredictable positions. The defragmentation step allocates many small objects to fill these partial pages, forcing the allocator to carve fresh, contiguous pages for subsequent allocations. This creates a clean starting state where the next spray allocations will fill pages sequentially.

Step 4: Spray thousands of objects. Fill entire pool pages in the target bucket with your controlled objects. The volume matters: you need enough spray objects to dominate the bucket, so that new allocations of the target size land adjacent to your objects with near-certainty. Pre-Segment Heap, a few hundred objects might suffice. Post-19H1, you may need 10,000 or more.

Step 5: Poke holes. This is the feng shui part. Free specific spray objects (for example, every other one) to create gaps. The vulnerable allocation will land in one of these gaps, surrounded by surviving spray objects on both sides. The pattern of holes determines the exploit's layout requirements. For a linear overflow, you want the target just before a spray object. For a UAF reclaim, you free the single slot you want to reclaim.

Step 6: Trigger the vulnerable allocation. Issue the syscall or I/O request that causes the kernel to allocate the vulnerable buffer. It lands in one of the prepared gaps, adjacent to your spray objects. The pool allocator's LIFO behavior (on pre-Segment Heap) or LFH bucketing (on Segment Heap) determines which gap gets filled, but because you created many gaps, at least one will be correct.

Step 7: Trigger the vulnerability. Now you exercise the bug. An overflow writes past the vulnerable buffer into the adjacent spray object. A UAF dereferences a pointer to memory now occupied by your spray object. A type confusion reads fields from your controlled data. The corruption is no longer random; it hits your prepared content.

Step 8: Read back or exploit the corrupted object. The final step depends on which fields were corrupted. A corrupted length field in a pipe attribute gives you an out-of-bounds read (information leak). A corrupted function pointer gives you code redirection. A corrupted data pointer gives you a write-what-where primitive. Read the corrupted spray object back through its original API (e.g., NtQueryInformationFile for pipe attributes) to extract the leaked data or confirm the corruption.

Choosing the right spray object

The spray object selection table is a critical reference. The right choice depends on matching the target's size class and pool type, having sufficient content control, and being able to create and destroy objects fast enough.

Object API to Create Pool Approx Size Useful Fields
DATA_QUEUE_ENTRY NtWriteFile on pipe NonPagedPoolNx header + data DataSize, inline data
_WNF_STATE_DATA NtUpdateWnfStateData PagedPool variable DataSize, ChangeStamp
_PIPE_ATTRIBUTE NtFsControlFile PagedPool header + name + value ValueLength, value pointer
_FILE_OBJECT NtCreateFile NonPagedPoolNx ~0x100 Various pointers
_TOKEN NtDuplicateToken PagedPool ~0x480 Privileges, SessionId
IO Completion entries NtSetIoCompletion NonPagedPoolNx fixed ~0x58 Data fields
EA buffers NtCreateFile with EA varies controlled Fully controlled content

A few practical notes on selection. DATA_QUEUE_ENTRY (pipe queue entries) are the workhorse for NonPagedPoolNx targets. Writing to a named pipe creates an entry whose size is the header plus your data length, giving fine-grained size control. The data content is fully controlled. They stay allocated until the pipe is read or closed, giving stable lifetime. The same technique surfaces in CVE-2024-30085, where cldflt.sys was exploited using pipe attribute spray for a pool overflow.

_WNF_STATE_DATA is the PagedPool equivalent. Calling NtUpdateWnfStateData creates a variable-size allocation with user-controlled content. WNF spray was used in the CVE-2023-28252 clfs.sys exploit for controlled PagedPool layout.

EA (Extended Attribute) buffers deserve special mention because their content is almost entirely user-controlled and their size can be precisely tuned. They work across pool types depending on the file system and allocation path, making them versatile when other spray objects do not match the needed size class.

Segment Heap changed everything

Before Windows 10 19H1, the kernel pool allocator was relatively simple. Allocations of the same size from the same pool type shared pages freely, regardless of their pool tag or origin. Spray was straightforward: allocate thousands of objects in the right size class, and they would intermingle with the target on the same pages.

The Segment Heap allocator, introduced in 19H1, fundamentally changed this landscape.

Pool tag isolation is the most impactful change. Allocations with different pool tags no longer share pool pages. If the vulnerable allocation uses pool tag ClfS and your spray uses tag NpAt (pipe attributes), they will never be adjacent because they live on separate pages. This single change broke the majority of pre-19H1 pool spray techniques overnight.

Variable Size (VS) subsegments handle allocations above the Low Fragmentation Heap (LFH) threshold. These allocations go to VS subsegments with randomized placement, making their positions within a page unpredictable. Sequential allocations do not produce sequential layout.

LFH randomization affects smaller allocations. Even within a single pool tag's pages, the order of allocations within an LFH bucket is randomized. You cannot assume that allocating objects A, B, C means they will be laid out as A, B, C in memory.

Modern exploits adapt to Segment Heap in several ways. Tag-matched sprays use spray objects that happen to share the vulnerable allocation's pool tag, or exploit code paths that allocate with the same tag. Larger spray volumes (10,000+ objects) compensate for randomization by saturating the bucket so thoroughly that adjacency becomes probable despite randomization. Cross-page techniques exploit the fact that pool pages in the same VS subsegment are still contiguous in virtual memory, even if allocations within a page are randomized.

The practical impact is that post-19H1 exploitation requires significantly more preparation, more spray volume, and more creative spray object selection. But it is not impossible. Every major kernel exploit since 2020 has successfully navigated Segment Heap constraints.

UAF reclaim via spray

For use-after-free exploitation, the spray's role shifts from achieving adjacency to achieving reclamation. After the vulnerable object is freed, the attacker sprays objects of the same size to reclaim the freed memory. The spray object's content overlaps the freed object's structure, giving control over fields still referenced through a dangling pointer.

Timing is critical here. The spray must complete after the free but before the dangling pointer is dereferenced. For reference-counting UAFs where the free is deterministic and the stale access is triggered by a separate syscall, the attacker has full control over timing: free, spray, trigger. For race-condition UAFs, the window may be narrow, and the spray must be pre-positioned or executed with minimal latency.

Size class and pool type must match the freed allocation exactly. On Segment Heap, pool tag matching may also be required. The CVE-2024-38193 afd.sys exploit and CVE-2023-29336 win32kfull.sys exploit both demonstrate UAF reclaim through carefully targeted spray.

Practical considerations

A few hard-won lessons that do not fit neatly into the narrative above but matter enormously in practice.

Segment Heap requires larger spray volumes (10,000+ objects) compared to pre-19H1. Budget your exploit's memory consumption and timing accordingly. Spraying 50,000 pipe queue entries takes measurable time.

LFH randomization makes exact adjacency probabilistic, not deterministic. Robust exploits must tolerate imprecision: instead of assuming the overflow lands in the object at offset +0x200, they spray a pattern that works regardless of which specific neighbor gets hit.

Pool cookies detect header corruption. Never target the pool header itself with an overflow. Target the object's data fields, which start after the header. An overflow that damages a pool cookie triggers an immediate bugcheck, turning your exploit into a crash.

Special Pool and Driver Verifier change pool layout entirely by placing every allocation on its own page with guard pages. They are invaluable for debugging but must be disabled for exploitation testing, as they defeat spray by design.

WinDbg's !pool, !poolused, and !pooltag commands are essential for verifying spray placement. Pool allocation breakpoints (ba on ExAllocatePoolWithTag) filtered by tag and size confirm that your spray objects are landing in the expected bucket.

Mitigations

  • Segment Heap randomization (19H1+) increases allocation placement unpredictability, raising spray volumes and narrowing the set of usable spray objects
  • Pool header cookies cause immediate BSOD when an overflow damages the header, preventing silent corruption chains
  • Pool tag isolation prevents cross-object spray between allocations with different tags on Segment Heap
  • Type Isolation (future): Microsoft has discussed separating kernel object types into distinct pool regions entirely, which would defeat cross-object spray even with matching tags
  • KASLR does not affect pool spray directly but raises the bar for post-spray steps that require kernel base addresses
CVE Driver Spray Target
CVE-2026-21241 afd.sys NPNX named pipe spray to reclaim 0x70-byte AfdN UAF slot
CVE-2024-30085 cldflt.sys Pipe attribute spray for pool overflow
CVE-2024-38193 afd.sys Pool UAF reclaim via spray
CVE-2023-28252 clfs.sys WNF spray for controlled layout
CVE-2023-36036 cldflt.sys Pool overflow exploitation
CVE-2023-29336 win32kfull.sys UAF reclaim with spray

References

See Also