The heap series is a catalogue of techniques; this is
the other half — the defence they're all negotiating with. _int_free is glibc's
free path, and over the years it has accumulated a sequence of integrity checks,
each one a tombstone for some technique. Read them in order and the whole map of
"what still works on libc 2.X" falls into place.
- function
- _int_free (and unlink_chunk)
- arch
- amd64-64-little
- lens
- every malloc_printerr, in the order free() hits it
the entry gates: pointer and size#
Before anything else, two sanity checks on the chunk you handed back:
|
free(): invalid pointer rejects a pointer near the top of the address space or
not 16-byte aligned — it stops you from free()-ing a wild or mid-chunk address.
free(): invalid size demands the size field be >= MINSIZE and aligned. Together
they mean a forged chunk needs a plausible, aligned header before it can even
enter the allocator's bins — the baseline tax on every fake-chunk technique
(House of Spirit pays it first).
the tcache: fast, and historically wide open#
If the size fits a tcache bin, free returns almost immediately — which is why tcache attacks are so easy, and why the only guards here are recent:
|
Pre-2.29 there was nothing here — tcache dup was a free,
free, done. The e->key == tcache test is the fix: tcache_put stamps a freed
chunk's key field, so a second free of the same chunk is caught by the scan. The
two famous escapes: the bin only holds 7, so park 7 chunks first and your real
double-free falls through to the fastbin; or corrupt the key
field via a UAF write so the scan never triggers. Note what's absent — no size-of-
next check, no consolidation — which is precisely why
tcache poisoning (overwrite next, allocate
anywhere) is the cleanest primitive in the modern heap.
the fastbin: a double-free guard one chunk deep#
|
double free or corruption (fasttop) only compares against the current head of
the fastbin — so it stops free(a); free(a) but not free(a); free(b);free(a). That one-deep blind spot is the entire fastbin dup.
invalid fastbin entry (free) then insists a linked chunk's size maps back to the
same bin index — the check a fastbin-dup-into-fake-chunk has to satisfy by lining up
a valid size at the target.
the consolidation path: where unlink lives#
Larger chunks take the slow path, and it's the most heavily guarded — four checks before any list surgery:
|
(top) blocks freeing the top chunk; (out) blocks a chunk whose neighbour falls
outside the arena; (!prev) checks the next chunk's PREV_INUSE bit is set — a
clear-it-via-overflow double free is caught here, and it's exactly the bit the
poison null byte and House of
Einherjar manipulate. Then backward consolidation
calls unlink_chunk, which carries the checks that ended the classic
unsafe unlink:
fd->bk == p && bk->fd == p is the integrity invariant of a doubly-linked list;
the old *target = &target - 0x18 write can't satisfy it, which is why unsafe
unlink now needs a pointer to a controlled pointer (the self-referencing trick).
corrupted size vs. prev_size ties prev_size to the real chunk size — the check
the poison null byte must thread on 2.29+.
reading the map#
Walk the function once and the modern heap meta is just a reading of these gates:
- tcache poisoning survives because the tcache path has no
nextintegrity check and returns before consolidation — the path of least resistance. - fastbin dup survives because
(fasttop)is one chunk deep. - classic unlink is dead because
unlink_chunkverifiesfd->bk/bk->fd. - null-byte / Einherjar are constrained by
(!prev)andcorrupted size vs.prev_size, not forbidden — you satisfy them with a matchingprev_size.
The single most useful exercise in heap exploitation isn't memorising houses — it's
being able to recite this function. Every technique is a statement about which of
these malloc_printerr lines it doesn't trip, and every new glibc release is a
diff that adds one more. Read the source, and you stop guessing whether a technique
works on a target: you can see the gate it has to pass.