You can't corrupt what you can't see. Every heap technique in this series is just
a lie told to malloc about its own metadata — so before any of that, this is
the metadata, drawn byte by byte. No exhaustive malloc.c tour; only the parts
you'll be overwriting.
(Pair this with the pwndbg commands for reading heap
state live — vis, bins, heap are how you'll watch all of this happen.)
a chunk is a size field and some data#
malloc deals in chunks: an 8-byte prev_size, an 8-byte size, then your
data. The pointer malloc hands you points at the data — the size field sits
0x10 bytes behind it. An allocated 0x90 chunk full of As:
00000000 00 00 00 00 00 00 00 00 91 00 00 00 00 00 00 00 |................| 00000010 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 |AAAAAAAAAAAAAAAA|
The size reads 0x91, not 0x90. Sizes are always 0x10-aligned, so the low
nibble is free for flags — and 0x91 = 0x90 | 1. That 1 is PREV_INUSE.
Three flags live in those low bits:
| bit | flag | meaning when set |
|---|---|---|
1 |
PREV_INUSE |
the previous (lower-address) chunk is in use |
2 |
IS_MMAPPED |
chunk came from mmap, not the heap |
4 |
NON_MAIN_ARENA |
chunk belongs to a thread arena, not the main one |
The size you ask for is rounded up to a real chunk size — request plus the 8-byte
header, aligned to 16, floored at 0x20:
So malloc(24) and malloc(0x18) both give you a 0x20 chunk; malloc(0x88)
gives 0x90. Internalise this — half of heap exploitation is landing a fake
chunk on a size that passes a bin's checks.
prev_size is shared
prev_size only means anything when the previous chunk is free (its
PREV_INUSE is clear in this chunk's size). While the previous chunk is in
use, those 8 bytes are fair game — the previous chunk's data spills into them.
That overlap is the entire basis of the unlink and poison-null-byte families.
a free chunk is a linked-list node#
Free a chunk and malloc reclaims its (now unused) data as list pointers. The
first two quadwords become fd and bk; for big chunks the next two become the
fd_nextsize/bk_nextsize skip-list. Here's that same chunk after free(),
linked into the unsorted bin — fd and bk now point back into libc's arena:
00000000 00 00 00 00 00 00 00 00 91 00 00 00 00 00 00 00 |................| 00000010 60 87 ec f7 ff 7f 00 00 60 87 ec f7 ff 7f 00 00 |`.......`.......|
Those 0x7fff... values are libc addresses — which is why a single freed,
leaked chunk hands you a libc base. The size field survives the free untouched;
that's the field every technique forges.
the six homes of a free chunk#
When you free a chunk it doesn't vanish — it gets filed into one of six
structures, chosen by size and what's already full. Knowing which bin catches a
free, and in what order malloc searches them, is the whole game:
| bin | structure | sizes (default) | order | consolidates? |
|---|---|---|---|---|
| tcache | singly-linked, LIFO | 0x20–0x410 |
LIFO | no |
| fastbins | singly-linked, LIFO | 0x20–0x80 |
LIFO | no¹ |
| unsorted | doubly-linked | any | FIFO-ish | yes |
| smallbins | doubly-linked | 0x20–0x3f0 |
FIFO | yes |
| largebins | doubly-linked + skip list | ≥ 0x400 |
size-sorted | yes |
| top | the wilderness | whatever's left | — | — |
¹ fastbins skip consolidation until malloc_consolidate sweeps them into the
unsorted bin (triggered by a large request or free of a large chunk).
The order matters because exploitation lives in the gaps:
freepath: tcache first (if the size has a tcachebin with a free slot) → else fastbin (if in fast range) → else unsorted. Forward/backward consolidation happens before a chunk reaches the unsorted bin.mallocpath: tcache → fastbin (if in range) → smallbin (exact) → then a pass over the unsorted bin (sorting everything it doesn't use into small/large) → largebins → top.
That unsorted-bin pass is where freed chunks get sorted, and it's the busiest crossroads in the allocator — the unsortedbin attack and House of Orange both hijack it.
the bins, one paragraph each#
tcache (glibc ≥ 2.26) — a per-thread cache that front-runs everything. 64
bins, one per size from 0x20 to 0x410, 7 chunks each by default,
singly-linked through fd. It barely checks anything, which is exactly why
tcache poisoning is the friendliest primitive
there is. Since 2.29 each free chunk also stores a key (a pointer to the tcache
struct) so a naive double-free is caught.
fastbins — singly-linked LIFO for small chunks (0x20–0x80), no
consolidation, so freed fastchunks sit there ready to be re-handed-out fast. The
only free-time check is "is the chunk I'm freeing already the head?" — trivially
sidestepped, which is the fastbin dup.
unsorted bin — one doubly-linked list that everything too big for tcache/fast
lands in first. malloc drains it lazily, sorting as it goes. A freed unsorted
chunk's bk points at the bin head in libc — corrupt it and the next malloc
writes a libc pointer wherever you aimed.
smallbins / largebins — the sorted, doubly-linked long-term storage.
Doubly-linked means unlink, and unlink was the original arbitrary write
(more in Part 4). Largebins add the fd_nextsize/bk_nextsize skip list, a
second pair of pointers you can abuse.
top chunk (the "wilderness") — the single chunk bordering unused memory,
served only when no bin can. Its size is just "how much room is left," which is
why overwriting it with -1 (the House of Force) used to let you allocate
anywhere.
the mitigations you'll keep hitting#
Every technique below has an expiry date stamped by a glibc check. The ones that shape modern heap pwn:
- tcache double-free key (2.29) — that
keyfield; bypassed by clobbering it. - tcache/fastbin alignment check (2.32) — a chunk handed out from these bins
must be
0x10-aligned, killing lazy "misaligned fake chunk" tricks. - safe-linking (2.32) —
fdin tcache/fastbins is stored mangled as(&fd >> 12) ^ ptr. Overwritingfdnow needs a heap leak to compute the mask. - top size sanity (2.29) —
mallocrejects a top chunk whose size exceeds the arena'ssystem_mem, which is what retired the House of Force.
know your libc version before you pick a technique
checksec won't tell you the glibc version; ./libc.so.6 will (strings -alibc.so.6 | grep "GNU C Library"). The same bug wants a different technique on
2.23 vs 2.27 vs 2.35 — picking the wrong one is the most common reason a "known"
attack just errors out.
That's the whole board. The rest of the series is moves on it: House of Force bends the top chunk, fastbin dup abuses the LIFO, unsafe unlink weaponises the doubly-linked lists, and tcache poisoning is the one you'll actually reach for first.