TL;DR

Newer version of glibc stops us from performing Tcache Poisoning, which allows us to modify the next pointer of the free'd chunk in tcache to an arbitrary write. If we aren't dealing with the new version of glibc without the restriction, we can simply double free the victim and do a simple tcache poisoning.

Prerequisites

  • A Double Free primitive.

Overview

When heap exploitation is getting tough with patches on glibc, the new member Tcache makes it simple again. But the good days didn't last long. A new patch to restrict Tcache Double Free was released, meaning we can no longer hijack the next pointer of tcache-bin chunks by freeing them twice. The patch was released in 2018, but this bypass technique still works if certain prerequisites are satisfied.

First, we take a look at the patch for later to better understand how we should bypass the restriction. We mainly focus on the malloc.c file because it's exactly related to the Ptmalloc mechanism.

Patch 1

It adds the key field to the tcache_perthread_struct:

/* @@ -2967,6 +2967,8 @@ mremap_chunk (mchunkptr p, size_t new_size) */
/* 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;

It's actually a surprise for me. I did not expect the tcache wouldn't even have the key field at the beginning—I can imagine the happiness of a hacker tester who confronts it. This part locates at the bk position in a single-linked list structure. And with the newest Safe Linking to protect such single-linked list, it will also be encrypted, according to my experience. The key field related macro is added to both tcache_put & tcache_get functions.

Patch 2

This is the security check that is designed to a double free:

/* @@ -2990,6 +2992,11 @@ tcache_put (mchunkptr chunk, size_t tc_idx) */
/* Caller must ensure that we know tc_idx is valid and there's room
   for more chunks.  */
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);

  /* Mark this chunk as "in the tcache" so the test in _int_free will
     detect a double free.  */
  e->key = tcache_key;

  e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

When a chunk is already free'd into tcache, it will be marked with the key value. If an attacker wants to free it again into the tcache bin, this part of the code will check if the chunk being free'd is already marked. If it's marked, _int_free will then throw an error for double free detection.

Patch 3

For one of the two most important functions related to tcache tcache_get:

/* @@ -3005,6 +3012,7 @@ tcache_get (size_t tc_idx) */
   assert (tcache->entries[tc_idx] > 0);
   tcache->entries[tc_idx] = e->next;
   --(tcache->counts[tc_idx]);
+  e->key = NULL;
   return (void *) e;
 }

The change adds (e->key = NULL;) assigns NULL to the key member of the structure pointed to by e. It clears sensitive information stored in key to ensure that it doesn't point to invalid memory after the element is removed from the cache.

Patch 4

Finally, _in_free will execute to defend double free:

@@ -4218,6 +4226,26 @@ _int_free (mstate av, mchunkptr p, int have_lock)
   {
     size_t tc_idx = csize2tidx (size);
 
+    /* Check to see if it's already in the tcache.  */
+    tcache_entry *e = (tcache_entry *) chunk2mem (p);
+
+    /* This test succeeds on double free.  However, we don't 100%
+       trust it (it also matches random payload data at a 1 in
+       2^<size_t> chance), so verify it's not an unlikely coincidence
+       before aborting.  */
+    if (__glibc_unlikely (e->key == tcache && tcache))
+      {
+       tcache_entry *tmp;
+       LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);
+       for (tmp = tcache->entries[tc_idx];
+            tmp;
+            tmp = tmp->next)
+         if (tmp == e)
+           malloc_printerr ("free(): double free detected in tcache 2");
+       /* If we get here, it was a coincidence.  We've wasted a few
+          cycles, but don't abort.  */
+      }
+
     if (tcache
        && tc_idx < mp_.tcache_bins
        && tcache->counts[tc_idx] < mp_.tcache_count)

It introduces a check to determine if the memory block being free'd is already in the per-thread cache (aka tcache). This is done by examining the key field of the tcache_entry structure pointed to by e. If e->key is equal to tcache, it indicates that the memory block is already in the tcache.

