RaRCTF - The Mound
[ pwn , heap ]

Points: 800
Description:
The glibc heap is too insecure. I took matters into my own hands and swapped efficiency for security.


The mound is a custom heap exploitation challenge, also featuring seccomp :) .
We are given a binary, mound, and the libc.so (2.31).

In short this is what i did:

  1. Reverse the custom heap implementation
  2. Find vulns in the binary
  3. Code execution + seccomp bypass

1 - Reverse the custom heap implementation

The binary mmaps two region of memory:

mmap(0xdead0000000, 0x8018, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
mmap(0xbeef0000000, 0x400000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)

0xdead0000000 will contain the following heap metadata:

struct heap_data {
    void* beefmap;               // = 0xbeef0000000
    unsigned long ID_LIST[4096];
    cache* cache;                // = 0xbeef0000010
    chunk* top_chunk;
};

0xbeef0000000 will be the actual heap containing the chunks.
The chunks are structured like this:

--------------------------------------
| (8 byte) random id | (8 byte) size | # <- chunk metadata, the id is used to
--------------------------------------      keep track of freed chunks.  
|                 data               |
--------------------------------------

When the heap is initalized a chunk of size 0xd0 is allocated at 0xbeef0000010. This chunk will hold a cache that works like the classic tcache from glibc:

struct cache {
    uint8_t counts[24];
    chunk* freelist[24]; // LIFO linked list
};

Each freelist will contain chunks of different sizes, from 0x10 to 0x100.

After the allocated chunks there is a top chunk of inital size 0x400000.

So, after the initialization of the heap, 0xbeef0000000 looks like this:


 0xbeef0000000:	0x27ae1e1f53167faf	0x00000000000000f0 <- cache chunk
 0xbeef0000010: 0x0000000000000000	0x0000000000000000 <- counts
 0xbeef0000020: 0x0000000000000000  	0x0000000000000000 |
 0xbeef0000030: 0x0000000000000000	0x0000000000000000 |
 0xbeef0000040: 0x0000000000000000	0x0000000000000000 |
 0xbeef0000050: 0x0000000000000000	0x0000000000000000 |
 0xbeef0000060: 0x0000000000000000	0x0000000000000000 |
 0xbeef0000070: 0x0000000000000000	0x0000000000000000 | freelists
 0xbeef0000080: 0x0000000000000000	0x0000000000000000 |
 0xbeef0000090: 0x0000000000000000	0x0000000000000000 |  
 0xbeef00000a0: 0x0000000000000000	0x0000000000000000 |  
 0xbeef00000b0: 0x0000000000000000	0x0000000000000000 |  
 0xbeef00000c0: 0x0000000000000000	0x0000000000000000 |  
 0xbeef00000d0: 0x0000000000000000	0x0000000000000000 |  
 0xbeef00000e0: 0x0000000000000000	0x0000000000000000 |
 0xbeef00000f0:	0x706396411d7177ac	0x00000000003fff10 <- top chunk  
 0xbeef0000100:	0x0000000000000000	0x0000000000000000  
 ...

Now we can analyze the functions used to allocate and free memory.

The program allows to free only chunks that fall inside the cache range.
A freed chunk looks like this:

--------------------------------------
| (8 byte) random id | (8 byte) size | # <- chunk metadata
-------------------------------------- 
|    cache* verify   |  chunk* next  |
--------------------------------------

verify is a pointer to the cache = 0xbeef0000010
next is a pointer to the next free chunk in the freelist.

In short the allocation works like this:

 mmalloc(size)
 |                                                 |------------------------------|
 |       |-----------------------------------|     | cache contains a freed chunk |
 |--> if | size falls inside the cache range | and | of the requested size        | 
     |   |-----------------------------------|     |------------------------------| 
     |
     |  ---yes--> return mcache_alloc(size)  
     |  ---no---> return top_chunk_alloc(size)
 
 top_chunk_alloc(size)
 |
 |--> subtract size from heap_data->top_chunk
 |--> move the top_chunk down (copy ID and size)
 |
 |--> set new_chunk->size and new_chunk->id = rand64bit()
 |--> return chunk
 
 mcache_alloc(size)
 |
 |--> take off chunk from freelist, freelist head is now chunk->next
 |--> if chunk->verify != heap_data->cache: exit(1) # "Mcache: Invalid verify" 
 |
 |--> remove chunk->id from heap_data->ID_LIST
 |--> clear chunk->verify and chunk->next
 |--> return chunk

And this is the freeing process:

 mfree(chunk)
 |
 |--> search chunk->id in heap_data->ID_LIST, if found: exit() # "Mound: Double free detected"
 |
 ---> mcache_free()
      |
      |--> if chunk size not in cache range: exit() # "Mound does not support 
      |                                                such large freeing yet."
      |--> add chunk to freelist:
      |    chunk->next = freelist[index]
      |    freelist[index] = chunk
      | 
      |--> set chunk->verify
      |
      |--> add chunk id to heap_data->ID_LIST

2 - Find vulns in the binary

Checksec of the binary:

Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x400000)

