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

the tcache stashing unlink attack

2026-06-043 min readheap pwn glibc

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.

target./stash
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:

_int_malloc, smallbin stash (paraphrased)c
1
2
3
4
5
6
7
while (tcache->counts[tc_idx] < tcache_count
       && (tc_victim = last(bin)) != bin) {
    bck = tc_victim->bk;
    bin->bk = bck;            /* detach tc_victim from the smallbin ... */
    bck->fd = bin;            /* ... by writing &bin into bck->fd  <-- the lever */
    tcache_put(tc_victim, tc_idx);
}

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:

corrupted smallbin chunk: bk -> target-0x10hex
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                                       |....            |
  1. A libc pointer written to target. Aim target at 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.
  2. An allocation at a chosen address. tcache_put also puts tc_victim into the tcache, so the next malloc of that size hands back the chunk you steered — overlapping whatever you targeted.
stash.pypython
1
2
3
4
5
6
7
8
from pwn import *
io = process("./stash")

fill_smallbin(count=2)                       # two chunks in one smallbin
corrupt_bk(chunk2, value = target - 0x10)    # the single overwrite
trigger_stash(size)                          # alloc from the bin -> stash leftovers
                                             # -> *(target) = main_arena+off
p = alloc(size)                              # next alloc lands on the stash victim

the check, and how to satisfy it#

Newer glibc guards the loop:

c
if (__glibc_unlikely (bck->fd != tc_victim))
    malloc_printerr ("malloc(): unsorted double linked list corrupted");

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:

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