Command InjectionArgument Injection

Argument Injection: Hijacking a Command Without a Shell

A value can be safely separated from the shell and still be interpreted as an option by the target program. Learn the parser boundary, a harmless proof, and when validation plus -- closes it.

SelfSec Team6 min read
Part 2 of 3from theCommand Injectionseries
--help

At a glance

CWE-88
Interpreter
Command-line option parser
Required condition
Attacker input is one argument but can be interpreted as an option or control operand.
Potential impact
The intended executable performs an unintended read, write, network, or execution action.
Primary defense
Validate operand shape and use an end-of-options delimiter where the tool supports it.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

The standard advice for command injection is to avoid the shell and pass user input as a single element of an argument array. That advice is correct, and it closes the metacharacter-injection door completely — ;, |, $() and friends are inert when no shell ever parses them. But it is not the whole story. A value can be a perfectly safe, properly quoted single argv element and still subvert the command, because the target binary parses its own arguments before it ever touches a file.

The common trigger is a leading dash. Many command-line tools treat a value starting with - or -- as an option, not data. If the supposedly positional value is --help, the array transports it correctly and curl still interprets it as a flag. This is argument injection, and it lives entirely inside the program's own argument parser.

The boundary after the shell

Direct process execution produces an argument vector such as:

argv[0] = curl
argv[1] = --silent
argv[2] = --help

The operating system preserves those boundaries. It does not label argv[2] as user data. The target program decides that meaning. Each executable has its own option grammar, so the defense must combine a structured process API with knowledge of the selected program.

How it happens

Consider an endpoint that fetches a user-supplied URL with curl, doing everything the secure-coding guides ask: no shell, argument array, no string concatenation.

const { execFile } = require("node:child_process");

app.post("/fetch", (req, res) => {
  const url = req.body.url;
  execFile("curl", ["-s", url], (err, stdout) => {
    res.send(stdout);
  });
});

There is no shell here, and url is a single argv element — metacharacter injection is genuinely impossible. The flaw is that curl parses its arguments positionally, and nothing pins url to the URL position. If the value begins with -, curl treats it as another option rather than the address to fetch. The application handed the attacker a slot in the command line, and the attacker filled it with a flag.

A safe proof: an operand becomes an option

Instead of a URL, an authorized tester submits a harmless option:

POST /fetch HTTP/1.1
Host: app.example
Content-Type: application/json

{"url": "--help"}

The resolved vector is equivalent to curl --silent --help. Curl prints help instead of fetching a URL, and the endpoint returns that output. A paired value such as https://example.com/ follows the normal fetch path. The difference proves that request data reached the option parser without writing a file, reading local content or invoking another command.

The input was passed perfectly safely as a single argument. The binary still did the attacker's bidding — because it, not a shell, decided that a value starting with a dash was a command, not data.

Impact depends entirely on the program and argument position. Options may change output format, destination, configuration source, network behavior or invoked helpers. Some tools stop parsing options at the first operand; others permute options or support response files. Do not assume one harmless proof implies a particular high-impact capability without reviewing the exact executable and version.

Why it matters

Argument injection is dangerous precisely because it slips past the mental model most developers use for command injection. The team removes the shell, passes an array and reasonably concludes the surface is closed. Depending on the target binary, however, options can still cause unintended file access, writes, network requests, configuration loading or execution-capable helper behavior. A single unanchored argument position is enough to expose that program's option surface.

The fix: end the options, anchor the value

The core fix is the -- end-of-options separator. Almost every well-behaved CLI treats -- as "everything after this is a positional operand, never an option," so inserting it immediately before the user value forces the binary to read it as data even if it starts with a dash.

The vulnerable fetch, fixed by anchoring the URL after --:

const { execFile } = require("node:child_process");

app.post("/fetch", (req, res) => {
  let url;
  try {
    url = new URL(req.body.url);
  } catch {
    return res.status(400).send("Invalid URL");
  }
  if (url.protocol !== "https:") {
    return res.status(400).send("Invalid URL");
  }
  execFile("curl", ["--silent", "--show-error", "--", url.href], (error, stdout) => {
    if (error) {
      return res.status(502).send("Fetch failed");
    }
    return res.type("text/plain").send(stdout);
  });
});

With -- in place, --help is handed to curl as a URL operand and fails rather than changing program mode. URL parsing rejects malformed values before execution. The feature still needs SSRF controls governing allowed destinations; argument safety does not decide which valid URLs the server may fetch.

For tools that operate on paths, path-prefixing can make a filename unambiguously positional, such as ./-report rather than -report. Layer these on top:

  • Validate and normalize the value against its expected shape — parse a URL and require an http/https scheme, resolve and confine a path under an allowed root.
  • Reject a leading - outright when the value is meant to be data and a dash has no legitimate place in it.
  • Allowlist when the set of valid values is known, rather than trying to blocklist dangerous flags one by one — the option surface of a tool like curl is far too large to enumerate.

Not every CLI supports --, and some subcommands parse options again after it. Confirm behavior in official documentation and tests for the deployed version. When the program has a maintained library API, using that library avoids both shell parsing and CLI option semantics.

Safe verification and false signals

  • Use a documented harmless flag such as help or version in a disposable lab.
  • Compare the output with a normal positional operand.
  • Confirm the application passes one argument rather than splitting whitespace itself.
  • Record the executable and version because option behavior changes.
  • Do not test write, configuration, helper-execution or local-file options on production data.

How SelfSec finds it

Argument injection is invisible to a scanner that only looks for shell metacharacters, so SelfSec probes for it directly. It crawls your application and injects option-shaped payloads — values leading with - and --, tool-specific flags like -o and -K — into every surface it finds: query parameters, form fields, headers, cookies, JSON bodies and path segments. Because the effect is often blind, it correlates response, timing or out-of-band evidence before reporting a finding, and includes a reproduction request you can replay. Core scan processing runs locally on 127.0.0.1; managed OOB confirmation uses the documented remote callback flow when enabled.

References

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about Command Injection

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The Command Injection series

  1. 01OverviewCommand Injection: How One Semicolon Hands Over Your Server7 min
  2. 02Argument InjectionArgument Injection: Hijacking a Command Without a ShellReading
  3. 03BlindBlind Command Injection: Proving Execution Through Time and DNS5 min

Related reading