RaRCTF - The Mound
[ pwn , heap ]

Points: 800
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:

 |                                                 |------------------------------|
 |       |-----------------------------------|     | 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)
 |--> 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
 |--> 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:

 |--> 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);

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:

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.


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

Leak filename:

$ ./solve.py REMOTE
[!] libc base: 0x7ff34a0a1000

Print flag:

$ ./solve.py REMOTE
[!] libc base: 0x7fb3ed074000

link to solve.py