The classic unlink is dead on modern glibc — its checks are
airtight. But the same doubly-linked-list bookkeeping happens in a quieter corner
of the allocator, with a much weaker guard: the smallbin → tcache stashing
loop. tcache poisoning gives you one arbitrary
allocation per corrupted next; stashing gives you a libc write and an
allocation, from a single bk overwrite.
- arch
- amd64-64-little
- libc
- 2.31
- primitive
- overwrite a smallbin chunk's bk
- needs
- a smallbin with >= 2 chunks (and a heap leak helps)
the stashing loop#
When _int_malloc services a request from a smallbin and the tcache for that size
has room, it doesn't stop at one chunk — it stashes the rest of the smallbin
into the tcache, walking the list from the back. The core of that loop, trimmed:
bck->fd = bin is an unlink half-step: it writes a libc pointer (bin, an address
inside main_arena) into bck->fd, which sits 0x10 bytes into whatever bck
points at. And bck came straight from tc_victim->bk — a field you can corrupt.
the two payoffs from one bk overwrite#
Point a stashed chunk's bk at target - 0x10. Then bck = target - 0x10, and
bck->fd — at target - 0x10 + 0x10 = target — receives bin:
00000000 00 00 00 00 00 00 00 00 91 00 00 00 00 00 00 00 |................| 00000010 ef 0e 5a 11 27 f0 00 00 0f 0f f5 d5 55 55 50 00 |..Z.'.......UUP.| 00000020 0f db ae 01 |.... |
- A libc pointer written to
target. Aimtargetat a__free_hook, a writable function pointer, or a GOT-adjacent slot, and the stashing loop drops a known-offset libc address there for free. - An allocation at a chosen address.
tcache_putalso putstc_victiminto the tcache, so the nextmallocof that size hands back the chunk you steered — overlapping whatever you targeted.
|
the check, and how to satisfy it#
Newer glibc guards the loop:
So target + 0x10 (which is bck->fd) must already equal tc_victim when the
loop runs. In practice you arrange that by pointing target at a region you can
pre-seed (another heap chunk, a controlled global) so its +0x10 slot holds the
victim's address. When you can't, the attack is constrained to targets whose
+0x10 you already control — which, conveniently, includes a freed chunk's own
neighbourhood.
the write the airtight unlink wouldn't give you
A libc pointer dropped on __free_hook's neighbourhood plus an overlapping
allocation — and unlink's modern checks never ran, because this isn't
unlink:
segfault{the_unlink_they_forgot_to_harden}
The lesson the heap keeps teaching: hardening one code path doesn't harden the
operation. unlink got bulletproofed, but the same list surgery lives in the
stashing loop with a single equality check — and a single equality check is a
condition you satisfy, not a wall you climb.