CWE-88: Improper Neutralization of Argument Delimiters in a Command ('Argument Injection')
Overview
Argument Injection occurs when untrusted input becomes an element of a command line and the program being invoked reads that element as one of its own options rather than as data.
Ordinary installed executables that an application calls can often be turned to code execution or filesystem changes through their own command-line options. These are known as LOLBins.
Relationship to Other CWEs
Report a finding here when the program being invoked is fixed and untrusted input lands in its argument list. Where the input can start a command of its own it is CWE-77 (Command Injection), or CWE-78 (OS Command Injection) when the sink is an OS shell. MITRE files CWE-88 under CWE-77 and gives it no children, so there is nothing more specific to route to below this page.
The neighbouring pages differ by how much of the command line the untrusted value reaches:
- CWE-88 (this page) - the program is fixed and the injected value is read as one of its own options, so the ceiling on the damage is whatever that program's flags can be made to do
- CWE-78 (OS Command Injection) - the injected value reaches a shell and starts commands of its own. The two are worth checking separately because the usual CWE-78 fix does not close this page's weakness: array-form execution removes the shell, but an element beginning with
-is still delivered faithfully and still read as an option
OWASP Classification
A05:2025 - Injection
Risk
High: An injected option makes the invoked program do something the caller did not ask for - run another program, or read or write a file the caller never named.
Remediation Steps
Core Principle: Never allow untrusted input to be parsed as command options/flags; validate and place it after a flag terminator where supported.
Trace the Data Path
Follow the untrusted value from where it enters to the process that runs:
- Source: where untrusted data enters - user input, external files, databases, network requests
- Argument construction: string concatenation, or the value placed directly into the argument list
- Sink: the command execution call (
system(),exec(),subprocess,Runtime.exec()) - Missing validation: where the value should have been checked and was not
Where to look for other instances of the same shape:
- Every place the code starts an external program, including libraries that do so on your behalf - image processing and archive handling are the usual cases
- The tools most often reached this way:
tar,curl,wget,git,rsync,find,ffmpeg - Which of the invoked program's options matter, and which of them a single
injected element can actually reach -
Common Vulnerable Patternsbelow covers that - A value in the first position (
subprocess.run([user_input, arg2])), which lets the user choose the program rather than one of its options. Do not offer that choice at all - Static analysis (Semgrep, CodeQL) can flag subprocess and exec calls whose arguments are not constants
Whether an argument array is enough, what the validation has to look like,
and where -- helps are the steps that follow.
Remove the Argument Vector (Primary Defense)
Where a library call can do the job, the fix is to stop building a command line at all:
- Use a native API:
tarfilerather than thetarbinary, an HTTP client rather thancurl, an image library rather than a converter - Pass the value as a typed argument: a path or URL handed to a function is never re-parsed as an option
Why this works: there is no downstream option parser left to reinterpret the value, so the weakness is removed rather than constrained.
Use Argument Arrays - and Know What They Do Not Cover
Argument arrays (shell=False, UseShellExecute=false, ProcessBuilder with a
list) are the correct baseline and every language page leads with them. They are
the fix for CWE-78, not for this weakness: they deliver
each element to the program exactly as given, including an element that begins
with -, which the program then reads as an option. A finding on this CWE is
not closed by pointing at the array.
Constrain the First Character
Validation is what closes the gap the array leaves:
- Anchor the first character to something that cannot introduce a flag:
[A-Za-z0-9]followed by the rest of the permitted class - Where the set of legitimate values is known, allowlist them
- Check the type: an integer must parse as an integer, a path as a valid path
- Limit the length, so an oversized argument cannot be used for denial of service
Common validation patterns - note that each anchors the first character, which a bare character class does not:
- Filenames:
[A-Za-z0-9][A-Za-z0-9._-]*(no path separators) - Hostnames:
[A-Za-z0-9][A-Za-z0-9.-]* - Numbers:
[0-9]+
Writing these as ^[a-zA-Z0-9._-]+$ and ^[a-zA-Z0-9.-]+$ is the usual mistake
and it fails twice over. The class contains -, so -rf and -delete match
both patterns; and $ is not an end-of-string anchor in Python, .NET or PCRE,
so report.txt\n matches as well. Use the language's whole-string call
(re.fullmatch(), matcher().matches()) or \A...\z, and see the language
pages for the per-language spelling - it differs, and the obvious cross-language
answer is wrong for Python before 3.14.
Blocking shell metacharacters (;, &, |, $, backticks) belongs to CWE-78
and does nothing here: - is not special to any shell, which is precisely why
it survives every escaping function.
Apply Least Privilege
Limit damage if argument injection occurs:
- Do not run the process as root or Administrator
- Allow only the arguments the task needs
- Isolate the process in a container (Docker, Kubernetes)
- Apply OS-level restrictions such as SELinux or AppArmor policies
Monitor and Log Command Execution
- Log every command invocation with its full argument list
- Alert on an argument that arrives beginning with
- - Track rejected and malformed arguments
- Review the logs for attack attempts
Test with Malicious Inputs
- Test with a leading dash:
--help,-rf, and the dangerous option for the tool actually invoked - Assert the side effect did not happen, not only that the request was rejected - a 400 alone does not prove the process never ran
- Check that legitimate values still work, including values with an interior hyphen or dot
- A re-scan cannot confirm this fix: the call site reads the same before and after, which is why the assertions above are behavioural
Shell metacharacters (;, &, |, backticks) test the wrong weakness here;
they belong to a CWE-78 test.
Common Vulnerable Patterns
// VULNERABLE - pseudo-code
run_process(["tar", "-cf", "archive.tar", "report.csv", user_value])
// Attack: user_value = "--use-compress-program=touch /tmp/pwned"
// Result: tar runs the named program instead of a compressor
Why this is vulnerable: the argument list is what stops command injection,
and it is present here. The weakness is one layer further in: the value reaches
the program's own option parser, and a value beginning with - is an option
rather than data. No shell, no metacharacters, and no escaping function is
involved in either the attack or the fix.
Three shapes account for most findings:
- A bare positional argument - a filename, URL, or pattern the program reads
as an option when it starts with
-. The most exploitable case. - A value that reaches an option that names a program.
tar's--use-compress-program,git's--upload-pack,rsync's remote shell andfind's exec predicate all turn an argument into execution. - A value that reaches an option that reads or writes a path.
curl's output and config options,git's--separate-git-dir. These produce file write or disclosure rather than execution, which is often still the whole finding.
One element is not one command line, and this is where triage goes wrong in
both directions. The value arrives as a single argv entry, so an option that
needs a separate value argument cannot be supplied with one - and a payload
copied from a shell session usually assumes it can. Three consequences, each
measured on the tools named:
- The option must carry its own value.
--use-compress-program=touch Xfires;--upload-file Xarrives as one unknown option andcurlexits 2. GNU-style--opt=valueand attached short options (-K/path,-ofile) are the forms that work. - Some options need a companion the attacker cannot add. GNU tar's
--checkpoint-action=exec=...does nothing without--checkpoint=N(measured, tar 1.35), so the payload every write-up quotes is inert from a single element. - The rest of the command line still has to make sense. Replacing the only
positional argument with an option usually leaves the tool with nothing to
work on:
taranswers "Cowardly refusing to create an empty archive" andgit cloneanswers "You must specify a repository to clone", both before any option takes effect. A fixed argument beside the injected one is what makes the difference.
None of this makes the finding a false positive - it decides which payload demonstrates it, and a demonstration that quietly fails is how a real finding gets closed as unreproducible.
Secure Patterns
// SECURE - pseudo-code
if not whole_string_match(user_value, "[A-Za-z0-9][A-Za-z0-9_.-]*"):
reject()
run_process(["tar", "-cf", "archive.tar", "report.csv", "--", user_value])
Why this works: The pattern requires a safe first character, so no
accepted value can be read as an option. Expressing it that way rather than as
"reject a leading dash" also excludes --, unicode dash characters and
whitespace-prefixed values. Matching the whole string, rather than anchoring
with ^ and $, is what keeps report.txt\n out; the language pages give the
per-language call. The -- terminator is defense in depth for tools that honour
it, not the primary control - find has no working end-of-options marker,
git honours -- only where the value is a path rather than a revision
(--end-of-options, git 2.24+, covers the rest), and -- never helps for a
value in an option's value position.
The stronger fix, where the platform offers it, is not to build an argument vector: a library call takes a path or URL as a typed value, so there is no parser downstream that can reinterpret it. The language pages lead with that option for each ecosystem.
Language-Specific Guidance
- Python -
subprocessargument lists,tarfileandpathlibas replacements, whyshlex.quote()does not apply - Java -
ProcessBuilder, replacing acurlsubprocess withHttpClient,URIover the deprecatedURLconstructors - JavaScript -
spawn/execFileargument arrays, whyshell: falseanswers a different question, globalfetch - PHP - why
escapeshellcmd()andescapeshellarg()do not address this,proc_open()with an argument array