EXPLOIT-DEV · LESSON 04

Return-Oriented Programming

25 min

Learning Objectives

  • Explain why DEP forces attackers to reuse existing code instead of injecting new code
  • Define a gadget and explain how gadgets chain via ret
  • Trace a short ROP chain and predict the resulting register/stack state
  • Explain why a ROP chain alone doesn't defeat ASLR (an info leak is still required)

Reusing the Building, Not Bringing Your Own

DEP/NX makes the stack and heap non-executable, so shellcode dropped there can’t run. Return-Oriented Programming sidesteps this without ever writing new executable code: it reuses instructions that already exist in the target binary or its loaded libraries — code that’s already marked executable — and chains tiny fragments of it together purely by controlling the stack.

Gadgets

A gadget is a short instruction sequence, already present in the binary, ending in ret:

pop rdi
ret

That’s a complete, useful gadget: it pops one value off the stack into rdi and then returns. Binaries are full of these — not because anyone wrote them intentionally, but because ret bytes (0xc3) and short instruction sequences occur naturally throughout compiled code, including inside the middle of other instructions when read at an unintended offset.

The mechanism that makes chaining possible is the same one from e1: ret pops whatever is at the top of the stack into RIP and jumps there. If the attacker controls the stack contents (via the same overflow that got them here), each ret becomes a “call” to the next gadget the attacker chose — there’s no need to control RIP more than once. Set up the stack correctly, trigger the first ret, and the chain drives itself.

Tracing a Chain

Say the goal is to call execve("/bin/sh", NULL, NULL) — same target as the shellcode example in e2 — but built entirely from gadgets instead of injected code. The stack, from the corrupted return address upward, might look like:

[ &pop_rdi_ret gadget   ]   <- overwritten return address; first ret lands here
[ &"/bin/sh" string     ]   <- popped into rdi by the gadget above
[ &pop_rsi_ret gadget   ]   <- next ret target
[ 0x0                   ]   <- popped into rsi
[ &pop_rdx_ret gadget   ]
[ 0x0                   ]   <- popped into rdx
[ &execve_addr          ]   <- final ret target: jumps into execve with args set

Tracing it: the first ret (from the vulnerable function itself) jumps to pop_rdi_ret. That instruction pops the next stack value — the address of "/bin/sh" — into rdi, then its own ret pops the next value, &pop_rsi_ret, into RIP. That gadget pops 0x0 into rsi and returns again, landing on pop_rdx_ret, which pops 0x0 into rdx. By the time execution reaches &execve_addr, rdi/rsi/rdx hold exactly the arguments the syscall convention from e2 expects, and the final ret doesn’t land on another gadget — it lands directly on the function itself. Every step is a legitimate ret transferring to legitimate, already-executable code; DEP never triggers because nothing is ever executed from a writable page.

What ROP Doesn’t Solve

A ROP chain is built entirely out of absolute addresses — the address of each gadget, the address of the target function, the address of the string. Every one of those addresses lives inside the binary or a loaded library, and ASLR randomizes exactly those base addresses on every run. A chain built for one run’s addresses is wrong on the next.

ROP is therefore a technique for defeating DEP, not ASLR. To build a working chain against an ASLR-protected target, the attacker still needs a separate information leak first — a bug that discloses even one real runtime address (a stack pointer, a return address, a library base) — and derives every gadget address from that leak by known fixed offsets. Without the leak, the offsets computed in e1 and the gadget layout traced above are exactly right in structure and completely wrong in value.

Takeaway

A gadget is borrowed code and a ret is a free jump — chain enough of them and you’ve written a program without executing a single byte you supplied yourself.