InnoCTF
pwn Reproducible walkthrough

Return-oriented programming: building a ROP chain

Schematic of stacked return addresses forming a chain of code gadgets, the central idea of return-oriented programming
A stack laid out as a program: addresses, not instructions. Lab capture, isolated network.

Once the stack is non-executable, the classic "jump to your shellcode" trick stops working. Return-oriented programming is the answer: instead of injecting code, you reuse code that is already in the binary, stitching together short fragments that each end in a return. This walkthrough builds a chain from first principles up to a call to execve.

Why ROP exists

Modern binaries ship with the no-execute (NX) bit set on the stack, so even if you overflow a buffer and land your bytes there, the CPU refuses to run them. ROP sidesteps the rule entirely. It never executes attacker-supplied code. It executes the program's own bytes, in an order the author never intended, by controlling the one thing the overflow already gives you: the return address, and everything above it on the stack.

A ROP chain is a program written in a language whose only instruction is "the address of some code that ends in ret." the mental model that makes gadgets click

Gadgets: the vocabulary

A gadget is a short sequence of instructions ending in ret. When the CPU returns, it pops the next address off the stack and jumps there, so a stack full of gadget addresses runs them back to back. Each ret advances to the next entry you placed. You find gadgets by scanning the binary:

find-gadgets.sh
# list useful register-loading gadgets
$ ROPgadget --binary ./vuln | grep ': pop rdi'
0x0000000000401283 : pop rdi ; ret
$ ROPgadget --binary ./vuln | grep ': pop rsi'
0x0000000000401281 : pop rsi ; pop r15 ; ret

On x86-64 the System V calling convention passes the first arguments in rdi, rsi, rdx. So a pop rdi ; ret gadget lets you load the first argument from the stack, then continue the chain. That single primitive is most of what you need to call a function with controlled arguments.

The plan: call execve("/bin/sh")

The goal is a shell. The cleanest route, when the binary or libc gives you the pieces, is to set up a syscall to execve with rdi pointing at the string "/bin/sh", the right syscall number in rax, and the argument and environment pointers null. The chain is: control the saved return address, then lay out gadget addresses and the values they pop.

exploit.py
# pwntools: build the chain after the overflow padding
from pwn import *

elf = ELF("./vuln")
rop = ROP(elf)
binsh = next(elf.search(b"/bin/sh\x00"))   # or place it yourself

rop.rax = 59            # execve syscall number
rop.rdi = binsh         # filename
rop.rsi = 0             # argv
rop.rdx = 0             # envp
rop.raw(rop.find_gadget(["syscall"]).address)

payload = b"A" * offset + rop.chain()
p = process("./vuln")
p.sendline(payload)
p.interactive()

The offset is the number of bytes from the start of your input to the saved return address, which you find with a cyclic pattern and a crash. Everything after it is your chain, popped one ret at a time.

When the binary is small: ret2libc and leaks

Small challenge binaries rarely contain every gadget you want. The usual move is to call into libc, where the gadgets and useful functions live. But position-independent libc is randomised, so you first need a leak: use a chain that calls puts on a known GOT entry to print a libc address, compute the base, then send a second chain that calls system("/bin/sh") at the now-known address.

The transferable habit

ROP is disciplined bookkeeping. Find the offset, enumerate your gadgets, and treat the stack as a sequence of small, verifiable steps. Test each stage in gdb and confirm a register holds what you expect before adding the next gadget. The same patience that recovers a leaked libc base is the patience that carves an implant out of a memory image, and that recognises an exploitable difference the way a blind SQL injection exposes one bit at a time.

Disclosure: This article was researched and drafted with AI assistance and edited by the InnoCTF Editorial Team. It explains a well-documented technique for education and authorized testing only; it does not target any live system.

Sources

  1. Shacham, H. "The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls" (ACM CCS 2007). Read the paper
  2. pwntools. "ROP" (documentation). Read the docs
  3. MITRE. "CWE-787: Out-of-bounds Write." Read the CWE entry