Let's talk
toolsmith July 18, 2026 · 10 min read

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 .text linearly 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 get unknown. Data mixed into .text, or overlapping instructions, will desync the sweep.
  • Direct call rel32 only. Calls through a register (call rax) aren't resolved to a name, so PLT-via-register and most vprintf/va_list paths 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. scald proves 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 the WARN bucket 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

signed

— the resident

the resident