Run the binary and we get the following output:

1. Add sand
2. Add dirt
3. Replace dirt
4. Remove dirt
5. Go home

“Add sand”: allocate on the classic heap using strdup with max 0x100 chars.
“Add dirt”: allocate on the custom heap using mmalloc(), max size 0x10000.
“Replace dirt”: edit an allocation. ( we can only edit allocations from strdup )
“Remove dirt”: free an allocation.
“Go home”: exit.

We’ll have to work with two global arrays:

char* arr[16];
unsigned long sizes[16];

In the binary we also have a win function:

void win(void) {
  char buf [64];
  
  puts("Exploiting BOF is simple right? ;)");
  read(0, buf, 0x1000);
  return;
}

The plan is to:

We can get a double free like this:


Side note:

In order to bypass the “Mcache: Invalid verify” check we need to make sure that the verify field of the fake chunk we put in the freelist matches heap_data->cache.
We can put in the freelist the address of ID_LIST[4095] (0xdead0007ff8).
At 0xdead0007ff8+8 we have heap_data->cache (which will bypass the check) and at 0xdead0007ff8+16 we have heap_data->top_chunk (which we will overwrite).



3 - Code execution + seccomp bypass

Now that win is called we have a simple buffer overflow.
However we still have to deal with seccomp.

$ seccomp-tools dump ./mound
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0c 0xc000003e  if (A != ARCH_X86_64) goto 0014
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x0a 0x00 0x40000000  if (A >= 0x40000000) goto 0014
 0004: 0x15 0x09 0x00 0x0000003b  if (A == execve) goto 0014
 0005: 0x15 0x08 0x00 0x00000142  if (A == execveat) goto 0014
 0006: 0x15 0x07 0x00 0x00000002  if (A == open) goto 0014
 0007: 0x15 0x06 0x00 0x00000003  if (A == close) goto 0014
 0008: 0x15 0x05 0x00 0x00000055  if (A == creat) goto 0014
 0009: 0x15 0x04 0x00 0x00000086  if (A == uselib) goto 0014
 0010: 0x15 0x03 0x00 0x00000039  if (A == fork) goto 0014
 0011: 0x15 0x02 0x00 0x0000003a  if (A == vfork) goto 0014
 0012: 0x15 0x01 0x00 0x00000038  if (A == clone) goto 0014
 0013: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0014: 0x06 0x00 0x00 0x00000000  return KILL

From the provided Dockerfile we see that the following script is called:

#!/bin/sh
mv /pwn/flag.txt /pwn/$(xxd -l 16 -p /dev/urandom).txt
service xinetd start
sleep infinity

So we also need to find the flag filename.

Steps:

Once the filename is known just use openat and sendfile to print the flag.

Leak filename:

$ ./solve.py REMOTE
[!] libc base: 0x7ff34a0a1000
...
pwn/716b228a42da0c8b248c9e2f801f2c6f.txt
...

Print flag:

$ ./solve.py REMOTE
[!] libc base: 0x7fb3ed074000
rarctf{all0c4t0rs_d0_n0t_m1x_e45a1bf0b2}  

link to solve.py