Get listed

Format string vulnerability: keep the format a source literal

A rubber stamp overflowing a blank form in coral.

A format-string bug is user input reaching printf-style formatting as the format, not as a value.

In C that can become a write. In higher-level languages it is often a crash or a leak of memory contents. Compiler flags and ‘never pass user text as the format’ are the controls.

The usual mistake is logging sprintf(user_input) because the linter was quiet on that file.

This page is the OpenSSF flag set that still leads with format warnings, and the pattern to grep in your own tree.

The OpenSSF Compiler Options Hardening Guide, dated 20 August 2026, still leads its default flag set with -Wformat -Wformat=2 -Werror=format-security. CWE-134 is a user-controlled format string. The compiler can refuse it. CWE-134 is not on MITRE’s 2025 CWE Top 25, because that warning and _FORTIFY_SOURCE did years of work. What is left in native trees in 2026 is the wrapper that takes a format from a log line, a config key, or a translated string, then hands it to printf.

Naming a specifier is not a control. The control is a format you wrote in the source, a compiler that can see it, and a wrapper attribute so a helper cannot hide it. This page does not show payloads. It shows the call you keep. For the rest of the native surface, keep the C vulnerability guide next to this page, and read buffer overflow when the write is a copy, not a format.

CWE-134 is a format the user wrote

CWE-134, Use of Externally-Controlled Format String, is the moment a printf family function receives a format you did not mint. printf, sprintf, snprintf, fprintf, syslog, errx, and the v-prefixed twins all parse the first data argument as grammar. A user string in that slot is grammar. A user string in a later slot, behind a literal "%s", is data.

CWE-134 starts when printf reads the first slot as grammar. Keep a literal in that box and leave user_name in the args.

SecureCoding

MITRE’s own CWE-134 page still says the class is easier to find than it used to be. That is the compiler warning working. It is not a reason to skip the literal. A format that arrives from a request, a file, a translated catalog, or a config map is still CWE-134, even if the process has no interest in writing memory. A wide specifier can still grow a log or a socket past the buffer you sized. That growth is a buffer overflow with a format-shaped fuse. An oversized width can also trip the integer overflow you thought you had closed in the length field.

What the compiler already refuses

GCC and Clang already know the safe shape. GCC warning docs and the 20 August 2026 OpenSSF guide. The flags that match this page:

  • -Wformat checks that the literal matches the argument types.
  • -Wformat-security warns when the format is not a literal and there are no extra arguments, as in printf(user_name).
  • -Wformat=2 adds -Wformat-nonliteral, so a non-literal format is a warning even when arguments exist.
  • -Werror=format-security promotes the no-argument case to a hard error. OpenSSF puts this in the default set. Debian and Ubuntu dpkg-buildflags have shipped a version of it for years.
# FIX: OpenSSF default, 20 August 2026 guide
CFLAGS += -O2 -Wall -Wformat -Wformat=2 -Werror=format-security

-Werror=format-security will not fail a build that does printf(status_fmt, user_name) when status_fmt is a char *. That is why -Wformat=2 is on the same line. Treat a non-literal warning as a review. The fix is almost always to write the format in the source and pass user_name as data.

Clang’s -Wformat-nonliteral is the same idea. MSVC needs /W4 plus /analyze and the SAL annotation on wrappers. A project that only enables -Wall does not get -Wformat-security on every GCC version in the same way, so write the flags. OpenSSF’s 20 August 2026 list is the one to paste, not a memory of what -Wall used to include.

FORTIFY is a belt, not the fix

_FORTIFY_SOURCE is a glibc plus compiler contract. At -O1 or higher, some libc calls become checked variants. Level 2, documented in the GNU C Library manual, restricts the %n specifier to a format that lives in read-only memory. Level 3, in glibc since 2.34 and in GCC since version 12 (May 2022), uses __builtin_dynamic_object_size so more buffers get a runtime bound. OpenSSF’s 2026 guide still writes the enable as -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3.

# FIX: belt on the copy and on some format checks
CFLAGS += -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3

FORTIFY can abort a process that used a writable format with a dangerous specifier. It cannot invent a literal you never wrote. It cannot see a format that arrived from a file. It cannot mark your log_status helper. The literal is the fix. FORTIFY is the belt you keep on after the literal is in place, the same way a canary is a belt on a copy. Do not ship a user-controlled format and hope level 3 notices.

The safe call shape

Every printf family call on this page uses the same shape: a string literal for the format, then the values.

// BAD: user_name is parsed as a format
// printf(user_name);
// syslog(LOG_INFO, user_name);
// snprintf(dest, dest_cap, user_name);

// FIX: literal format, user_name is data
printf("%s\n", user_name);
syslog(LOG_INFO, "%s", user_name);
int wrote = snprintf(dest, dest_cap, "%s", user_name);
if (wrote < 0 || (size_t)wrote >= dest_cap) {
 return -1;
}

snprintf still needs dest_cap. A literal "%s" stops format parsing. It does not stop a destination that is too small. Check the return. That check is the overlap with the buffer-overflow page: the format is yours, the length is still a number you have to honor.

