Topic: mcp server format string security
MCP server format string security — printf vulnerabilities in native MCP server addons
Format string vulnerabilities arise when a C/C++ native MCP addon passes user-controlled data as the format argument to printf, fprintf, sprintf, or related functions instead of as a parameter to a fixed format string. The classic bug is printf(user_input) instead of printf("%s", user_input). In an MCP server, tool arguments sourced from the model may have been shaped by a prompt injection attack — an adversary who can influence the model's output can inject %x%x%x%p%p%s into a tool argument and read stack memory from the server process.
Why format string bugs appear in MCP native addons
Logging is the most common source of format string vulnerabilities in native MCP addons. When a developer adds debug logging to trace tool invocations — recording which file path was requested, which query string was evaluated, which code snippet was parsed — the easiest mistake is to write printf(args.path) rather than printf("%s", args.path). The code compiles without errors, works correctly for all normal inputs, and passes every unit test. The vulnerability only activates when the input contains % characters interpreted as format directives.
In an MCP server, this matters more than in a typical CLI tool because the input is model-generated. A prompt injection attack embeds instructions in content the model processes — a malicious file content, a web page the model reads via a fetch tool, or adversarial text in a user message. The injection may instruct the model to call a specific tool with a specifically crafted argument. The model, following its instruction-following behavior, generates the argument exactly as instructed, including embedded format specifiers.
Consider a code-analysis MCP server that exposes a parse_file tool. The addon logs the file path for debugging:
// VULNERABLE — logs the file path as a format string
void log_invocation(const char *path) {
fprintf(stderr, "Parsing file: ");
fprintf(stderr, path); // BUG: path is the format string
fprintf(stderr, "\n");
}
// If path is "%x.%x.%x.%x.%p.%s", stderr receives stack memory contents.
// If the MCP protocol error response includes stderr output (common in dev builds),
// the stack dump reaches the attacker via the tool's error response.
The fix is a single character — add a format string literal:
// SAFE — path is a parameter, not a format string
void log_invocation(const char *path) {
fprintf(stderr, "Parsing file: %s\n", path);
}
Stack memory disclosure via %x and %p
When printf receives a format string containing %x (unsigned hex integer) or %p (pointer) directives, it reads the corresponding arguments from the call stack according to the calling convention. With no actual arguments provided, it reads whatever happens to be on the stack at those positions — saved frame pointers, return addresses, local variables, and libc pointers:
// Input: user_controlled_path = "%x.%x.%x.%x.%x.%x.%x.%x"
printf(user_controlled_path);
// Output (example): bfffd4a0.b7e12345.0.bfffd4b8.b7c8f000.0.bfffd4c0.804b290
// These values reveal:
// - Stack addresses (defeating ASLR on 32-bit builds)
// - libc base addresses (b7e12345 — enables ROP gadget calculation)
// - Local variable values (potentially including keys, tokens, or paths)
// - Saved return addresses (enabling precise stack layout mapping)
On 64-bit Linux with ASLR enabled, a format string leak provides enough information to bypass ASLR: the attacker reads a libc pointer from the output, subtracts the known offset to libc base, and then knows the address of every gadget in libc for a subsequent exploit. Format string leaks are therefore not merely information disclosure — they are a prerequisite step for code execution exploits against ASLR-protected binaries.
The safe equivalent uses a string literal as the format argument in every case:
// ALL of these are safe — format string is always a literal
printf("%s", user_controlled_path);
fprintf(logfile, "[INFO] path=%s\n", user_controlled_path);
snprintf(buf, sizeof(buf), "%s", user_controlled_path);
// For structured logging in C++: spdlog takes the message as a parameter
spdlog::info("Parsing file: {}", user_controlled_path);
// For Node.js logging: pino, winston, bunyan all use structured fields
logger.info({ path: userControlledPath }, "Parsing file");
syslog and err() family vulnerabilities
The same bug class appears in the BSD syslog family and the GNU err/warn family, which are frequently used in server daemons for system-level logging:
// VULNERABLE — user input as format string to syslog
syslog(LOG_INFO, user_controlled_input); // reads stack
err(EXIT_FAILURE, user_controlled_input); // reads stack, then exit
// VULNERABLE — vsyslog called via a wrapper that forwards user input
void log_tool_call(const char *msg) {
syslog(LOG_DEBUG, msg); // BUG if msg is user-controlled
}
// SAFE — always use a format string literal as the first format argument
syslog(LOG_INFO, "%s", user_controlled_input);
err(EXIT_FAILURE, "%s", user_controlled_input);
warn("%s: unexpected value", user_controlled_input);
In a server context, syslog output goes to journald or /var/log/syslog. The security consequence depends on who can read the log sink. In containerized MCP server deployments, the journal is often accessible to any process in the same user namespace — a separate compromised service could read the format string output. In aggregated log monitoring pipelines (Datadog, Splunk, CloudWatch), the raw log output — including any leaked memory bytes — is forwarded to a remote log store, potentially crossing a trust boundary.
The err/warn family also emits to stderr. In MCP server implementations that capture stderr and include it in tool error responses (a common pattern in development builds that persists into production), the format string output is directly returned to the agent — and therefore potentially visible to the attacker who influenced the model's tool call.
Detection and enforcement via compiler flags
GCC and Clang detect format string vulnerabilities at compile time via the -Wformat-security flag, which warns when a non-literal string is passed as the format argument to a printf-family function. Critically, this warning is not enabled by -Wall or even -Wextra — it must be specified explicitly. Build systems for native MCP addons must add it deliberately:
// binding.gyp — enable format string security warnings as build errors
{
"targets": [{
"target_name": "my_addon",
"cflags": [
"-Wformat",
"-Wformat-security",
"-Werror=format-security" // promote to error — CI breaks on violation
],
"sources": ["src/addon.cc"]
}]
}
// CMakeLists.txt equivalent
target_compile_options(my_addon PRIVATE
-Wformat
-Wformat-security
-Werror=format-security
)
With -Werror=format-security, any call to printf(variable) is a compile error rather than a warning, making the CI pipeline break before the vulnerable code ships. Static analysis tools provide additional coverage: clang-tidy with the clang-analyzer-security.insecureAPI.UncheckedReturn check, and Semgrep with the rule c.lang.security.insecure-use-printf-format-string.insecure-use-printf-format-string, both catch the pattern in source trees where the flag was absent during builds.
SkillAudit detection
SkillAudit's Security axis performs two complementary checks for format string vulnerabilities. Statically, it scans all C and C++ source files in the native addon for calls to printf, fprintf, sprintf, snprintf, syslog, err, warn, and vprintf where the format argument is a variable rather than a string literal — flagging each as a high-severity finding regardless of the variable's apparent origin. It also inspects binding.gyp and CMakeLists.txt for the presence of -Wformat-security in compile flags and reports its absence as a medium-severity hardening gap. Dynamically, the LLM probe issues tool calls with arguments containing format string metacharacters (%x, %s, %p, %n) in every string parameter position and checks whether the server's error responses or observable behavior reflect format interpretation — stack-like hex strings in error output, unexpected crashes from %n writes, or process exits from %s reading an invalid stack pointer. Run a free audit at skillaudit.dev to catch format string risks in your native MCP addon before a prompt injection exploits them.
Related: heap corruption, stack overflow, error message disclosure.