module 02 · the dark mirror of module 01

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, size plus the PREV_INUSE/IS_MMAPPED/NON_MAIN_ARENA bits) and the fd/bk links 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:

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 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.

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:

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

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

Connections

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
  1. 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), munmap instead 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's size word: size &= ~0xff clears PREV_INUSE — the poison-NULL-byte seed of House of Einherjar.
  1. t[7] goes to the fastbin; malloc returns t[6], t[5], t[4]. The first seven frees fill the tcache bin (default 7); the eighth overflows to the fastbin. The three mallocs pop the tcache LIFO stack — last freed first. t[7] returns only on the eighth malloc, after tcache drains; deterministic every run.
  1. A libc address: main_arena+0x60. A lone chunk carries fd = bk = the bin head in main_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.
  1. 0x555555559 — the mangled NULL. t[0] was freed into an empty bin, so its next must 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".
  1. 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.
  1. 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 address pos (heap leak required), store PROTECT_PTR(pos, target); the target must also be 16-byte aligned — 0x404068 would die as unaligned tcache chunk detected.
  1. The fastbin pop's size check. A chunk was popped from a fastbin whose size word 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's size field (the House of Spirit/Force family requirement — a consistent forged size, not just a pointer).
  1. 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.