ROP: building code when you cannot inject it
For years the exploit for a stack overflow ended like this: write your
shellcode into the buffer, overwrite the return address to point into it, and the
CPU runs your bytes. Then came NX (No-eXecute, the
W^X bit): pages are writable or executable, never both. The
stack is data, so it does not execute. Shellcode in the buffer becomes inert
bytes.
Return-oriented programming sidesteps the problem at the root: you do not need to inject code if the code you need is already mapped. The binary's text and libc are executable by definition. The trick is to use them in pieces.
The gadget
A gadget is a short sequence of instructions ending in
ret. For example:
pop rdi
ret
This gadget takes a value off the stack into rdi and then returns.
The ret is the key: it pops the next address off the stack and jumps
there. If you control the stack, you control which gadget runs next. The chain
becomes a list of gadget addresses interleaved with the data each gadget
consumes.
Why pop rdi matters
In the System V calling convention (Linux x86-64) the first integer arguments
go in rdi, rsi, rdx, rcx, r8, r9. To call
system("/bin/sh") you must put the pointer to the string in
rdi and jump to system. That is why
pop rdi; ret is the most sought-after gadget in the whole
discipline.
# conceptual chain on the stack, from the return address onward:
[ &(pop rdi; ret) ] # gadget: load the next qword into rdi
[ &"/bin/sh" ] # data consumed by the pop
[ &system ] # ret jumps here, with rdi already set
The stack alignment trap
A detail that costs hours: the ABI requires rsp to be 16-byte
aligned at the point of a call. Many modern libc functions use SSE
instructions (movaps) that segfault on a misaligned
stack. If your chain calls system and crashes inside
do_system on a movaps, that is not your bug: add one
empty ret before the call to realign.
[ &(ret) ] # alignment gadget: consumes 8 bytes, realigns rsp
[ &(pop rdi; ret) ]
[ &"/bin/sh" ]
[ &system ]
ASLR: without a leak you go nowhere
With ASLR the libc addresses change on every run, so you cannot hardcode
&system. You need a leak: make the program print an
address that points into libc, compute the base by difference, and derive
everything else from it.
The classic two-stage pattern, when you have a single write primitive but the
binary calls puts:
# Stage 1: leak. Print puts's GOT address, then return to main.
[ &(pop rdi; ret) ]
[ &puts@got ] # rdi = pointer to puts's GOT entry
[ &puts@plt ] # puts(puts@got) -> prints puts's runtime address
[ &main ] # return to main for a second round of input
Once the leak arrives you compute the base:
libc_base = leaked_puts - libc.symbols['puts']
system = libc_base + libc.symbols['system']
binsh = libc_base + next(libc.search(b'/bin/sh'))
The second round of input uses the now-known addresses for the
final chain into system("/bin/sh"). The assumption is that you know
the target's exact libc version: leaking a single symbol plus the well-known
libc database (known offsets for each build) is usually enough to
identify it.
When gadgets are not enough: ret2csu
Sometimes the binary is tiny and has no pop rdx; ret, which you
need for the third argument (think execve, or
mprotect(addr, len, RWX)). The lifeline is called
ret2csu: it abuses two blocks the linker always inserts
into dynamically linked binaries, inside __libc_csu_init.
The first block runs a string of pops filling
rbx, rbp, r12, r13, r14, r15; the second copies
r13/r14/r15 into rdx/rsi/rdi (edi) and then calls
call [r12+rbx*8]. By setting rbx=0 and r12
to a pointer to a function pointer, you get control of three argument registers and
an indirect call, all without dedicated gadgets. It is the Swiss army knife when
the binary is bare.
How you find gadgets
ROPgadget --binary ./target | grep ': pop rdi'
# or, far more convenient, inside pwntools:
from pwn import *
elf = ELF('./target')
rop = ROP(elf)
rop.raw(rop.find_gadget(['pop rdi', 'ret']))
rop.call('system', [next(elf.search(b'/bin/sh'))])
Where the defence stands today
ROP is not unbeatable. Modern countermeasures attack its very mechanics:
CET Shadow Stack keeps a protected copy of return addresses and
trips if a ret does not match; stack canaries catch
the linear overflow before it reaches the return address; CFI
(Control-Flow Integrity) checks that indirect jumps land on legitimate targets.
But on legacy code, drivers, firmware and binaries built without these
protections, the gadget chain remains the reference technique for turning an
out-of-bounds write into code execution.