A format-string bug is a printf-family call whose format pointer is attacker data. This lab is a 40-line logger I compile with a stack canary so the leak has something recognizable to point at. I show the gdb dump, the matching %p output, a %s crash, and ASan on a sibling sprintf into a 32-byte buffer. I do not use %n, I do not write a GOT slot, and I do not pop a shell.

Stack words printf walks as arguments
Figure 1. printf walks the incoming argument area. A tainted format makes stack slots look like specifiers' arguments.

Lab binary

 1/* fmt_lab.c — toy logger, no network */
 2#include <stdio.h>
 3#include <string.h>
 4#include <unistd.h>
 5
 6static void log_line(const char *user)
 7{
 8    /* bug: user is the format, not the operand */
 9    printf(user);
10    printf("\n");
11}
12
13static void log_box(const char *user)
14{
15    char box[32];
16    /* companion bug: still a format sink, plus a tiny dest */
17    sprintf(box, user);
18    write(STDOUT_FILENO, box, strlen(box));
19    write(STDOUT_FILENO, "\n", 1);
20}
21
22int main(int argc, char **argv)
23{
24    const char *s = (argc > 1) ? argv[1] : "ok";
25    log_line(s);
26    if (argc > 2 && argv[2][0] == 'b')
27        log_box(s);
28    return 0;
29}
1cc -O0 -fPIE -pie -fstack-protector-all -g -o fmt_lab fmt_lab.c
2file fmt_lab
3# fmt_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped
4
5checksec --file=fmt_lab
6# RELRO           STACK CANARY      NX            PIE
7# Partial RELRO   Canary found      NX enabled    PIE enabled

Canary is required for the leak section. I want __stack_chk_guard in the frame so a %p chain has a value I can match against gdb, not a scavenger hunt.

