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.quoteandescapeshellargfix one argument for one shell; they are a fallback, not a design, and PHP'sescapeshellcmdstill lets an attacker add arguments. - Choose the executable and the environment yourself: an absolute path, a fixed
PATH, and no inheritedLD_PRELOAD,NODE_OPTIONSor secrets. - Windows has no argument array: every runtime rebuilds one command line, and
cmd.exeparses batch-file arguments by its own rules, which led to the 2024 BatBadBut fixes in Rust, Node.js and PHP.
| 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 |
- 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. - Run your SAST tool's command-injection rules (for CodeQL, the queries named in this guide) and resolve or document every result.
- 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. - In a test environment, log each child's argument vector and confirm user values appear as single elements after
--or--end-of-options. - Inspect a child's environment (
/proc/<pid>/environon Linux, or a test program that prints it) and confirm only the variables you set are present. - On Windows builds, confirm no
.bator.cmdfile 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
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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 practiceOne value, two ways to start a program
- 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.
- 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. - Fixed: the program gets an argument arrayThe application checks the value against a pattern, then starts
/usr/bin/gitdirectly with the value as one element after--end-of-options. No shell exists to parse it, and Git cannot read it as an option. - 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.
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.
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. |
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.
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.stdoutA 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.
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()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 |
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.
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;
}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.
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);
}
}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.
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'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.
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 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. |
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.
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/.
- Retrying with
shell=Truewhen 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 -cwith 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
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 | shdoes, 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])withfolder_pathfrom the server's own map of folders.2. S2: no shell, still exploitable
Decide whether
remote=--upload-pack=touch /tmp/xis a problem withexecFile.Expected result and reasoning
Yes, CWE-88: Git reads
--upload-packas an option and runs the command. Accept onlyhttps://URLs with an allowed host, then callexecFile(GIT, ["ls-remote", "--end-of-options", remote]).3. S3: Runtime.exec with a string
Explain what happens to
host=example.test; idand tohost=-i 0.2 example.test.Expected result and reasoning
No shell runs, so
;does nothing, butStringTokenizersplits on whitespace, so the second value adds a-ioption (CWE-88). Validate the host and usenew 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.Expected result and reasoning
CWE-78: with
shell: true, Node.js joinsargswith spaces without escaping (DEP0190) andcmd.exeparses the result, so&starts another command; since CVE-2024-27980 a.batcannot be spawned withoutshell: true. Pass the data in a file the script reads, or replace the script with an executable.
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.
- OWASP OS Command Injection Defense Cheat Sheet
- OWASP WSTG: Testing for Command Injection (WSTG-INPV-12). Version 4.2.
- OWASP ASVS 5.0.0: V1 Encoding and Sanitization. Version 5.0.0.
- The Open Group Base Specifications: 12.2 Utility Syntax Guidelines. POSIX.1-2024 (IEEE Std 1003.1-2024).
- Python documentation: subprocess, Security Considerations. Python 3 documentation.
- Node.js documentation: Child process. Node.js v26.11.1.
- PHP manual: proc_open
- CERT/CC VU#123335: Multiple programming languages fail to escape arguments properly in Microsoft Windows. Revision 8, 13 May 2024.
- Rust blog: Security advisory for the standard library (CVE-2024-24576). 9 April 2024.
- Node.js: Wednesday, April 10, 2024 Security Releases (CVE-2024-27980). April 2024.
- 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
- Role
- Developers
- ASVS 5.0.0 requirements
- v5.0.0-1.2.5 · v5.0.0-1.2.8 · v5.0.0-2.2.1
- CAPEC attack patterns
- OWASP WSTG v4.2 tests
- WSTG-INPV-12