However, it acknowledges that this check might coincidentally match other data in memory, so it's not completely reliable. To mitigate this, it verifies that tcache is not NULL and then checks if e->key is equal to tcache.

If the check for a double free succeeds, it probes the memory using LIBC_PROBE and then iterates through the tcache entries to ensure that e is not already present. If it finds e in the tcache, it prints an error message using malloc_printerr.

If the check concludes that it was a coincidence, the function continues execution without aborting. It wastes some time, but glibc thinks it deserves.

Attack Demo

The core mindset is to find a way to modify the key value. To better illustrate how to bypass this restriction using House of Botcake, we use a demo code from how2heap with our analysis.

The House of Botcake demonstrates a powerful tcache poisoning attack by tricking malloc into returning a pointer to an arbitrary location (in this demo, the stack). This exploit relies only on the double free mechanism.

We first prepare a target we want to hijack on the stack:

intptr_t stack_var[4];

Then we prepare a proper heap layout by allocating 7 chunks (malloc(0x100)) for us to fill up tcache list later, a chunk for later consolidation (prev), the victim chunk (a), and lastly with a padding to avoid coalescing with top chunk.

for(int i=0; i<sizeof(x)/sizeof(intptr_t*); i++){
        x[i] = malloc(0x100);
}

intptr_t *prev = malloc(0x100);
intptr_t *a = malloc(0x100);
malloc(0x10);

Now the heap layout looks like:

Now the heap layout is ready. We will start to attack to cause chunk overlapping.

Step 1. Fill up tcache list with 7 dummy chunks by default:

for(int i=0; i<7; i++){
        free(x[i]);
}

Step 2. Free the victim chunk a so it will be added to unsorted bin:

free(a);

Step 3. Free the previous chunk prev and make it consolidate with the victim chunk:

free(prev);

Now we can see the adjacent prev & a chunks are consolidated into one big chunk, with their old pointer values at fd & bk pointers:

Step 4. Add the victim chunk to tcache list by taking one out from it and free victim again:

malloc(0x100);	// take one out from tcache
/*VULNERABILITY*/
free(a);// a is already freed
/*VULNERABILITY*/

We are allowed to do this because the pointers of victim chunk are generated when it's free'd into the unsorted bin. Therefore, the pointers now are nothing related to the tcache and key. We can then bypass the tcache security check for both:

e->key = tcache_key;
e->key = null;

And now the victim chunk is simultaneously in the tcache & unsorted bin!

⚠ According to the heap layout, we can clearly understand why we need the prev chunk to be consolidated with our victim. Because we are able to malloc prev chunk (by calling malloc(0x218) here) and then overwrite the meta data on victim chunk. So next time we use malloc to request a chunk in size of 0x110 from the tcache, we are able to hijack the next pointer of the victim chunk (it will be on top of tcache list) to perform an arbitrary write with the next malloc.

Let's finish the attack demo for final verification. Now we have the chunk overlapping primitive. The prev chunk size is 0x220 and the victim chunk a size is 0x110. And we do:

a = malloc(0x100);
memset(a, 0, 0x100);
prev[0x110/sizeof(intptr_t)] = 0x41414141;
assert(a[0] == 0x41414141);

It micmics an overwriting on the next pointer of the victim chunk. But I don't think this is the most powerful attack method. Just like my analysis above, we can first malloc(0x210) to request the consolidated prev chunk, overwrite the data at 0x555555559b20 to our target address (keep the other metadata of the victim chunk correct). Then we can make the fake chunk with an entry of the target address (i.e. the stack_var we defined at the beginning, maybe the author of this demo forgets it) on the top of the tcache list. And then we can use next malloc to request the fake chunk to perform arbitrary write on the target address!

References

https://sourceware.org/git/?p=glibc.git;a=commit;h=bcdaad21d4635931d1bd3b54a7894276925d081d

https://github.com/shellphish/how2heap/blob/master/glibc_2.39/house_of_botcake.c


Are you watching me?