How this bug class keeps getting shipped

Argument injection is the vulnerability class that refuses to die. It is not command injection, though the two look related, and not shell metacharacter smuggling, though defenders often triage it as such. It is the specific failure to neutralize argument delimiters (spaces, quotes, --, option prefixes) when user-controlled data is spliced into a fully quoted, shell-safe subprocess invocation. The program never spawns a shell. The developer feels safe. The child process happily consumes the attacker's flags.

CISA's Known Exploited Vulnerabilities catalog added a fresh entry on 2026-09-10: CVE-2026-86060 in MikroTik RouterOS, described as an improper neutralization of argument delimiters that lets an attacker change the trusted RouterOS policy mask, resulting in privilege escalation. The catalog entry is terse, but the shape is familiar to anyone who has read a Dell PowerStore advisory: CVE-2024-51532 was published under the same CWE, enabling a low-privileged local attacker to modify system state via injected arguments. Two vendors, two product categories, one root cause. This post traces the mechanism end to end using those two public entries and shows what to check in your own reference architectures before the same shape appears in your product.

The mechanism, in one paragraph

Argument injection lives in the gap between "no shell" and "no attacker input in argv." A privileged wrapper takes a caller-supplied string (a filename, a hostname, an interface, a policy identifier) and passes it, correctly quoted, as an argument to a trusted binary. Because the string is quoted, no shell metacharacter can escape. But the trusted binary parses argv with getopt-style option handling, and a leading - or an interior --config=/tmp/evil is not a metacharacter: it is a first-class flag. The wrapper's quoting cannot prevent the caller from selecting a mode the wrapper never intended to expose. The child process obediently reconfigures itself, and the caller inherits whatever the trusted binary is trusted to do.

Anatomy of the RouterOS case (CVE-2026-86060)

The CISA KEV entry for CVE-2026-86060 describes the vulnerable behavior as improper neutralization of argument delimiters that "allows an attacker to change the trusted RouterOS policy mask, leading to privilege escalation." Policy masks in RouterOS govern the capabilities a session may exercise: read, write, policy, sensitive, sniff, and so on. If an attacker who controls an argument to a privileged internal command injects an option that widens the mask or redirects the policy target, the result is not a misbehaving command: the session that emerges holds capabilities it did not hold on entry.

The abstract pattern in pseudocode stays within what the KEV entry discloses:

# Trusted wrapper: "safe" because no shell, and argv is fully quoted.
def apply_policy(user_supplied_name):
    argv = ["/nova/bin/policy", "apply", user_supplied_name]
    subprocess.run(argv, check=True)   # no shell=True, no injection... right?

# Attacker-controlled input:
user_supplied_name = "--mask=full"

# What the trusted binary actually parses:
#   argv[0] = /nova/bin/policy
#   argv[1] = apply
#   argv[2] = --mask=full        <-- option, not positional argument

Nothing in the wrapper is "wrong" by the checklist most reviewers carry. There is no shell=True, no format string, no os.system, no unescaped metacharacter. The bug: the wrapper never asserted that user_supplied_name begins with a legal identifier character rather than a delimiter, and never told the trusted binary "everything after this point is positional." Both defenses are one line each; neither is expensive; neither is present.

The Dell PowerStore variant (CVE-2024-51532)

CVE-2024-51532 places the same class in a very different product: Dell PowerStore, a storage array. The advisory states that a low-privileged attacker with local access could exploit an argument-injection flaw to modify state beyond their authorization. The specific injected surface lacks public PoC disclosure, but the CWE and the design smell are identical. Storage arrays are dense with privileged wrappers: quiesce a volume, take a snapshot, unmount a filesystem, restart a service. Each is a candidate. Any wrapper that accepts a caller-supplied identifier and passes it to a sudo-fronted binary is vulnerable wherever a missing -- separator is all that separates "manage your own resources" from "manage anyone's resources."

The class keeps shipping because the two products share almost nothing at the code, language, or deployment level, yet the same mistake reached production in both. Argument injection is not a language bug or a library bug. It is a contract bug between two components that individually pass every unit test.

What an attacker needs, and how to tell whether you are exposed

An attacker needs three things: a code path that accepts their input, a privileged subprocess running with more authority than they hold, and a wrapper that quotes for the shell but does not sanitize for getopt. To find candidates in your own stack, hunt for these patterns in privileged code paths:

# Wrappers that pass caller-controlled tokens to trusted binaries
rg -n 'subprocess\.(run|Popen|call|check_output)\(\s*\[' \
   --glob '!**/tests/**' --glob '!**/vendor/**'

# Same shape in Go
rg -n 'exec\.Command\(' --glob '!**/vendor/**'

# Same shape in Node
rg -n 'child_process\.(spawn|execFile)\(' --glob '!**/node_modules/**'

# Setuid or capability-bearing binaries reachable from a lower-trust caller
getcap -r / 2>/dev/null
find / -perm -4000 -type f 2>/dev/null

For each hit, ask three questions. Does any argv element after argv[0] originate, in whole or in part, from a caller with less authority than the child process carries? Is there a -- end-of-options separator between the fixed and caller-controlled portions of argv? Does the caller-controlled token clear an allowlist (identifier regex, path canonicalization, enum) before reaching the argv builder? A no to either of the last two, combined with a yes to the first, is a bug to treat as latent CVE-2026-86060 or CVE-2024-51532 until proven otherwise.

Fixing it once, on purpose

The remediation pattern is the same in every language. Refuse tokens whose first character is -. Insert -- between the fixed and variable portions of argv. Prefer positional inputs over anything that can be reinterpreted as an option. If the trusted binary does not support --, wrap it in one that does, or vet the input against a strict allowlist and document the allowlist in the same file as the subprocess call so the coupling is visible on code review.

Argument injection keeps shipping because it hides in code that looks correct to reviewers trained on shell injection. Treat CVE-2026-86060 and CVE-2024-51532 as the data points that put this class on your control catalog, and rewrite the wrappers before a KEV entry names your product.

Verifiable security.