hackweb
notes on hacking and technology
IT EN
main menu
user menu
you are not logged in

ROP: building code when you cannot inject it

March 11, 2026 · 4 min read · #binary-exploitation #rop #linux

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.


« back to home

latest posts
 
your IP address:
216.73.216.108
visitor #0
MOTD:
Every abstraction leaks somewhere.
Here we look at where.
topics