algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

the glibc heap, from the chunk up

2026-06-086 min readheap pwn glibc

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:

an allocated chunk — malloc(0x88) returned data+0x10hex
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:

chunksize(n)=max(0x20, (n+8+0xf) & ~0xf)

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:

the same chunk, freed — fd/bk overwrite the old datahex
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 0x200x410 LIFO no
fastbins singly-linked, LIFO 0x200x80 LIFO no¹
unsorted doubly-linked any FIFO-ish yes
smallbins doubly-linked 0x200x3f0 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:

  • free path: 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.
  • malloc path: 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 (0x200x80), 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 key field; 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) — fd in tcache/fastbins is stored mangled as (&fd >> 12) ^ ptr. Overwriting fd now needs a heap leak to compute the mask.
  • top size sanity (2.29) — malloc rejects a top chunk whose size exceeds the arena's system_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.