std::format in C++20 and the {fmt} library check the format at compile time when it is a literal. Prefer them in new C++. They do not help a C translation unit, and they do not help a C++ call that still goes through printf(status_fmt,...).

Wrappers that hide the format

The warning fires at the call to printf. A helper that takes const char *fmt and forwards it to vprintf moves the format one frame away. The caller writes log_status(user_name) and the compiler sees a function you invented, not printf.

// FIX: the attribute puts the warning back on the caller
void log_status(const char *fmt,...)
 __attribute__((format(printf, 1, 2)));

void log_status(const char *fmt,...) {
 va_list ap;
 va_start(ap, fmt);
 vfprintf(stderr, fmt, ap);
 va_end(ap);
}

// Call site still uses a literal
log_status("user %s signed in\n", user_name);

MSVC’s equivalent is _Printf_format_string_ and /analyze. Clang understands the GNU attribute. If the helper cannot take a format at all, that is better: void log_status(const char *user_name) and a literal inside the function. Then no caller can smuggle grammar through the first slot.

Where a format still arrives as data

The 2025 Top 25 drop is real. The leftovers in native trees in 2025 and 2026 are not printf(user_name) in a new file. They are the places a format arrives as data:

  • A config or protocol field named “format”. A status template in JSON, a metric name with %s already in it, a user-facing “message pattern.” Store the message id. Look up a literal in a switch or a table you compiled in.
  • gettext and friends. A translated string that still contains specifiers is a format you no longer own. Translate the surrounding words. Keep the specifier in the source literal, or use a named-placeholder library that does not call printf on the catalog text.
  • syslog, err, errx, custom *printf in embedded libc. Same rule. Same attribute on the wrapper. Some embedded C libraries have no FORTIFY at all. The literal is then the only control.
  • Logging macros that pass msg through. #define LOG(msg) fprintf(stderr, msg) is printf(user_name) with extra steps. #define LOG(fmt,...) fprintf(stderr, fmt, ##__VA_ARGS__) still needs the format to be a literal at every call site, plus a format attribute on a real function if you want the compiler to see it through the macro.

Dynamic languages are usually a different CWE. Python % and str.format can still leak or confuse, but they are not CWE-134 memory writes. This page stays on native code. The C guide covers the neighboring bugs.

Two more leftovers that grep poorly:

  • A format assembled with strcat. Teams build status_fmt from a prefix plus a user token, then call snprintf(dest, dest_cap, status_fmt, user_name). The capacity check on dest does not make status_fmt yours. Put the token in an argument. Write the prefix in the literal.
  • A second language that calls your C. A Go C.CString passed into a C helper that then printfs it, or a Python C extension that forwards PyUnicode into syslog. The warning lives in the C file. The format still has to be a string you compiled in. Annotate the exported helper so the other language cannot pass a format at all.

glibc’s manual still describes level 2 as “accepting %n only in read-only format strings.” That sentence is a belt description. It is not permission to take a writable format and hope the specifier is harmless. Keep the format in .rodata by writing it in the source.

Grep and tests you can run today

You are not walking an exploit. You are proving every format string was written in the source.

  1. Build with the OpenSSF line above. A -Wformat-security error is a call to fix, not a flag to delete.
  2. Grep the family: printf, sprintf, snprintf, fprintf, dprintf, syslog, warnx, errx, and the v twins. Read every call whose first data argument is not a string literal.
  3. Grep your own helpers: log_, die(, fatal(, trace(. Every one that forwards a format needs the format attribute, or it needs to stop taking a format.
  4. Grep gettext, _(", and ngettext next to printf. A translated format is a review.
# Expect: zero hits where the format is a variable
rg -n 'printf\(|sprintf\(|snprintf\(|fprintf\(|syslog\(|errx\(|warnx\(' --glob '*.{c,cc,cpp,h,hpp}'

# Wrappers that should carry the attribute
rg -n 'vprintf|vfprintf|vsnprintf|vsyslog' --glob '*.{c,cc,cpp,h,hpp}'

A unit test for log_status is a compile test. If you can call log_status(user_name) without a warning under -Wformat=2, the attribute is missing. Add it, or change the signature so user_name cannot be a format.

Questions we keep getting

Is snprintf enough to close CWE-134?

snprintf closes the destination bound when you pass dest_cap and check the return. It does not close CWE-134 if the format argument is still user_name. Use snprintf(dest, dest_cap, "%s", user_name).

Do I still need FORTIFY if every format lives in the source?

Yes. Level 3 is a belt on copies and on a few remaining libc calls. Keep it. Do not treat it as a license to take a format from a file.

What about custom printf replacements in an embedded libc?

Same shape. Literal first, values after. If that libc has no FORTIFY and no format attribute, the grep and the code review are the whole compiler. Do not add a format-from-config feature to make up for a missing catalog.

Malav Vyas

Malav Vyas / About Author

Software Engineer | All things command-line | Arch | Infosec guy Twitter