Introduction

In the late 1990s, security researchers began noticing a peculiar class of bug in C programs. The culprit was a single misuse of one of the most common functions in the language: printf.

The safe way to print a user-supplied string is:

printf("%s", user_input);

The dangerous way — seen in real production code — is:

printf(user_input);   /* user controls the format */

Those two lines look almost identical, but their consequences could not be more different. In the safe version, printf treats user_input as pure data. In the dangerous version, printf treats it as a format string — a program that drives how printf reads arguments off the call stack.

If an attacker controls that string, they control what printf does: they can read arbitrary values from the stack by inserting %x or %p specifiers, and — using the special %n specifier — they can write an arbitrary value to an arbitrary address. A single log call becomes a full arbitrary read/write primitive.

The fix is trivially simple. The vulnerability has been known since 1999 (Tymm Twillman and the BIND exploit) and is classified as CWE-134. Yet it kept appearing in shipping software for decades, because a mistake that looks this innocent is easy to make and easy to miss in code review.

Try It

This sandbox simulates the C stack that printf sees. Eight slots hold values a real program might have on its stack. Type a format string in the input below and press Run.

<!-- {{c_html_intro}} -->
<p class="hint">{{hint_para}}</p>
<div class="stack-panel">
  <div class="stack-label">{{label_stack}}</div>
  <div id="stack" class="stack"></div>
</div>
<div class="input-row">
  <label class="input-label" for="fmtInput">{{label_format}}</label>
  <input id="fmtInput" type="text" value="%x.%x.%x" spellcheck="false" autocomplete="off" />
  <label class="chk-label">
    <input type="checkbox" id="safeMode" />
    {{label_safe_mode}}
  </label>
</div>
<div class="btns">
  <button id="runBtn" type="button">{{btn_run}}</button>
  <button id="resetBtn" type="button" class="ghost">{{btn_reset}}</button>
</div>
<div id="output" class="output" aria-live="polite"></div>
<div id="status" class="status" aria-live="polite"></div>
/* {{c_css_intro}} */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.stack-panel { margin-bottom: .6rem; }
.stack-label { font-size: .75rem; font-weight: 700; text-transform: uppercase;
               letter-spacing: .05em; color: #666; margin-bottom: .3rem; }
.stack { display: flex; flex-wrap: wrap; gap: 4px; }
.slot { width: 72px; height: 52px; display: flex; flex-direction: column;
        align-items: center; justify-content: center; border-radius: 8px;
        border: 1.5px solid #cdd9e3; background: #e8eef3; font: 600 11px ui-monospace, monospace;
        color: #1d3557; transition: background .25s, border-color .25s, color .25s; }
.slot .addr { font-size: 9px; color: #888; margin-bottom: 2px; }
.slot.written { background: #ffecb0; border-color: #e8b000; color: #7a4f00; }
.slot.read { background: #d4edff; border-color: #4da3e8; color: #0c3d6b; }
.slot.damaged { background: #ffd6d6; border-color: #e63946; color: #6b0c0c; }
.input-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .5rem; }
.input-label { font-size: .82rem; font-weight: 600; white-space: nowrap; }
input[type=text] { flex: 1 1 160px; font: 14px ui-monospace, monospace; padding: .3rem .5rem;
                   border: 1.5px solid #adb1b8; border-radius: 6px; min-width: 0; }
.chk-label { display: flex; align-items: center; gap: .3rem; font-size: .82rem;
             white-space: nowrap; cursor: pointer; user-select: none; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem;
         border: 1.5px solid #1d3557; background: #1d3557; color: #fff;
         border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.output { font: 14px ui-monospace, monospace; background: #1a1a2e; color: #a8d8a8;
          border-radius: 8px; padding: .5rem .7rem; min-height: 2.4em;
          word-break: break-all; margin-bottom: .4rem; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
// Code not found

Use %x (or %d, %p) to read successive stack slots as hex numbers. Use %n to write the number of characters printed so far into the next slot — that is the write primitive attackers use. Notice how a fixed format printf("%s", input) makes the specifiers inert.

The Real Mechanics

To understand why this is dangerous, you need to understand how printf is implemented.

The call-stack contract. When C calls printf(fmt, a, b, c, ...), the arguments are pushed onto the stack in order. printf walks that list using the format string as a recipe: %d consumes the next integer, %s consumes the next pointer, and so on. If the format string asks for more arguments than were supplied, printf simply keeps reading up the stack — into whatever happens to be there.

Reading memory with %x. An attacker-controlled format like %x.%x.%x.%x leaks four stack words as hex. With enough specifiers, the attacker can reach saved return addresses, canaries, heap pointers, or anything else in scope — a memory disclosure that defeats ASLR.

Writing memory with %n. The %n specifier is the lethal one. It does not print anything; instead it stores the number of characters printed so far into the address pointed to by the corresponding argument. Since printf walks the stack for its arguments, and the attacker can pre-place a target address on the stack (or find one already there), %n turns into an arbitrary write.

Controlling the value. %n writes whatever the character count happens to be. Attackers use width specifiers — %1000u prints 1000 spaces — to make the count equal any desired value. Chaining several %Ku%n pairs lets them write a full 32-bit (or 64-bit) address word by word.

Why the fix works. printf("%s", user_input) gives printf a literal %s format it cannot deviate from. No matter what characters are in user_input, they are consumed as a single string argument, never interpreted as specifiers. The format is a constant in the program, not data supplied at runtime.

Modern compilers (GCC, Clang) warn with -Wformat-security when a non-literal format is passed to printf. Many APIs now enforce compile-time format checks. The vulnerability is solved — but only if the developer listens to the warning.

Where It Matters

Format-string vulnerabilities hit some of the most widely deployed software of their era:

  • wu-ftpd (2000): a format string in the FTP server's SITE EXEC command allowed remote root access on millions of Unix servers overnight. It is one of the most-cited examples of the class.
  • IRIX telnetd (2001): SGI's telnet daemon passed user-supplied environment variables directly to syslog as a format string, allowing pre-authentication remote code execution.
  • Samba (2003): a format string in the smbd printing code was remotely exploitable on Windows file-sharing servers.
  • Network daemons and log calls: any place a server formats a message containing untrusted input without a fixed format is a candidate. Logging libraries have historically been a rich source.
  • IoT and embedded firmware: stripped-down C codebases with no compiler warnings enabled still ship format-string bugs today; they are a routine finding in firmware audits.

The same underlying issue also surfaces in languages beyond C wherever a printf-style function accepts a user-supplied format: older versions of Python's %-formatting, Ruby's sprintf, or any C extension. The general lesson connects to broader ideas about buffer overflows and the importance of treating user data and program control as separate domains.

Conclusion

The format-string vulnerability is, in a sense, the perfect security bug: the dangerous code and the safe code look nearly identical, the fix has been known for over 25 years, compilers have warned about it for decades — and it still appears in new software.

It is a reminder that the existence of a correct pattern does not guarantee it is followed. Every printf(user_input) in a codebase is a trust boundary that was accidentally erased: the program handed the attacker a key to its own stack.

The rule is simple enough to memorize: always pass a fixed format string. printf("%s", msg) instead of printf(msg). fprintf(log, "%s", event) instead of fprintf(log, event). One extra argument, one extra pair of quotes — and the entire class of attacks disappears.

Format strings sit at the intersection of two ideas that recur throughout systems security: the danger of mixing data and instructions, and the cost of ignoring warnings. The same tension appears in SQL injection (data treated as a query) and in every other injection class. The lesson from printf generalizes far beyond C.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/format-string-vulnerability/Content licensed under CC BY-NC 4.0.