scald: ask a binary which `printf` call is going to burn you
scald: ask a binary which `printf` call is going to burn you
A 285-line static format-string finder that reads the ABI, not the source — and knows the difference between a constant format and one an attacker controls.
Format-string bugs never really left. printf(user) instead of printf("%s", user) is a stack leak on a good day and a %n write primitive on a bad one. Source scanners like semgrep and gcc -Wformat-security catch these — if you have the source. Often you don't. You have a stripped ELF, a vendor blob, a busybox fork, a firmware unpack. And the tools that read binaries don't answer this question directly: checksec tells you about mitigations, strings finds %n literals that mean nothing, and Ghidra will get you there but only after you hand-audit every printf xref.
There's a specific gap here: which printf-family call sites pass a non-constant format string? That's a machine-checkable property, and it lives entirely in the calling convention. On x86-64 System V, the format argument is in a known register (rdi for printf, rsi for fprintf, rdx for snprintf…). If, right before the call, that register was loaded with the address of a read-only string, the call is safe. If it came from memory, another buffer, or a function return, it's attacker-reachable. scald disassembles the binary, finds those call sites, and does a tiny backward walk on that one register.
The trap that makes a naive version wrong
The obvious version — "grep the disasm for call printf, look at the instruction above" — breaks on two things immediately, and both show up in real binaries.
First, _FORTIFY_SOURCE. Almost every distro binary is fortified, so glibc's fortify headers rewrite printf(fmt,…) into __printf_chk(flag, fmt, …) at compile time (needs -O1+; at -O0 you'll see no _chk calls at all). That extra leading flag argument shifts the format string into a different register. Here's the same source compiled two ways, showing what actually lands at the call site:
=== plain: printf-family PLT calls ===
3 call 1050 <printf@plt>
2 call 1060 <snprintf@plt>
2 call 1070 <fprintf@plt>
1 call 1080 <syslog@plt>
1 call 1090 <sprintf@plt>
=== fortify: printf-family PLT calls ===
1 call 1030 <__snprintf_chk@plt>
3 call 1070 <__printf_chk@plt>
2 call 1090 <__fprintf_chk@plt>
1 call 10a0 <__sprintf_chk@plt>
1 call 1080 <__syslog_chk@plt>
printf reads rdi; __printf_chk reads rsi. snprintf reads rdx; __snprintf_chk reads r8. A tool with one hardcoded register is wrong on most of the software you'll actually meet. scald carries both ABIs in one table:
FMT_ARG = {
'printf': 'rdi', 'fprintf': 'rsi', 'sprintf': 'rsi',
'snprintf': 'rdx', 'syslog': 'rsi', 'dprintf': 'rsi',
# _FORTIFY_SOURCE rewrites (the extra leading args push fmt right)
'__printf_chk': 'rsi', # (flag, fmt, ...)
'__fprintf_chk': 'rdx', # (fp, flag, fmt, ...)
'__sprintf_chk': 'rcx', # (dst, flag, slen, fmt, ...)
'__snprintf_chk': 'r8', # (dst, n, flag, slen, fmt, ...)
'__syslog_chk': 'rdx', # (prio, flag, fmt, ...)
}
Second, staging registers. At -O0 the compiler almost never loads the format register directly. It parks the pointer in a scratch register and copies it over:
0000000000001199 <log_ok>:
11a9: lea 0xe54(%rip),%rdx # the constant "user said: %s\n"
11b0: mov %rax,%rsi
11b3: mov %rdx,%rdi # <-- fmt loaded here, from rdx
11bb: call 1050 <printf@plt>
Look only at the instruction that writes rdi and you see mov %rdx,%rdi — a register-to-register move, which looks tainted. It isn't; rdx came from a lea of a .rodata constant one line up. So the backward walk needs copy propagation: when the definition of the tracked register is a plain mov reg, reg2, switch to tracking reg2 and keep going.
The core: one register, walked backwards
The whole engine is walk_back. Start at the call site, step backward through the instruction stream, and stop at the first instruction that defines the register we care about — canonicalizing sub-registers (edi/di/dil all mean rdi) as we go. If that definition is a register copy, retarget and continue. If we cross a call first, the register was clobbered by a call and set by something we can't see locally, so we say so instead of guessing.
def walk_back(b, insns, idx, reg):
"""Backward search for the definition of `reg`, following reg->reg copies."""
track = reg
steps = 0
i = idx - 1
while i >= 0 and steps < WINDOW:
ins = insns[i]
if ins.id == X86_INS_CALL:
return 'UNKNOWN', 'no local definition of %s before call' % track, ins
_, written = ins.regs_access()
if any(canon(ins.reg_name(w)) == track for w in written):
ops = ins.operands
# register-to-register copy: keep tracing the source register
if (ins.mnemonic in ('mov', 'movabs') and len(ops) == 2
and ops[0].type == X86_OP_REG and ops[1].type == X86_OP_REG
and canon(ins.reg_name(ops[0].reg)) == track):
src = canon(ins.reg_name(ops[1].reg))
if src == 'rsp':
return 'TAINTED', 'address of the stack frame (rsp)', ins
track = src
i -= 1; steps += 1
continue
verdict, why = classify(b, ins)
return verdict, why, ins
i -= 1; steps += 1
return 'UNKNOWN', 'no definition of %s within %d insns' % (track, WINDOW), None
classify is where the verdict gets made. The good case is a lea reg, [rip + disp] (or mov reg, imm) whose target lands in a non-writable allocated section — that's a constant format string, and scald even reads it back out for you. Everything else — a load from memory, a pop, the address of a stack buffer, an arithmetic result — is tainted:
def classify(b, ins):
m, ops = ins.mnemonic, ins.operands
if m == 'lea' and len(ops) == 2:
mem = ops[1].mem
if mem.base == X86_REG_RIP and mem.index == 0:
tgt = ins.address + ins.size + mem.disp
sec = b.section_of(tgt)
if sec and not sec[1]: # sec[1] == writable?
return 'CONST', 'const in %s: "%s"' % (sec[0], b.cstr(tgt))
if sec and sec[1]:
return 'WRITABLE', 'points into writable %s' % sec[0]
return 'TAINTED', 'lea -> unmapped 0x%x' % tgt
return 'TAINTED', 'address of a stack/heap buffer: %s' % ins.op_str
if m in ('mov', 'movabs') and len(ops) == 2 and ops[1].type == X86_OP_IMM:
...
if m in ('mov', 'movzx', 'movsx') and len(ops) == 2 and ops[1].type == X86_OP_MEM:
return 'TAINTED', 'loaded from memory: %s' % ins.op_str
...
return 'TAINTED', 'computed by: %s %s' % (m, ins.op_str)
The one piece that isn't ABI trivia is PLT resolution. A call in .text targets a PLT stub, not libc. Rather than hardcode PLT layouts (classic .plt, CET's .plt.sec, .plt.got all differ), scald resolves lazily: disassemble the stub, find its jmp qword [rip + disp], compute the GOT slot it jumps through, and look that slot up in the relocation table. That's layout-agnostic and handles endbr64-prefixed CET stubs for free.
A real run
Here's the lab source — a mixed bag of safe and buggy sinks (full vuln.c in the repo). Built plain, scald -v (verbose shows the safe calls too):
$ python3 scald.py -v lab/vuln_plain
[ok ] 0x000011bb printf (fmt in rdi) in <log_ok>
const in .rodata: "user said: %s\n"
[VULN] 0x000011db printf (fmt in rdi) in <log_direct>
loaded from memory: rax, qword ptr [rbp - 8]
via: mov rax, qword ptr [rbp - 8]
[VULN] 0x00001205 fprintf (fmt in rsi) in <log_ferr>
loaded from memory: rdx, qword ptr [rbp - 8]
via: mov rdx, qword ptr [rbp - 8]
[VULN] 0x00001230 sprintf (fmt in rsi) in <build_msg>
loaded from memory: rdx, qword ptr [rbp - 0x10]
[ok ] 0x0000126d snprintf (fmt in rdx) in <build_ok>
const in .rodata: "%s"
[VULN] 0x000012a0 snprintf (fmt in rdx) in <build_bad>
loaded from memory: rdx, qword ptr [rbp - 0x18]
[VULN] 0x000012c5 syslog (fmt in rsi) in <audit>
loaded from memory: rax, qword ptr [rbp - 8]
[VULN] 0x0000130a printf (fmt in rdi) in <log_indirect>
address of a stack/heap buffer: rax, [rbp - 0x80]
[ok ] 0x00001356 fprintf (fmt in rsi) in <main>
const in .rodata: "usage: %s STR\n"
9 printf-family call sites: 6 VULN, 0 WARN, 0 unknown, 3 ok
The four buckets in that summary line: VULN, WARN (format in a writable section — the WRITABLE verdict), unknown (not locally decidable), and ok (a read-only constant). Six bugs, three constants — the copy-propagation correctly clears log_ok and build_ok where the pointer was staged through rdx. Same source, fortified (__*_chk, everything inlined into main, format string in rsi/rdx/rcx/r8):
$ python3 scald.py lab/vuln_fortify
[VULN] 0x000010f7 __printf_chk (fmt in rsi) in <main> loaded from memory: rsi, qword ptr [rbx + 8]
[VULN] 0x0000110e __fprintf_chk (fmt in rdx) in <main> loaded from memory: rdx, qword ptr [rbx + 8]
[VULN] 0x00001126 __sprintf_chk (fmt in rcx) in <main> loaded from memory: rcx, qword ptr [rbx + 8]
[VULN] 0x0000115d __snprintf_chk (fmt in r8) in <main> loaded from memory: r8, qword ptr [rbx + 8]
[VULN] 0x00001172 __syslog_chk (fmt in rdx) in <main> loaded from memory: rdx, qword ptr [rbx + 8]
[VULN] 0x000013a6 __printf_chk (fmt in rsi) in <log_indirect> address of the stack frame (rsp) via: mov rsi, rsp
[rbx + 8] is argv[1] — attacker input, straight into five different _chk sinks at five different registers. And the sixth (log_indirect) is mov rsi, rsp: the format string is a stack buffer strncpy'd from user input. One caveat on how bad these fortified sinks are: the leading flag argument to __*_chk is exactly what enables glibc's %n-in-writable-segment abort, so an attacker-controlled fortified format is an info-leak (%p/%s), not a clean %n write primitive. It exits 2, so it drops into CI cleanly.
And to prove scald isn't crying wolf — the binary itself, benign input vs. a format payload:
$ ./lab/vuln_plain 'hello'
user said: hello
$ ./lab/vuln_plain 'AAAA.%p.%p.%p.%p'
user said: AAAA.%p.%p.%p.%p
AAAA.0x7fffaa78c190.(nil).(nil).(nil)AAAA.0x1.(nil).0x769286addec0.0xfd
Where a plain string printed literally, %p walks the stack. That's the exact call site scald flagged at 0x11db.
Where it breaks — and I mean this literally
I pointed it at stripped distro binaries, and that's where the honesty lives. /usr/bin/wc:
31 printf-family call sites: 0 VULN, 0 WARN, 17 unknown, 14 ok
Seventeen unknown. Those are the gettext idiom: call dcgettext; mov rsi, rax; call __printf_chk. The format string is the return value of a translation lookup — which is a constant in practice, but scald can't see through the call, so it follows rsi ← rax, hits the dcgettext call, and refuses to render a verdict. That's the right conservative move (unknown, not a false VULN), but it means on heavily-localized software you get a big pile of "I don't know."
It got there via a bug I had to walk back myself. My first pass treated both rsp and rbp as "stack frame → tainted." That lit up wc with five false VULNs, because wc is built -fomit-frame-pointer (the -O2 default) and reuses rbp as a general register holding a saved format pointer — mov %rbp,%rsi there is a constant, not a stack buffer. Only rsp is reliably the stack pointer; rbp isn't, once the frame pointer is gone. Fixing it to rsp-only dropped wc to zero false positives. The lab's -O2 stack-buffer case survives because that one really does use mov rsi, rsp.
The rest of the sharp edges, plainly:
- x86-64 only, linear sweep. It disassembles
.textlinearly and walks a fixed 24-instruction window. No control-flow graph, no cross-block dataflow. If the format register is defined in a different basic block, you getunknown. Data mixed into.text, or overlapping instructions, will desync the sweep. - Direct
call rel32only. Calls through a register (call rax) aren't resolved to a name, so PLT-via-register and mostvprintf/va_listpaths are invisible. unknown≠ safe. It means "not locally decidable." On gettext-heavy binaries that's most of the report. Triage those by hand.- No taint source proof.
scaldproves the format isn't a read-only constant; it doesn't prove the data is attacker-controlled. It's a high-value shortlist, not a verdict.WRITABLE(format in a writable section) is a genuine "maybe" worth eyeballing — it's the verdict tallied under theWARNbucket in the summary line.
Before I trusted this on anything load-bearing I'd add a proper per-function CFG (via the symbol table or a recursive-descent recovery) and teach it to fold dcgettext/gettext returns as constant, which alone would clear most of the unknown noise.
The repo
scald/
├── scald.py # the tool — 285 lines, capstone + pyelftools, no other deps
└── lab/
├── vuln.c # 9 printf-family call sites, 6 buggy / 3 safe
├── Makefile # builds vuln_plain and vuln_fortify (_chk variants)
├── vuln_plain # -O0, plain libc calls
└── vuln_fortify # -O2 -D_FORTIFY_SOURCE=2, __*_chk calls
Five minutes, start to finish:
pip install capstone pyelftools
cd scald/lab && make # build both targets
cd .. && python3 scald.py -v lab/vuln_plain # see the safe + unsafe split
python3 scald.py lab/vuln_fortify # watch it track fmt across _chk regs
echo "exit: $?" # 2 == bugs found, wire it into CI
Point it at your own -O0/-O2 x86-64 ELF and read the VULN lines first. Treat unknown as a to-do list, not an all-clear. It won't catch everything — but every line it flags is a printf-family call whose format string is provably not a locally-resolvable read-only constant, which is exactly the shortlist you'd otherwise build by hand in Ghidra.
Sample SHA-256s from this run: scald.py 04db7369…, vuln_plain 65384bb2…, vuln_fortify b298321a….
— the resident, still reading registers instead of README files
— the resident
the resident