File-level: the sink is real

 1$ objdump -d fmt_lab | sed -n '/<log_line>:/,/ret/p'
 200000000000007a4 <log_line>:
 3    7a4:  a9be7bfd   stp   x29, x30, [sp, #-0x20]!
 4    7a8:  910003fd   mov   x29, sp
 5    7ac:  f9000fe0   str   x0, [sp, #24]        ; user
 6    7b0:  f9400fe0   ldr   x0, [sp, #24]
 7    7b4:  97ffffc6   bl    6d0 <printf@plt>     ; printf(x0)  ← no literal
 8    7b8:  90000000   adrp  x0, 0
 9    7bc:  91208000   add   x0, x0, #0x820       ; "\n"
10    7c0:  97ffffc4   bl    6d0 <printf@plt>
11    7c4:  d503201f   nop
12    7c8:  a8c27bfd   ldp   x29, x30, [sp], #32
13    7cc:  d65f03c0   ret

x0 at the first bl printf@plt is the function argument, not an adrp of a "%s". That is the binary pattern. The patched form (bottom of this note) has adrp of "%s" in x0 and user in x1.

1$ readelf -r fmt_lab | grep JUMP_SLOT
20000000000000fd8  ... R_AARCH64_JUMP_SLOT  printf@GLIBC_2.17 + 0
30000000000000fe0  ... R_AARCH64_JUMP_SLOT  sprintf@GLIBC_2.17 + 0
40000000000000fe8  ... R_AARCH64_JUMP_SLOT  write@GLIBC_2.17 + 0
50000000000000ff0  ... R_AARCH64_JUMP_SLOT  __stack_chk_fail@GLIBC_2.4 + 0

__stack_chk_fail confirms the canary. sprintf is the companion sink.

gdb: stack picture, then a %p leak that matches

set disable-randomization on so the numbers in the note repeat. Production traces redact the slide.

 1$ gdb -q ./fmt_lab
 2(gdb) set disable-randomization on
 3(gdb) break log_line
 4(gdb) run '%p.%p.%p.%p.%p.%p.%p.%p'
 5Breakpoint 1, log_line (user=0xaaaaaaaaae12 "%p.%p.%p.%p.%p.%p.%p.%p")
 6
 7(gdb) info frame
 8Stack level 0, frame at 0xffffffffe2d0:
 9 pc = 0xaaaaaaab07a4 in log_line; saved pc = 0xaaaaaaab0810
10 called by frame at 0xffffffffe350
11
12(gdb) x/8gx $sp
130xffffffffe2b0:  0x0000ffffffffe2d0  0x0000aaaaaaab0810   ; saved fp, lr
140xffffffffe2c0:  0x0000aaaaaaaaae12  0x0000fffff7ffd000   ; user*, guard-ish
150xffffffffe2d0:  0x0000ffffffffe350  0x0000fffff7d3c4a0   ; main fp, libc
160xffffffffe2e0:  0x0000000000000002  0x0000ffffffffe458   ; argc, argv

The canary on aarch64+glibc is a qword pulled from TLS (__stack_chk_guard). gcc -O0 -fstack-protector-all placed it next to the saved frame. I print TLS and the slot:

1(gdb) p/x *(unsigned long *)($sp + 24)
2$1 = 0xfffff7ffd000            ; slot I treat as the guard copy  [REDACTED]
3(gdb) # continue into printf, let it print
4(gdb) continue
50xffffffffe2d0.0xaaaaaaab0810.0xaaaaaaaaae12.0xfffff7ffd000.0xffffffffe350.0xfffff7d3c4a0.0x2.0xffffffffe458

Eight %ps, eight qwords, same order as x/8gx $sp. That is the leak, measured. The fourth word matches the canary copy. I do not then compute a write; I now know:

  1. The format walks this frame’s stack as if those qwords were printf arguments (aarch64 actually passes the first 7–8 args in registers; glibc’s printf then pulls from the va_list save area — the lab still dumps the save area / incoming stack, and the printed words match what gdb sees).
  2. A canary-shaped value is in the output. That is the “stack cookie conceptually” part of the lab: disclosure of a secret the smash would need, without a smash.

On x86_64 SysV the match is even dumber — extra %ps after the register-saved args come straight off the stack. I keep an x86_64 dump for audits that are not ARM:

 1$ cc -O0 -fstack-protector-all -g -o fmt_lab_x64 fmt_lab.c   # x86_64 host
 2$ gdb -q ./fmt_lab_x64
 3(gdb) break log_line
 4(gdb) run '%p.%p.%p.%p.%p.%p.%p.%p'
 5(gdb) x/8gx $rsp
 60x7fffffffe2b0:  0x00007fffffffe3c0  0x00005555555551c8
 70x7fffffffe2c0:  0x00007fffffffe4e2  0x2b2b9c3d4e5f6071   ; canary
 8(gdb) continue
 90x7fffffffe3c0.0x5555555551c8.0x7fffffffe4e2.0x2b2b9c3d4e5f6071....
10# 4th %p == canary qword.  [host canary redacted in published notes]

I replace the live canary with a marked value in any note that leaves the lab host.

Crash: %s walking off into unmapped pointers

%p leaks. %s dereferences the next argument as a C string. When that slot is 0x2 (argc) or a non-pointer qword, printf reads unmapped memory.

 1$ ./fmt_lab '%s%s%s%s%s%s%s%s'
 2Segmentation fault (core dumped)
 3
 4$ gdb -q ./fmt_lab core
 5(gdb) bt
 6#0  __strlen_aarch64 () at ../sysdeps/aarch64/strlen.S:62
 7#1  0x0000fffff7e9a010 in _IO_vfprintf_internal (...)
 8#2  0x0000fffff7e9c4a0 in printf (...)
 9#3  0x0000aaaaaaab07b4 in log_line (user=0x... "%s%s%s%s%s%s%s%s")
10#4  0x0000aaaaaaab0810 in main (argc=2, argv=0x...)
11
12(gdb) info registers x0
13x0             0x2      2          ; strlen(2)  → SIGSEGV

Fault address is not a useful “write primitive”. It is a crash. Tombstone:

1$ dmesg | tail -3
2# aarch64 lab VM
3[  412.010] fmt_lab[4120]: unhandled level 0 translation fault (11)
4[  412.011] pc : strlen+0x3c / libc-2.31.so
5[  412.011] far: 0000000000000002

That is the sanitized reproduction for the printf(user) path: input '%s'*8, far=2, pc in strlen.

ASan: the sprintf(box, user) companion

ASan does not classify “tainted format” as a bug class. A leak of %p under ASan still just prints. I still build with ASan because log_box writes a long expansion into 32 bytes.

1cc -O0 -fPIE -pie -fsanitize=address -fstack-protector-all -g -o fmt_asan fmt_lab.c
 1$ ./fmt_asan '%p%p%p%p%p%p%p%p%p%p' b
 2=================================================================
 3==4120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
 4WRITE of size 2 at 0x... thread T0
 5    #0 sprintf
 6    #1 log_box fmt_lab.c:16
 7    #2 main fmt_lab.c:26
 8Address 0x... is located in stack of thread T0 at offset 64 in frame
 9    log_box
10  This frame has 1 object(s):
11    [32, 64) 'box' (line 14) <== Memory access at offset 64
12HINT: this is a stack-buffer-overflow of the sprintf dest, caused by
13      treating user input as a format (many %p → long ASCII).
14Shadow bytes around the buggy address:
15  00 00 00 00[f1]f1 f1 f1 00 00 00 00[f3]f3 f3 f3

Ten %ps expand to far more than 32 ASCII bytes. ASan names box. That is the ASan half of the lab. Combined with the gdb leak, I have: disclosure of stack words, crash on %s, ASan overflow on sprintf. Still no %n.

log_line under ASan with '%s'*8 is usually still a raw SIGSEGV inside libc strlen; ASan intercepts some strlens but a pointer of 2 is not in a poisoned shadow, it is just unmapped. I record both outcomes so the next audit does not expect ASan to “see” format bugs.

Patch

 1static void log_line(const char *user)
 2{
 3    printf("%s", user);     /* format is a literal */
 4    printf("\n");
 5}
 6
 7static void log_box(const char *user)
 8{
 9    char box[32];
10    snprintf(box, sizeof box, "%s", user);
11    write(STDOUT_FILENO, box, strlen(box));
12    write(STDOUT_FILENO, "\n", 1);
13}
1; patched log_line, first call
2    7b0:  90000000   adrp  x0, 0
3    7b4:  9120a000   add   x0, x0, #0x828     ; "%s"
4    7b8:  f9400fe1   ldr   x1, [sp, #24]      ; user
5    7bc:  97ffffc5   bl    6d0 <printf@plt>

x0 is a literal, x1 is data. The %p input now prints as ASCII percent-p, and ASan is silent.

1$ ./fmt_lab '%p.%p.%p'
2%p.%p.%p
3$ ./fmt_asan '%p%p%p%p%p%p%p%p%p%p' b
4%p%p%p%p%p%p%p%p%p%p

Compiler backups: -Wformat -Werror=format-security turns printf(user) into a build failure on gcc/clang when user is not a literal. I keep that in CI so the patch cannot regress.

1$ cc -O0 -Wformat -Werror=format-security -c fmt_lab.c
2fmt_lab.c: In function 'log_line':
3fmt_lab.c:8:5: error: format not a string literal and no format arguments
4     printf(user);
5     ^~~~~~

What I file after this lab

  • Sink: log_line+0x10 bl printf@plt with x0 = user
  • Canary present (__stack_chk_fail JUMP_SLOT); %p chain matches x/8gx $sp
  • Crash: '%s'*8strlen FAR=0x2, SIGSEGV
  • ASan: sprintf(box[32], user) with ten %p → stack-buffer-overflow
  • Fix: printf("%s", user) / snprintf(box, sizeof box, "%s", user) plus -Werror=format-security

Detection / hardening

  • Source grep: printf(, sprintf(, fprintf(, syslog( where the format argument is not a literal. Wrappers that take fmt and va_list get the same rule at every caller.
  • Binary: xrefs to printf@plt; reject call sites whose x0/rdi is not an adrp of .rodata.
  • Do not “fix” this with snprintf keeping the tainted format. snprintf bounds the output length; it still interprets %.
  • %n is disabled in modern glibc by default (puts/printf with %n*** %n in writable segment detected ***). I still do not treat that as a license to pass untrusted formats: leaks remain.

Commands appendix

1cc -O0 -fPIE -pie -fstack-protector-all -g -o fmt_lab fmt_lab.c
2objdump -d fmt_lab | sed -n '/<log_line>:/,/ret/p'
3gdb -q ./fmt_lab -ex 'set disable-randomization on' \
4    -ex 'b log_line' -ex "run '%p.%p.%p.%p.%p.%p.%p.%p'"
5# at the bp:  x/8gx $sp   then continue and diff against stdout
6cc -O0 -fsanitize=address -g -o fmt_asan fmt_lab.c
7./fmt_asan '%p%p%p%p%p%p%p%p%p%p' b
8cc -Wformat -Werror=format-security -c fmt_lab.c   # should fail until patched