Heap exploitation
The 30-second version. Module 01 chapter 19 praised dlmalloc's descendants for keeping bookkeeping inside the heap: zero side tables, one 16-byte header. This chapter reads that decision adversarially. The chunk header (prev_size,sizeplus thePREV_INUSE/IS_MMAPPED/NON_MAIN_ARENAbits) and thefd/bklinks that freed chunks carry in your old data are within reach of an overflow — and the allocator trusts them: it dereferences the pointers you can overwrite and merges chunks using sizes you can forge. A heap overflow upgrades into "make the allocator allocate where I choose" — an arbitrary write. The weapon families are tcache poisoning, fastbin dup, the unsorted-bin leak, and the House-of-* lore; the hardening history (double-free keys in 2.29, safe-linking in 2.32, hook removal in 2.34) reads best as each attack's obituary. Defensive framing throughout: you cannot reason about mitigations you have never derived from the attack.
The header is in the blast radius
Start from the layout chapter 19 derived (glibc 2.35, x86-64): malloc returns mem = p + 16 for a chunk at p. The header is 16 bytes — prev_size (8 bytes) and size (8 bytes, flags in the low bits). Usable space is size - 8: the last 8 bytes of user data legally overlap the next chunk's prev_size, meaningful only when this chunk is free.
one glibc 2.35 chunk on x86-64, request 0x68 -> chunk size 0x70
p = chunk address mem = p + 16 = what malloc returned
| |
v v
+----------------+----------------+---------------------------------+
| prev_size (8B) | size + flags(8B)| user data (104 usable bytes) |
+----------------+----------------+---------------------------------+
chunk size = (request + 8 + 15) & ~15, minimum 0x20 (32 bytes)
usable = size - 8
flags packed in the 3 low bits of the size word (0x71 = 0x70 | PREV_INUSE):
P (0x1) PREV_INUSE - previous chunk in use. If 0, THIS chunk's
prev_size is meaningful and free() will walk
backward by that many bytes to merge (consolidate)
M (0x2) IS_MMAPPED - free() munmap()s instead of binning
A (0x4) NON_MAIN_ARENA - chunk belongs to an mmap'd arena, not the brk arena
The SAME 104 user bytes, three ways (what the allocator writes into them):
ALLOCATED FREED into unsorted/small/ FREED into tcache
(your data) large bin (doubly-linked) (glibc 2.26+, LIFO)
+-------------+ +-------------+ +-------------+
| your data | | fd (8B) | | next (8B) |
| | | bk (8B) | | key (8B) |
+-------------+ +-------------+ +-------------+
nothing bin head lives in main_arena next MANGLED since
allocator- INSIDE LIBC: lone chunk's glibc 2.32:
written fd = bk = main_arena+0x60 next = raw ^ (addr>>12)
(a libc address: the leak) key: double-free tag,
2.29 = tcache struct
ptr, 2.34+ = random
Now the attacker's arithmetic. The next chunk's size field sits at mem + (size - 8) — exactly at offset usable. A copy that runs 1 byte past the usable size writes the least significant byte of the neighbor's size word: a trailing NUL lands as size &= ~0xff, clearing PREV_INUSE and zeroing the low size byte — the heap's version of chapter 02's poison NULL byte, and the seed of House of Einherjar below. Writing past the requested size but within the usable size hits only slack: the overflow must exceed usable, not request.
Every field is a control-flow decision made from attacker-writable data. free() reads the next chunk's P bit to decide whether to merge backward using prev_size — a forged prev_size plus a planted fake chunk makes consolidation link memory you designed into a bin; the M bit routes free() to munmap instead of the bins; and the freed chunk's first 16 user bytes become fd/bk or tcache next/key — a use-after-free read sees them (the chapter 05 crossover), a use-after-free write replaces them.
tcache: the cache that added an attack surface
tcache arrived in glibc 2.26 for the performance reason chapter 19 derived: a same-size free/malloc pair should cost two indexed writes and no lock. The per-thread structure is 64 bins indexed by chunk size (32..1040 bytes), each a LIFO stack capped at 7 chunks (the tcache_count default), with a counts[] occupancy array (uint16_t per bin since glibc 2.30; char in 2.26-2.29). Free pushes, malloc pops — and on the pop, glibc 2.35 verifies only alignment: the bin index was decided at free time, so whoever controls the links controls the next allocation.
Tcache poisoning, derived. Free two same-size chunks: entries[i] points to b, b->next to a (mangled, more below). If an overflow or UAF write replaces b->next with an attacker-chosen value, the second malloc of that size hands the attacker the pointer they chose:
tcache bin for 0x70 chunks, after free(a); free(b): count = 2
entries[5] --> b --next--> a --next--> NULL
overwrite b->next with TARGET (stored as TARGET ^ (b >> 12) on glibc >= 2.32;
raw TARGET on 2.26-2.31):
entries[5] --> b --next--> TARGET
malloc #1 --> returns b count = 1
malloc #2 --> returns TARGET count = 0
the program then WRITES through TARGET: arbitrary write
Before glibc 2.29 the tcache path had no integrity checks — a double free ("tcache dup") got two live pointers to one allocation. The counter-moves, each a version number:
- 2.29 — the key field.
free()stamps the chunk's second word with a per-thread key; freeing a chunk that still carries it aborts withfree(): double free detected in tcache 2. The key was the tcache struct's own address in 2.29-2.33 — guessable with a heap leak — and became a randomgetrandom()value in 2.34. - 2.32 — safe-linking.
nextis stored mangled:PROTECT_PTR(pos, ptr) = (pos >> 12) ^ ptr,posbeing the slot's own address. This is obfuscation, not encryption: the XOR key is 12 bits of shifted slot address — exactly what a heap leak gives you. Read one entry and you can decode every other; know the chunk's address and you can forge the mangled form. What it kills is the zero-knowledge forgery of 2.26-2.31; what it does not hide is the mangled NULL — literallychunk_address >> 12, a small recognizable value (the lab shows it). Partial overwrites of the low bytes still work — the key's high bits in the stored word are untouched. 2.32 also added alignment checks (malloc(): unaligned tcache chunk detected), so a forged target must be 16-byte aligned.
The lesson generalizes: a cache added for threads added a second metadata format in user-reachable bytes, with fewer checks on the hot path.
Below the cache: fastbins and the unsorted bin
Fastbins (chunk sizes 0x20-0x80, glibc 2.35 default max_fast = 128) are singly-linked LIFO stacks with deliberately deferred consolidation — the speed trade chapter 19 documented. When the tcache bin is full, the 8th same-size free lands here. Two properties matter adversarially:
- The pop checks the size word: the chunk coming off the fastbin must map back to that fastbin's index, or
malloc(): memory corruption (fast)aborts. An overflow targeting fastbins must forge a consistent size, not just a pointer. - The double-free check (
double free or corruption (fasttop)) fires only when the chunk being freed is already at the fastbin head. Sofree(a); free(b); free(a)sails through — a is in the list twice, and two latermallocs return the same pointer to two different owners: fastbin dup, the classic recipe for overlapping allocations and the ancestor of chapter 05's "same pointer, two types" confusions. Fastbinfdpointers got the same safe-linking mangling as tcache in 2.32.
The unsorted bin is the leak. Free a mid-size chunk (above fastbin size, below the 128 KiB mmap threshold, with an allocated neighbor so it cannot merge into top) and it is consolidated and parked in the unsorted bin — a doubly-linked list whose head is not in the heap at all: it is main_arena+0x60, and main_arena is a static structure inside libc's data segment (glibc 2.31-2.41 x86-64; offset varies per build). A lone chunk in that bin carries fd = bk = a libc address. Read those 8 bytes — through a UAF or a dangling pointer the program still prints — and ASLR's libc half is defeated: libc_base = leak - known_build_offset. That single read is why heap write-ups pair a "heap leak" with a "libc leak": together they turn chapter 03's ret2libc and ROP into payload.
The unsorted bin's bk was also an arbitrary-write primitive before 2.29: the unsorted bin attack overwrote a freed chunk's bk with target - 0x10, so removing the chunk executed bck->fd = unsorted_chunks(av) — writing main_arena+0x60 into target (the folklore target: global_max_fast). glibc 2.29 added the integrity check (malloc(): corrupted unsorted chunks 3) that killed the classic form; the leak through fd/bk is unfixable, because the values must point at libc for the bin to work at all.
The House-of-* museum, one line per exhibit
The named techniques of the last two decades are all the same sentence with the metadata field swapped: overflow X, the allocator's bookkeeping trusts it, the allocator now writes where I say — each pinned to the glibc it worked on.
- House of Force (named in Phantasmal Phantasmagoria's "Malloc Maleficarum", 2005; mass CTF sightings 2016): smash the top chunk's
sizeto(size_t)-1,mallocan enormous value so the top pointer wraps onto a target address, and the nextmallocreturns memory there. Dead since glibc 2.29 (malloc(): corrupted top size). - House of Spirit (Maleficarum 2005; modern write-up in Phrack 66's "Malloc Des-Maleficarum", 2009): craft a fake chunk — plausible size word, fake next chunk — at an address you control,
free()it, and latermallochands your fake address back. Alive in spirit ever since: tcache-era variants only need the fake header to survive free-time checks. - House of Orange (born in HITCON CTF 2016): corrupt the top chunk's size so the next large
malloccallssysmalloc, which frees the old top into the unsorted bin — a free you never performed — then rides unsorted-bin writes into FILE-structure exploitation (_IO_list_all, fake vtable) for code execution with no heap free under your control. Shaped by glibc 2.24's vtable validation; the classic chain predates 2.26. - House of Einherjar (published by st4g3r; how2heap's tcache-aware glibc-2.32 variant still works): one NUL byte into the neighbor's
sizeclearsPREV_INUSE; a forgedprev_sizeyou planted makesfree()consolidate backward onto a fake chunk below the heap, yielding overlapping allocations. The heap's poison-NULL-byte cousin of chapter 02.
Treat the museum as vocabulary, not a toolkit: what matters is the grammar — which field, which check, which version killed it.
Allocator choice is a security decision
Every technique above is an implementation attack on glibc's specific in-band metadata: change the allocator and the cookbook does not transfer — which is why hardened targets switched:
- Firefox 3 (2008) adopted jemalloc, officially for its measured fragmentation wins (chapter 19); the security dividend is that its extent metadata lives out of band, away from the data an overflow reaches. Modern Firefox still ships a hardened jemalloc fork.
- Chrome moved to PartitionAlloc (rolled out 2019-2021): allocations partitioned by type into separate pools with encoded freelist pointers and bounds-checks — a fake-chunk overflow lands in the wrong pool and dies.
- Android 11 (2020) switched its system allocator from jemalloc to Scudo, which adds metadata checksums and a quarantine that delays reuse.
- mimalloc (Microsoft Research, 2019) offers a secure mode with randomized, encoded freelists — the same property holds.
Be honest: swapping allocators raises the cost of exploitation, not the bug count. Overflow, use-after-free, and double-free are portable primitives — they lose only the ready-made metadata forgery that ptmalloc's in-band bookkeeping handed the attacker.
What insiders know
- The version ledger, memorized as years: tcache glibc 2.26; tcache key (double-free detection) 2.29;
counts[]widens fromchartouint16_t2.30; safe-linkingptr ^ (pos >> 12)— tcache and fastbins — 2.32; randomtcache_keyplus removal of__malloc_hook/__free_hookfrom the API 2.34 — the classic one-gadget arbitrary-write targets vanished from every pre-2021 exploit chain. - tcache's 7-per-bin default is a check boundary, not just a performance knob. The 8th same-size free bypasses tcache entirely and meets the fastbin's weaker, head-only double-free check — exploit grooming sequences count frees for this reason.
- Safe-linking is xor-with-a-leakable-key.
PROTECT_PTR(pos, ptr) = (pos >> 12) ^ ptr; the key is derived from the slot's own address, so any single heap leak unmasks the whole list — and the mangled NULL is alwayschunk_addr >> 12, a tell-tale small value (the lab shows0x555555559). - The key-field double-free check (2.29) fires only if the key survived. A UAF write that zeros or scrambles
e->keybetween the two frees, or seven intervening same-size frees that flush the bin, both route the bug past tcache into lower, weaker layers. - Hardened targets swap allocators to change the attack surface, not to fix bugs: Firefox's jemalloc (2008), Chrome's PartitionAlloc, Android 11's Scudo all move metadata out of overflow reach — the glibc metadata-forgery cookbook dies, the overflow/UAF primitives survive.
- Heap labs always call
setbuf(stdout, NULL)first. Otherwise the firstprintfallocates a stdio buffer mid-experiment and silently rewrites the chunk layout you were about to dump — every how2heap file starts with it.
See it yourself
The lab watches glibc write the metadata this chapter attacks. Outputs are from glibc 2.41 (Debian 13, x86-64) — on Ubuntu 22.04's glibc 2.35 the shape is identical, only addresses and the main_arena offset differ. gdb disables ASLR by default.
/* heaplab.c */
#include <stdio.h>
#include <stdlib.h>
int main(void) {
setbuf(stdout, NULL); /* keep stdio out of the heap story */
char *big = malloc(0x500); /* 0x510 chunk: above tcache range */
char *guard = malloc(0x90); /* separates big from the top chunk */
char *t[9];
for (int i = 0; i < 9; i++)
t[i] = malloc(0x68); /* 0x70 chunks: tcache AND fastbin */
for (int i = 0; i < 8; i++)
free(t[i]); /* 7 fill tcache; t[7] -> fastbin */
free(big); /* -> unsorted bin */
printf("checkpoint 1\n"); /* (gdb) break here */
void *r1 = malloc(0x68), *r2 = malloc(0x68), *q[7];
for (int i = 0; i < 7; i++) q[i] = malloc(0x68);
printf("r1=%p r2=%p q4=%p q5=%p q6=%p\n", r1, r2, q[4], q[5], q[6]);
return 0;
}
$ gcc -g -O0 -o heaplab heaplab.c && gdb ./heaplab
(gdb) break 18 /* checkpoint 1 */
(gdb) run
checkpoint 1
(gdb) x/4gx t[0]-16 /* first chunk freed -> bottom of stack */
0x555555559840: 0x0000000000000000 0x0000000000000071
0x555555559850: 0x0000000555555559 0x962af71aca295b0a
Read that second line — it is the user area of the freed chunk: next = 0x555555559 is the mangled NULL (chunk address >> 12, safe-linking since 2.32); 0x962af71aca295b0a is the random key (2.34+) — the same value on every entry this thread frees; that is what makes it a tag. Now the stack head and its decode:
(gdb) x/4gx t[6]-16 /* last freed -> top of the stack */
0x555555559ae0: 0x0000000000000000 0x0000000000000071
0x555555559af0: 0x000055500000cfd9 0x962af71aca295b0a
(gdb) p/x *(unsigned long *)t[6] ^ ((unsigned long)t[6] >> 12)
$1 = 0x555555559a80 /* = t[5]: safe-linking unmangled */
One xor with a known heap address recovers the pointer: obfuscation, verified. The 8th free and the unsorted bin:
(gdb) x/4gx t[7]-16 /* tcache was full -> fastbin */
0x555555559b50: 0x0000000000000000 0x0000000000000071
0x555555559b60: 0x0000000555555559 0x0000000000000000 <- mangled NULL,
raw 0 pre-2.32
(gdb) x/4gx big-16 /* unsorted bin */
0x555555559290: 0x0000000000000000 0x0000000000000511
0x5555555592a0: 0x00007ffff7fa1b20 0x00007ffff7fa1b20 <- fd = bk = the
unsorted bin head = main_arena+0x60
(gdb) info proc mappings /* then find the libc mapping */
0x7ffff7dbc000 0x7ffff7f3b000 ... libc.so.6
(gdb) p/x 0x7ffff7fa1b20 - 0x7ffff7dbc000
$2 = 0x225b20 /* leak minus build offset = base */
That is the libc leak end-to-end: 8 readable bytes in a freed chunk minus a per-build constant equals ASLR's libc base (0x225b20 on this Debian 2.41 build; Ubuntu 22.04's 2.35 has its own constant — with libc6-dbg installed, p &main_arena names it directly). Now the three tiers serve reuse in order:
(gdb) continue
r1=0x555555559af0 r2=0x555555559a80 q4=0x555555559850 q5=0x555555559b60 q6=0x5555555592a0
r1 = t[6], r2 = t[5], q4 = t[0] — tcache pops, LIFO, deterministic. q5 = t[7] — only after seven pops drained the tcache does the fastbin serve one. q6 = big — with both caches empty, the unsorted-bin chunk is split. Three caches, three check regimes, one malloc call site.
The 2.29 check, as a runtime message — df.c is malloc(0x68); free(p); free(p);:
$ gcc -o df df.c && ./df
free(): double free detected in tcache 2
Aborted (core dumped)
On glibc 2.27 (Ubuntu 18.04) this program prints nothing — the second free succeeds and the bug becomes an exploit ingredient. Rerun any lab with GLIBC_TUNABLES=glibc.malloc.tcache_count=0 (chapter 19's knob): tcache off, every address changes.
How to use this knowledge
- Triage heap-corruption crashes by abort string.
tcache 2means a same-thread double free after 2.29;fasttopa fastbin head repeat;memory corruption (fast)a corrupted size word on a pop;corrupted unsorted chunks 3bin-list integrity. - Inspect a suspect heap in gdb with two commands.
x/4gx ptr-16reads the chunk's size and flags (sane multiple of 0x10? does the P bit match the neighbor?); the one-line xor decodes a freed chunk'snextto see whether the freelist still points at heap memory. - Review the shapes that feed this chapter. A fixed-size copy into a
malloc'd buffer where the request equals the usable size makes the first overflow byte the neighbor's size LSB — the Einherjar seed. Off-by-one bounds on heap buffers are not cosmetic. - Put allocator choice on the security review agenda. For a C/C++ network service, linking jemalloc/mimalloc or running Scudo moves metadata out of overflow reach — and test under the production allocator, because "works on my machine" is an allocator statement (module 03 chapter 02's theme).
- Answer interview questions by derivation. "Explain tcache poisoning" should start at the chunk diagram — in-band metadata, the LIFO stack, the two-malloc consequence — and end at the version ledger.
Connections
- Module 01 ch05 — the toy free-list heap, read here with hostile eyes.
- Module 01 ch19 — the allocator machinery in friendly mode: chunk layout, bin ladder, tcache design; this chapter is ch19 re-run adversarially.
- Module 02 ch02 — stack exploitation: same in-band-adjacent-metadata pattern (canary next to return address vs. header next to data).
- Module 02 ch03 — the unsorted-bin leak is the libc-base half that turns ROP/ret2libc from theory into payload.
- Module 02 ch06 — the mitigation ladder: safe-linking, tcache keys, and allocator swaps are rungs; MTE is the structural answer to this chapter.
- Module 03 ch02 — the memory-bug family: the overflow/double-free/UAF shapes in code review that supply every primitive used here.
- Module 04 ch04 — recovering C++: the vtable pointers heap attackers forge are the ones RE reconstructs; grooming plans need those layouts.
Quiz
Q1 (concept). Derive why in-band metadata is an attack surface: which allocator decisions are made from the chunk header fields, and what does a 1-byte overflow past the usable size overwrite?
Q2 (predict the pointers). glibc 2.35, x86-64: malloc(0x68) nine times into t[0..8], then free(t[0]) through free(t[7]). Which chunk goes to the fastbin, and what do the next three malloc(0x68)s return?
Q3 (predict the number). A program on glibc 2.41 frees a 0x510 chunk into the unsorted bin (allocated neighbor, no top merge) and prints the freed pointer's first 8 bytes via a dangling pointer. What value family appears, and how does it become the libc base?
Q4 (predict the value). In the same program, t[0] was the first chunk freed into an empty tcache bin. What does its next word read in the dump, and why that exact value?
Q5 (bug diagnosis). A service on glibc 2.35 aborts with free(): double free detected in tcache 2. Which mechanism fired — and name two ways the same double free evades that message on the same glibc.
Q6 (bug diagnosis). An exploit writer on glibc 2.34 overwrites a freed chunk's tcache next with the raw target address 0x404060 (16-byte aligned) and calls malloc twice. The program crashes inside malloc, not at the target. Diagnose the mistake and give the correct stored value.
Q7 (bug diagnosis). An application aborts with malloc(): memory corruption (fast). What check fired, and which bug shape (from this chapter's museum) most likely produced it?
Q8 (cross-chapter). Module 01 ch19 presented tcache as a performance win; chapter 02 presented stack canaries as a mitigation. Argue why glibc 2.29-2.34 (key, safe-linking, random key, hook removal) is the heap's own mitigation ladder — and why Firefox's 2008 jemalloc switch belongs on the same ladder as chapter 06's rungs.
Answers
- The allocator trusts bytes the program can write. From the header it decides: merge backward or not (P bit +
prev_size), consolidate forward (next chunk's size),munmapinstead of bin (M bit), which arena's bins (A bit), and — once freed — what to link and pop (fd/bk/next). A 1-byte overflow past usable size (not past the request — 8 bytes of slack) lands on the LSB of the next chunk'ssizeword:size &= ~0xffclearsPREV_INUSE— the poison-NULL-byte seed of House of Einherjar.
t[7]goes to the fastbin; malloc returnst[6],t[5],t[4]. The first seven frees fill the tcache bin (default 7); the eighth overflows to the fastbin. The threemallocs pop the tcache LIFO stack — last freed first.t[7]returns only on the eighth malloc, after tcache drains; deterministic every run.
- A libc address:
main_arena+0x60. A lone chunk carriesfd = bk =the bin head inmain_arena, a static struct in libc's data segment (x86-64, glibc 2.31-2.41 era; build dependent).libc_base = leak - offset_of(main_arena+0x60)for the exact build (0x225b20 on the lab's Debian 2.41) — one 8-byte UAF read defeats the libc half of ASLR, the prerequisite for chapter 03's ret2libc/ROP.
0x555555559— the mangled NULL.t[0]was freed into an empty bin, so itsnextmust be NULL; with safe-linking (glibc 2.32+) the stored form is(slot_address >> 12) ^ 0 = slot_address >> 12. The small value in a dump tells you both "first chunk freed" and "the heap lives at 0x555555559xxx".
- The tcache key check.
free()(2.29+) stamps each tcache entry's second word with the per-thread key; a chunk arriving with the key still present — and still on the bin's list — aborts as a double free. Two escapes: (a) a UAF write between the frees that overwrites the key, or (b) seven intervening same-size frees so the bin is full and the second free routes to the fastbin, where fasttop catches only head repeats.
- Safe-linking was ignored. Since 2.32 the stored value must be
target ^ (slot_address >> 12)— the raw pointer decodes to a garbage "next", and the pop aborts or writes through a wild pointer. Correct form: read the slot's own addresspos(heap leak required), storePROTECT_PTR(pos, target); the target must also be 16-byte aligned —0x404068would die asunaligned tcache chunk detected.
- The fastbin pop's size check. A chunk was popped from a fastbin whose
sizeword does not map back to that fastbin's index — some chunk on a fastbin list had a corrupted or forged size word. The likely producer: a heap overflow into the neighbor'ssizefield (the House of Spirit/Force family requirement — a consistent forged size, not just a pointer).
- Both are "make trusted metadata costlier to forge." The glibc ledger raises the forgery price stepwise — key (needs a UAF write), safe-linking (needs a heap leak), random key (a fresh leak), hook removal (kills the payoff target) — as canaries did for the stack, and neither stops a sufficient primitive. Firefox's jemalloc switch is the same rung: metadata relocated out of the overflow's reach — chapter 06's definition of a mitigation.