Skip to content

fix(command-injection): judge process commands by what the input controls - #217

Merged
SizzleUnrlsd merged 8 commits into
mainfrom
fix/command-option-injection
Oct 6, 2026
Merged

SizzleUnrlsd merged 8 commits into
mainfrom
fix/command-option-injection

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #208.

command-injection reported every tainted argument of subprocess.run, call, check_call, check_output and Popen as a high command injection, whether or not a shell runs the command and whatever the argument's place. This change implements the expectations fixed in #208, with the decision taken in review on the case 5 / case 7 overlap: the option model decides, not a fixed priority.

Behaviour

Case Example Result
1 Shell string run(cmd, shell=True) (by keyword or at its position), os.system(cmd) high, unchanged
2 String without shell run(cmd) high, unchanged: it names the program
3 Program run([prog, "-l"]), executable=prog high, unchanged
3 Operand run as shell text ["ssh", "host", f"cat {x}"], ["ssh", "host", "ls", f"-l{x}"], ["powershell", f"Get-Item {x}"], a shell's first operand after -c (["sh", "-ce", f"echo {x}"]), any argument of build.bat high
3 Script an interpreter runs ["bash", f"/tmp/{x}.sh"], ["python3", f"scripts/{x}.py"], ["perl", f"{x}.pl"], ["python3", "-m", f"pkg.{x}"] high, hotspot: execution is possible, exploitability depends on the file the input chooses. Not when an option gives the code (-c, -m, -e, --eval, -r) or a shell reads it from its input (-s). An input that starts the operand (f"{x}.pl") may be a code option and its value (-e<code>#.pl): high. The elements after a script or code are its own arguments: options of unknown meaning, medium (hotspot after an option), refuted behind a constant prefix
3 Command inside the command ["sudo", x], ["timeout", "5", x], ["find", ".", "-exec", "sh", "-c", x, ";"] the program is high; its arguments are read with that program's model (["sudo", "ls", x] is medium)
8 shell from an expression or **kwargs run(cmd, shell=flag) high, hotspot
4 Free element run(["ls", x]) medium; high when a documented option of the program fits in one element there (git push accepts --receive-pack=…)
5 Standalone element, constant prefix not - ["git", "tag", f"v{x}"] refuted
5b Prefix naming an option f"--output={x}", f"-I{x}", f"/c{x}" judged by the option: high when it runs its value (--upload-pack=, tar -I, cmd /c), medium when its effect is not established
5c Prefix leaving the option open f"--{x}" as case 4
6 After -- ["git", "log", "--", "a", x] refuted for programs documented to honour -- (git, tar, rsync, rm); otherwise case 4
7 Value an established option takes ["cmd", "/c", f"dir {x}"], [sys.executable, "-c", f"print({x})"], ["git", "commit", "-m", f"release {x}"] judged by what the option does, whatever the prefix: runs it, high; keeps it as data (git commit -m, gh --title, only with the subcommand established and for one element), refuted; effect not established, medium
7 After an option the model does not describe, or an element of unknown value ["xcrun", "notarytool", "--keychain", x], ["tool", "-c", f"echo {x}"], ["tool", flag, f"echo {x}"] kept for review: hotspot, at case 4's severity, prefix or not
7 After options established to take no value ["rm", "-rf", x] (grouped short options read as getopt does) the element stands alone: case 4

Severity is set per case; the hotspot verdict lowers the confidence only. The evidence of each finding says what the input controls (…; the input is a whole element, and an option such as git --receive-pack=... runs the program it names).

Design

  • taint/commands.py reads the command a flow reaches from its list or tuple display, element by element, nested unpacked literals included; an unpacked iterable of unknown contents stands for any number of elements. A display with any other use than the call (append, insert, +=, item assignment, an alias…) spells no command, and the flow keeps the previous high result; so do shapes the module cannot read (a value of unknown origin, a flow reaching the sink in a callee, the tainted value also passed as executable= or another keyword such as input=).
  • IR: BuildList and BuildTuple gain unpacked_at, the place of each *iterable among the display's items. The lowering separated plain and unpacked elements and lost their order, so ["xcrun", "notarytool", *(["--keychain", x])] could not be read. The field defaults to (); substitute keeps it, and no other consumer rebuilds these instructions.
  • TaintDetector.assess (exported Assessment) returns a flow's verdict and severity; by default it pairs judge's verdict with the rule's severity, so ssrf, open-redirect and plugin detectors keep their behaviour. The plugin API version is unchanged since the hook is additive.
  • Option model (data of the bundled plugin, documented behaviours only):
    • OPTIONS: whether an option takes a value (next element, =, glued to a short option, every following element for cmd /c and powershell -Command, or the elements up to ; or + for find -exec) and what the program does with it. It may run a command: git -c, --upload-pack and its short form -u, --receive-pack, --exec; ssh -o; tar --to-command, --checkpoint-action, --use-compress-program, -I; rsync -e, --rsh; find -exec and its family; python -c, and -m and -W (a module, kept for review); perl -e/-E/-M/-m, ruby -e and -r (kept for review), node -e/--eval/-p/--print and -r/--require/--import (kept for review), php -r/-B/-R/-E; cmd /c and /k; powershell and pwsh -Command, -EncodedCommand, matched whatever the case. It may keep the value as inert data: git commit and tag -m/--message; gh release --title/--notes, gh workflow --ref. Or it takes no value: the flags of ssh, rm, python and the shells, whose -c is a flag.
    • OPERANDS: the operands a program runs, from a given one on. Interpreters (sh, bash, dash, zsh, ksh, python, perl, ruby, node, php) run the script their first operand names unless an option gives the code; the input there is high, kept for review. Some are shell text: ssh after the destination, powershell, and the first operand of sh, bash, dash, zsh and ksh when -c is among their options. Others are a command of their own: the program of sudo, doas, env, xargs, nohup, nice and time (after NAME=VALUE assignments for env and sudo), or of timeout after the duration. Such a command is read again with that program's model, as are the elements after find -exec. When the options before cannot be read, the element is assumed to be such an operand.
    • Program names are normalised: path (POSIX or Windows) dropped, .exe removed, lower case, interpreter versions and distribution names dropped (python3.12, pythonw, pypy3, nodejs, perl5.36, php8.2; py read as python), sys.executable read as python. A constant executable= names the program, and so does shutil.which("name"). An executable that is given but cannot be read leaves the program unknown, and an unknown program keeps every standalone element for review. A .bat/.cmd program makes every argument high, since cmd.exe parses them again.
  • GNU tar: --to-command, --checkpoint-action and -I run a program in GNU tar; bsdtar (the macOS tar) lacks the first two and reads -I as an include-patterns file. They are kept: a false positive on bsdtar is preferred to a false negative on GNU tar. git --config-env is left out (its danger needs control of an environment variable).

Reviews

An independent review of the first version found two blocking false negatives: a constant prefix refuted the value of an option that runs it (["sh", "-c", f"echo {x}"], ["ssh", "-o", f"ProxyCommand={x}"], ["find", ".", "-exec", f"sh -c {x}", ";"]), and ssh was taken to end its options at -- while its operands after the host are a remote shell command. A second review of the fixed version found more:

  • a shell's -c read as an option taking a value, so sh -ce …, bash -c -e … and sh -c -- … were refuted;
  • ssh operands after the first remote word judged as ssh options;
  • find -exec sh -c x;
  • an element of unknown value before the input leading to a refutation;
  • an inert reading applied with the subcommand unknown, or to several elements;
  • git clone -u;
  • PowerShell operands and parameter case;
  • a glued cmd /c;
  • an unreadable executable=.

All of them are fixed and covered by tests.

A third review confirmed those fixes and found more, also fixed and covered by tests:

  • a shell option the model does not describe (bash -O extglob, --rcfile f, sh +e) hid -c, so sh -c injections were refuted;
  • NAME=VALUE before the program of env or sudo was taken for the program;
  • with the program unknown ([extra, f"echo {x}"], an unreadable positional executable), a constant prefix refuted an operand the program may run. It is now kept for review, and shutil.which("ssh") names the program;
  • a chain of several hundred wrappers raised RecursionError and stopped the scan. Commands inside commands are now read to a depth of 32, and deeper elements are taken as operands the program runs (high);
  • a 2000-element command with every element tainted took 11 s. Element texts and options are now read once per command, which brings it to about 1 s.

A fourth review, of the interpreter script model, found python -W reported medium, a whole first operand of python or node reported only as a hotspot, the interpreter's options applied to the script's arguments (node app.js -e x read as node -e), versioned and distribution names not recognised, missing code options (perl -M, ruby -r, node --require, php -R) and bash -s. All are fixed and covered by tests.

A fifth review found that perl, ruby, node and php read options after -e CODE until their first operand (perl -e CODE -e x is Perl code, checked on the real binaries), while python and the shells read none; that a shell's lone - ends its options; that unmodelled flags of perl, ruby, node and php kept their scripts' arguments for review; pwsh, which reads its first operand as a script, and pyw/-dbg names. All are fixed and covered by tests.

A sixth review found a regression of that fix: pwsh -Command, -c and -EncodedCommand were read as code followed by script arguments, while every element after them is the command; and node -pe (--print --eval, code in the next element) was read as -p with a glued value. Both are fixed and covered by tests.

Validation

  • New tests/test_command_arguments.py: 172 tests, covering every case of the table (each checks severity, verdict and confidence) and the order of unpacked elements in the IR.

    • The cases added after each review failed on the version reviewed: 28 after the first, 22 after the second, 12 after the third (including a 500-wrapper command), the interpreter script cases, which failed before their model, 20 after the review of that model, 20 after the next one, and 7 after the sixth.
    • The rm and ssh option groups failed before the getopt reading.
    • The fallback cases (mutated lists, **kwargs, args=, tuples) pass on every version and pin the previous result.
  • tests/test_detectors.py: the manifest of command-injection now requires ir.ssa, ir.defuse and abstract.module_strings, like ssrf. Only that assertion was updated, with prior approval.

  • Full suite: 1875 passed, 1 failed (test_packaging::test_the_version_has_one_source_of_truth, the known stale local install).

  • ruff check, mypy and git diff --check pass, as does ruff format --check on the new and rewritten files and on the changed lines. test_architecture holds: the plugin imports no parser.

  • Regression snapshots are unchanged (read-only diff of every repository). The local deps.py difference of full-stack-fastapi-template also appears on main with Python 3.14.

  • luigi sge.py:296 is subprocess.call(["rm", "-rf", self.tmp_dir]), with a value read from a process. It stays a vulnerability, now medium: -r and -f take no value.

  • mitmproxy was rescanned with the configuration of the 2026-10-02 reference: commit be75825, enterprise plugins at ac35460, [email protected], no baseline. Compared with its 87 results, on top of the fixes already merged, the 8 high command-injection findings become:

    • 5 refuted:
      • F021: git commit -m, an inert message;
      • F022 and F024: f"v{version}" operands;
      • F025: gh release create, a tag operand and --title;
      • F026: gh workflow run --ref.
    • 2 medium hotspots to review: build.py:178 and :200, values after xcrun notarytool --keychain, an option the model does not describe.
    • release.py:132 stays high for both of its flows:
      • branch, read from git branch --show-current, is a free element of git push, which accepts --receive-pack=. Git refuses branch names starting with -, which the engine cannot know.
      • tag_name, from argv (f"v{version}"), follows branch, an element of unknown value that may be an option taking it. It is kept for review (hotspot) at the severity of a free element of git push.

    The scan goes from 87 to 75 results, and high from 18 to 4: F004, F049 and the two flows of release.py:132. Among the components, only the command-injection plugin digest differs.

Not covered

  • The message title stays Command injection and the rule id command-injection. Naming the option cases (command-injection: severity of option injection in argument lists run without a shell #208 "Rule naming") is left for a follow-up decision.
  • git's global -c is also applied after the subcommand, so git commit -c x is reported high, a false positive.
  • PowerShell parameters given by an abbreviated prefix (-com) are not recognised; they are kept for review.
  • The option model holds only the entries above. A program it does not describe gets the default readings.

…rols

Every tainted argument of subprocess calls was a high command injection,
whether or not a shell runs it. The rule now reads the command a flow
reaches: a shell string, a string naming the program or the program itself
stays high; an element of an argument list run without a shell is an option,
medium unless a documented option of the program runs a command it chooses;
a constant prefix that keeps the input from being an option, or an operand
after -- for a program that honours it, is refuted; a value after an option
the model does not describe stays a hotspot to review.

TaintDetector gains an assess hook returning the verdict and the severity,
since a hotspot verdict only lowers the confidence. List and tuple displays
keep the place of their unpacked elements in the IR, so that the order of a
command survives *(...) parts.

Closes #208
@SizzleUnrlsd SizzleUnrlsd added the bug Something isn't working label Oct 5, 2026
@SizzleUnrlsd SizzleUnrlsd self-assigned this Oct 5, 2026
Review of the first version: a constant prefix refuted the value of an
option that runs it (sh -c "echo $x", ssh -o ProxyCommand=..., find -exec),
and ssh operands after the host, a remote shell command, were read as plain
operands. A value an established option takes is now judged by what the
option does with it, whatever its prefix: run it (high), keep it as data
(git commit -m, gh --title: refuted), or an effect not established (medium);
after an option the model does not describe, the finding stays for review.
Operands a program runs as a command (ssh after the host, the program of
sudo, env, xargs, timeout..., arguments of a batch file) are high.

The option model also covers cmd /c and powershell -Command, shell flags
grouped with -c, tar -I, the program given by a constant executable=,
sys.executable, git.exe and python3.12; shell given at its position is
read; -- counts anywhere before the input, and ssh is no longer taken to
end its options there.

Refs #208
…ands

Second review: a shell's -c is a flag whose first operand is the command,
so grouped and separate forms (sh -ce, bash -c -e, sh -c --) were refuted;
ssh operands after the first remote word are shell text even when they start
with -; find -exec sh -c x and similar commands inside a command were judged
as free elements; an element of unknown value before the input led to a
refutation.

Programs now declare the operands they run: shell text (ssh after the host,
powershell, a shell's first operand after -c) or a command of its own (the
program of sudo, env, xargs, timeout..., what follows find -exec), read again
with that program's model. An element of unknown value before the input
keeps the finding for review. An inert reading needs the subcommand
established and covers one element only; git clone -u, case-insensitive
Windows options, a glued cmd /c, and an executable that cannot be read are
handled.

Refs #208
…unknown programs

Third review: a shell option the model does not describe (bash -O extglob,
--rcfile f, sh +e) hid -c, so sh -c injections were refuted; NAME=VALUE before
the program of env or sudo was taken for the program; with the program
unknown, a constant prefix refuted an operand it may run; a deep chain of
wrappers raised RecursionError.

Reading the options before a shell's operands now stops at an option it cannot
read, the shells' + options and bash long options are modelled, env and sudo
skip their assignments, an unknown program keeps the finding for review, and
shutil.which(name) names the program. Commands inside commands are read to a
bounded depth, and element texts and options are read once per command.

Refs #208
A script path chosen by the input was refuted as an operand when it had a
constant prefix (bash f"/tmp/{x}.sh"), unlike the program of a wrapper.
Interpreters now declare the script their first operand names: sh, bash,
dash, zsh, ksh, python, perl, ruby, node and php, unless an option gives the
code (-c, -m, -e, --eval, -r). The input there is high, kept for review:
execution is possible, and its exploitability depends on the file the input
chooses; so is the module of python -m. The code options of perl, ruby, node
and php run their value, and python's flags are modelled so that its options
can be read.

Refs #208
…options

Review of the interpreter script model: python -W, which imports the module
its category names, was medium; a whole first operand of python or node,
which may be a code option and its value, became a hotspot; the
interpreter's own options were applied to the script's arguments (node
app.js -e x read as node -e); versioned or distribution names (nodejs, pypy3,
perl5.36) were not recognised; perl -M, ruby -r, node --require and php -B/-R/-E
were missing; bash -s reads its script from the standard input.

Arguments after a script or code are now read as the script's own: its
options, of unknown meaning, kept for review after an option and refuted
behind a constant prefix. An input that starts the script operand keeps the
high reading of a code option when the interpreter has one.

Refs #208
…ter code

Review of the script arguments: perl, ruby, node and php read options after
-e CODE until their first operand, so perl -e CODE -e x is Perl code, while
the arguments reading took it for the script's; python and the shells read
none after the code. A shell's lone - ends its options, and the operand after
it is the script. The flags and value options of perl, ruby, node, php and
PowerShell are modelled, so that a script argument behind them is no longer
kept for review; php -f, -d, -c, -z and PowerShell -File choose code to
examine; pwsh reads its first operand as a script; node short options take no
glued value; pyw and -dbg names are recognised. Whole-word options are matched
before being read as grouped short options.

Refs #208
Review of the interpreter options: pwsh -Command, -c and -EncodedCommand were
read as giving code followed by script arguments, while every element after
them is the command, so pwsh -Command Get-Item v$x was refuted. A code option
that takes the rest of the command line now leaves its elements to that
reading. node -pe, an alias of --print --eval, takes its code from the next
element.

Refs #208
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

command-injection: severity of option injection in argument lists run without a shell

1 participant