Skip to content

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 Patterns below 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: tarfile rather than the tar binary, an HTTP client rather than curl, 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 and find'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 X fires; --upload-file X arrives as one unknown option and curl exits 2. GNU-style --opt=value and 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: tar answers "Cowardly refusing to create an empty archive" and git clone answers "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 - subprocess argument lists, tarfile and pathlib as replacements, why shlex.quote() does not apply
  • Java - ProcessBuilder, replacing a curl subprocess with HttpClient, URI over the deprecated URL constructors
  • JavaScript - spawn/execFile argument arrays, why shell: false answers a different question, global fetch
  • PHP - why escapeshellcmd() and escapeshellarg() do not address this, proc_open() with an argument array

Additional Resources