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.
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:
- The format walks this frame’s stack as if those qwords were
printfarguments (aarch64 actually passes the first 7–8 args in registers; glibc’sprintfthen pulls from theva_listsave area — the lab still dumps the save area / incoming stack, and the printed words match what gdb sees). - 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+0x10bl printf@pltwithx0 = user - Canary present (
__stack_chk_failJUMP_SLOT);%pchain matchesx/8gx $sp - Crash:
'%s'*8→strlenFAR=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 takefmtandva_listget the same rule at every caller. - Binary: xrefs to
printf@plt; reject call sites whosex0/rdiis not anadrpof.rodata. - Do not “fix” this with
snprintfkeeping the tainted format.snprintfbounds the output length; it still interprets%. %nis disabled in modern glibc by default (puts/printfwith%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