Command Injection Beyond os.system
When most developers hear “command injection,” they think of os.system() in Python or Runtime.exec() in Java. Those are the textbook examples, and most teams know to avoid them. But the more I researched this topic, the more I realised that command injection surfaces through dozens of less obvious APIs across every language, subprocess pipes, shell expansions, backtick operators, and even seemingly safe exec functions that become dangerous with the wrong arguments. This is one of my favourite vulnerability classes to dig into because the attack surface is so much wider than people realise. Let me walk you through command injection patterns across seven languages, from the obvious to the genuinely subtle.
The Root Cause
Command injection happens when an application constructs a shell command using untrusted input and passes it to an operating system shell for execution. The shell interprets metacharacters like ;, |, &&, $(), and backticks as command separators or substitution operators, allowing an attacker to append or replace the intended command.
The critical distinction, and this is the thing that really made everything click for me, is between executing a command through a shell versus executing a program directly. When you invoke a shell (/bin/sh -c "..." or cmd.exe /c "..."), every metacharacter in the string is live. When you execute a program directly with an argument array, the OS passes arguments as-is, no shell interpretation occurs. Once developers truly internalize this distinction, they almost never introduce command injection again.
The Easy-to-Spot Version
Python: subprocess with shell=True
@app.route("/api/network/ping", methods=["POST"])
def ping_host():
data = request.get_json()
host = data.get("host", "")
result = subprocess.run(
f"ping -c 3 {host}",
shell=True,
capture_output=True,
text=True,
timeout=10
)
return jsonify({"output": result.stdout})
The shell=True flag is the red flag every reviewer knows to look for. An attacker sends {"host": "127.0.0.1; cat /etc/passwd"} and the shell executes both commands. SAST tools like Bandit flag shell=True with high confidence. This pattern shows up a lot in “network diagnostic” features, it’s almost a cliché at this point, and I’ve run into it myself in code reviews.
Java: Runtime.exec with concatenation
@PostMapping("/tools/dns")
public ResponseEntity<?> dnsLookup(@RequestBody Map<String, String> body) {
String domain = body.getOrDefault("domain", "");
String[] cmd = {"/bin/sh", "-c", "nslookup " + domain};
Process proc = Runtime.getRuntime().exec(cmd);
// read output...
return ResponseEntity.ok(Map.of("output", output));
}
Passing {"/bin/sh", "-c", ...} explicitly invokes a shell. The domain parameter is concatenated into the shell command string, giving the attacker full shell access. An input like example.com; whoami executes both commands. What I find interesting is that the developer used an array form of exec, which suggests they knew about the safer API, but then routed it through sh -c anyway.
The Hard-to-Spot Version
These are the patterns that survive code review because they don’t use the obvious dangerous APIs, or they use them in ways that look safe at first glance. Some of these took me a while to fully understand when I first encountered them.
Python: Popen with Partial Sanitization
@app.route("/api/files/search", methods=["POST"])
def search_files():
data = request.get_json()
pattern = data.get("pattern", "")
directory = data.get("directory", "/var/data")
if ".." in directory:
return jsonify({"error": "Invalid directory"}), 400
cmd = f"find {directory} -name '{pattern}' -type f"
proc = subprocess.Popen(
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
stdout, _ = proc.communicate(timeout=15)
return jsonify({"files": stdout.decode().strip().split("\n")})
This pattern is worth studying closely, the developer added a path traversal check (.. detection) but missed the command injection entirely. The pattern parameter is wrapped in single quotes, which looks safe, but an attacker can break out with '; cat /etc/shadow; echo '. The single-quote wrapping creates a false sense of security. What I’ve come to appreciate is that partial sanitization is almost worse than no sanitization because it gives the developer (and the reviewer) false confidence.
Go: exec.Command with Shell Invocation
func diskUsage(c *gin.Context) {
path := c.Query("path")
if path == "" {
path = "/"
}
cmd := exec.Command("sh", "-c", "du -sh "+path)
output, err := cmd.CombinedOutput()
if err != nil {
c.JSON(500, gin.H{"error": "Command failed"})
return
}
c.JSON(200, gin.H{"usage": string(output)})
}
Go’s exec.Command is safe when used directly, exec.Command("du", "-sh", path) would pass path as a single argument with no shell interpretation. But wrapping it in sh -c reintroduces the shell, making it just as dangerous as any other shell injection. This is a common Go anti-pattern: developers use exec.Command (safe API) but negate its safety by routing through sh -c. I think it happens because people copy shell commands from their terminal and want them to “just work” in Go.
C: popen with Format Strings
void handle_lookup(const char *hostname) {
char cmd[512];
snprintf(cmd, sizeof(cmd), "host %s", hostname);
FILE *fp = popen(cmd, "r");
if (!fp) {
fprintf(stderr, "Command failed\n");
return;
}
char line[256];
while (fgets(line, sizeof(line), fp)) {
printf("%s", line);
}
pclose(fp);
}
In C, popen() always invokes a shell. There is no shell=True flag to search for, it’s the default and only behaviour. The snprintf provides buffer overflow protection but does nothing against shell metacharacters. An input of example.com; rm -rf / executes both commands. Something worth remembering when reviewing C code: if you see popen(), treat it exactly like system() because that’s what it is under the hood.
C++: system() Hidden in Utility Functions
std::string runDiagnostic(const std::string& target) {
std::string tmpFile = "/tmp/diag_" + std::to_string(getpid()) + ".txt";
std::string cmd = "traceroute -m 10 " + target + " > " + tmpFile + " 2>&1";
system(cmd.c_str());
std::ifstream ifs(tmpFile);
std::string result((std::istreambuf_iterator<char>(ifs)),
std::istreambuf_iterator<char>());
std::remove(tmpFile.c_str());
return result;
}
The system() call is buried inside a utility function that might be in a different translation unit. The function signature takes a std::string, nothing about it screams “shell execution.” A reviewer looking at the call site runDiagnostic(userInput) might not realise it leads to a shell command. The more I researched this pattern, the more examples I found in C++ codebases where the dangerous function was several layers deep in a utility library. It’s one of those bugs that makes you realise you can’t just grep for system() in the file you’re reviewing, you have to follow the call chain.
Rust: std::process::Command with Shell
fn check_certificate(domain: &str) -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("sh")
.arg("-c")
.arg(format!("echo | openssl s_client -connect {}:443 2>/dev/null | openssl x509 -noout -dates", domain))
.output()?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
Rust’s std::process::Command is safe by default, it executes programs directly without a shell. But like Go, developers sometimes wrap commands in sh -c to use shell features like pipes and redirects. The moment sh -c appears, all of Rust’s process safety guarantees evaporate. The format! macro concatenates domain into the shell string, enabling injection. There’s an irony here that I find worth noting: Rust developers often have a strong security mindset because of the language’s memory safety focus, but they’ll still reach for sh -c when they need pipes.
JavaScript: child_process.exec
app.post('/api/tools/whois', (req, res) => {
const domain = req.body.domain || '';
const cmd = `whois ${domain}`;
exec(cmd, { timeout: 10000 }, (error, stdout, stderr) => {
if (error) {
return res.status(500).json({ error: 'Lookup failed' });
}
res.json({ result: stdout });
});
});
Node.js child_process.exec always spawns a shell. The safe alternative is execFile or spawn (without shell: true), which execute programs directly. Template literals make the concatenation look clean, and the timeout option suggests the developer thought about safety, just not the right kind. What I noticed while researching Node.js patterns is that exec is the first function most tutorials show, and execFile is less well-known, which probably explains why this comes up so often.
Detection Strategies
SAST Tool Coverage
| Language | Tool | Catches Easy | Catches Hard |
|---|---|---|---|
| Python | Bandit | ✓ (shell=True) |
Partial (Popen with shell) |
| Java | SpotBugs | ✓ (Runtime.exec) | Partial (ProcessBuilder) |
| Go | gosec | ✓ (exec.Command + sh) | Sometimes |
| Rust | clippy | Limited | No |
| C/C++ | cppcheck | ✓ (system/popen) | Limited |
| JavaScript | eslint-plugin-security | ✓ (exec) | Partial (execFile misuse) |
The hard-to-spot versions often evade SAST, and here’s what I think explains it:
- The shell invocation is indirect (through a wrapper function)
- The tool doesn’t track taint across function boundaries
- The dangerous API is used in a way that looks like the safe pattern
SAST is a good first pass, but it’s no substitute for manual review on the subtle cases.
Manual Review Checklist
Here’s a checklist that works well for reviewing command injection:
- Search for all shell-invoking APIs:
system(),popen(),exec(),shell=True,sh -c,cmd /c - For each hit, trace every string component back to its origin
- Check if “safe” APIs (
exec.Command,Command::new,execFile) are being routed through a shell - Look for command strings built in helper functions, the execution and construction may be far apart
- Verify that any input validation actually prevents shell metacharacters, not just path traversal
Remediation
The universal fix is to avoid shell invocation entirely and pass arguments as arrays. This is the single most effective defence against command injection:
Python, Fixed
result = subprocess.run(
["ping", "-c", "3", host],
capture_output=True, text=True, timeout=10
)
Go, Fixed
cmd := exec.Command("du", "-sh", path)
output, err := cmd.CombinedOutput()
JavaScript, Fixed
const { execFile } = require('child_process');
execFile('whois', [domain], { timeout: 10000 }, (error, stdout) => {
res.json({ result: stdout });
});
When shell features (pipes, redirects) are genuinely needed, use the language’s pipe mechanisms instead:
p1 = subprocess.Popen(["echo"], stdout=subprocess.PIPE)
p2 = subprocess.Popen(
["openssl", "s_client", "-connect", f"{domain}:443"],
stdin=p1.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
)
For C/C++ where popen() and system() are the primary APIs, use fork()/execve() directly, or use posix_spawn() with explicit argument arrays.
The bottom line: if you see sh -c or shell=True anywhere in a codebase, treat it as a code smell and investigate. Nine times out of ten, there’s a safer way to accomplish the same thing.