Back to Blog
critical SEVERITY7 min read

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

A command injection vulnerability in `typhoon/typhoon_window.py` allowed a locally-crafted monitor name from `xrandr` output to inject arbitrary shell commands via a `subprocess.check_output()` call with `shell=True`. The fix replaces the interpolated shell string with a safe argument list, eliminating the injection surface entirely. This is a textbook example of how seemingly harmless system-integration code can become an exploitable attack vector.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Python's `subprocess.check_output()` inside `typhoon/typhoon_window.py`. The vulnerable code used `shell=True` and interpolated a `primary_monitor` variable — derived from `xrandr` output — directly into a shell command string. An attacker who could influence monitor names (e.g., via a malicious display configuration) could inject arbitrary shell commands. The fix replaces the f-string shell command with a plain argument list (`["xfconf-query", "-c", "xfce4-desktop", "-p", key]`), which bypasses the shell entirely and neutralizes the injection vector.

Vulnerability at a Glance

cweCWE-78
fixReplace f-string shell command with a structured argument list, removing shell=True
riskArbitrary shell command execution with the privileges of the running process
languagePython
root causeUnsanitized xrandr-derived monitor name interpolated into a shell=True subprocess call
vulnerabilityOS Command Injection

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

The get_wallpaper_path() Function Had a Hidden Injection Point

The typhoon/typhoon_window.py file is responsible for managing desktop window behavior, including detecting and retrieving the current wallpaper path across different desktop environments. On the surface, the get_wallpaper_path() method looks like routine system integration code — it queries xrandr to find the primary monitor, builds a configuration key, then asks xfconf-query for the wallpaper path. Routine, that is, until you look at line 1132.

# Before the fix — vulnerable code at line 1132
wallpaper = subprocess.check_output(
    f'xfconf-query -c xfce4-desktop -p "{key}"', shell=True, text=True
).strip()

The variable key is constructed from primary_monitor, which is itself derived from parsing live xrandr output. That chain — external tool output → string interpolation → shell=True subprocess — is exactly the pattern that creates a command injection vulnerability (CWE-78). Let's break down exactly why, and what the one-line fix looks like.


The Vulnerability Explained

What Makes shell=True Dangerous Here

When you call subprocess.check_output(..., shell=True), Python hands your entire string to /bin/sh -c. That means the shell interprets every metacharacter in the string: semicolons, backticks, $() expansions, pipes, redirects — all of it. If any part of the string comes from an untrusted source, you've handed that source a shell prompt.

In this case, the dangerous input path is:

  1. xrandr is executed to list connected monitors.
  2. The code parses the output to extract primary_monitor — the name of the primary display (e.g., HDMI-1, eDP-1).
  3. primary_monitor is embedded into the key variable:
    python key = f"/backdrop/screen0/monitor{primary_monitor}/workspace0/last-image"
  4. key is then interpolated directly into the shell command string:
    python f'xfconf-query -c xfce4-desktop -p "{key}"'

The double quotes around {key} in the f-string provide no protection against shell injection. A monitor name like:

HDMI-1"; rm -rf ~ #

would produce the shell string:

xfconf-query -c xfce4-desktop -p "/backdrop/screen0/monitorHDMI-1"; rm -rf ~ #/workspace0/last-image"

The shell sees two commands separated by ; and executes both.

Realistic Attack Scenario

An attacker with local system access — or the ability to influence display configuration (e.g., via a malicious USB-C dock, a compromised display driver, or a crafted EDID) — could set a monitor name containing shell metacharacters. When the Typhoon application calls get_wallpaper_path() (for instance, on startup or when the desktop environment changes), the injected command executes silently with the privileges of the Typhoon process.

This isn't theoretical. EDID spoofing via USB-C adapters and malicious monitors is a documented local privilege escalation vector. The monitor name is part of the EDID data, which is entirely attacker-controlled in such scenarios.

Real-World Impact for This Application

Typhoon is a desktop application running as the logged-in user. Successful exploitation would give an attacker code execution in that user's context — access to their home directory, credentials, session tokens, and any other resources the user can reach. On a developer's workstation, that could mean access to SSH keys, GPG keys, cloud credentials, and source code.


The Fix

One Line, One Principle: Never Use the Shell When You Don't Need It

The fix is elegantly minimal. At line 1132, the f-string shell command is replaced with a structured argument list, and shell=True is removed:

# Before — vulnerable
wallpaper = subprocess.check_output(
    f'xfconf-query -c xfce4-desktop -p "{key}"', shell=True, text=True
).strip()

# After — safe
wallpaper = subprocess.check_output(
    ["xfconf-query", "-c", "xfce4-desktop", "-p", key], text=True
).strip()

When subprocess.check_output() receives a list, Python uses execvp() (or equivalent) to launch the process directly — no shell is involved. Each list element is passed as a discrete argument to the xfconf-query process. No amount of shell metacharacters in key can escape the argument boundary; they are passed as literal characters to the program.

Why This Completely Neutralizes the Injection

With the list form:
- The shell is never invoked — there is no interpreter to abuse.
- key is passed as a single, atomic argument to xfconf-query, regardless of its content.
- A monitor name like HDMI-1"; malicious_cmd # is handed verbatim to xfconf-query as one argument, which will simply fail to find a matching key — no command execution occurs.

The fix requires zero validation, zero escaping, and zero additional dependencies. It's the correct structural solution: remove the attack surface entirely rather than trying to sanitize around it.


Prevention & Best Practices

1. Default to Argument Lists in Python subprocess

Make the list form your first instinct whenever you use subprocess. Reserve shell=True only for cases where you genuinely need shell features (pipelines, glob expansion) and you can guarantee the entire string is static with no external input.

# Prefer this
subprocess.run(["git", "log", "--oneline", branch_name], check=True)

# Avoid this unless you have no alternative
subprocess.run(f"git log --oneline {branch_name}", shell=True, check=True)

2. Treat All External Tool Output as Untrusted

xrandr, lshw, uname, hostname — any program that reflects hardware or environment state can return attacker-influenced data in certain scenarios. Parse their output, but don't embed it into shell strings.

3. Use shlex.quote() as a Last Resort, Not a First Line of Defense

If you absolutely must construct a shell string with dynamic data, shlex.quote() will properly escape a single token for POSIX shells. But this is a fallback — the argument list approach is always safer and clearer.

import shlex
# Only if shell=True is truly unavoidable:
safe_key = shlex.quote(key)
subprocess.check_output(f'xfconf-query -c xfce4-desktop -p {safe_key}', shell=True)

4. Run Static Analysis in CI

Tools that can catch this pattern automatically:

  • Bandit (B602, B603): flags subprocess calls with shell=True and string formatting
  • Semgrep: rules targeting subprocess + shell=True + f-strings
  • multi_agent_ai (used in this PR): flagged this exact pattern as rule V-001

Add these to your CI pipeline so injection-prone patterns are caught before they reach production.

5. Follow OWASP A03:2021 — Injection

The OWASP Top 10 lists injection as the third most critical web application security risk, and the same principles apply to desktop and system software. Treat all data that crosses a trust boundary as potentially hostile.

Relevant standards:
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP Command Injection Defense Cheat Sheet


Key Takeaways

  • shell=True + f-strings is a red flag in any language: In typhoon_window.py, the combination of shell=True and f'...{key}...' created a direct injection path from xrandr output to shell execution.
  • Monitor names are attacker-influenced data: Hardware-derived strings like display names from xrandr should never be trusted as safe for shell interpolation — EDID spoofing is a real attack vector.
  • The argument list form of subprocess is always available: There was no functional reason to use shell=True here; xfconf-query accepts positional arguments that map cleanly to a Python list.
  • Removing the shell removes the attack surface: The fix didn't need sanitization or escaping — it eliminated the interpreter that would have processed metacharacters in the first place.
  • One line of code, one critical severity finding: The difference between shell=True with an f-string and a plain argument list is a single line change — but the security difference is enormous.

How Orbis AppSec Detected This

  • Source: primary_monitor variable derived from parsing xrandr command output — external, attacker-influenceable data
  • Sink: subprocess.check_output(f'xfconf-query -c xfce4-desktop -p "{key}"', shell=True, ...) at typhoon/typhoon_window.py:1132
  • Missing control: No sanitization or escaping of primary_monitor before embedding it in the shell command string; no validation that the monitor name is free of shell metacharacters
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced the f-string shell command with a structured argument list and removed shell=True, bypassing the shell interpreter entirely

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 typhoon/typhoon_window.py is a precise illustration of how command injection sneaks into production code: not through obvious user input, but through a chain of seemingly safe operations — querying a system tool, parsing its output, building a config key, and passing it to another tool. Each step looks reasonable in isolation. Together, they create an exploitable injection path.

The fix — replacing a shell string with an argument list — is one of the most reliable security improvements available in Python. It requires no new libraries, no complex validation logic, and no ongoing maintenance. It simply removes the shell from the equation.

If you maintain Python code that calls external programs, audit your subprocess calls today. Search for shell=True combined with any string formatting (f"", %, .format()). Each instance is a potential injection point waiting for the right input to arrive.


References

Frequently Asked Questions

What is command injection?

Command injection (CWE-78) occurs when attacker-controlled data is embedded in a shell command string, allowing extra commands to be appended or substituted and executed by the OS.

How do you prevent command injection in Python?

Pass commands as a list of strings to subprocess functions and never use shell=True with any untrusted or externally-derived data. This bypasses the shell interpreter entirely.

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?

No. Manual escaping is error-prone and easy to get wrong. The correct approach is to avoid the shell entirely by passing an argument list, as Python's subprocess module supports natively.

Can static analysis detect command injection?

Yes. Tools like Semgrep, Bandit, and multi-agent AI scanners can flag subprocess calls that use shell=True combined with string interpolation or concatenation, as seen in this exact case.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #51

Related Articles

high

How Shell Injection via os.system() happens in Python and how to fix it

A shell injection vulnerability in TensorFlow's DELF dataset download script allowed attackers who controlled the `data_dir` parameter to execute arbitrary shell commands by injecting metacharacters into `os.system()` calls. The fix replaces all four `os.system()` invocations with `subprocess.run()` using argument lists, eliminating shell interpretation entirely. This change closes a high-severity code execution path in production ML infrastructure.

critical

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

CVE-2026-9277 is a critical command injection vulnerability in the `shell-quote` npm package (versions prior to 1.8.4) caused by unescaped line terminators that allow attackers to inject and execute arbitrary shell commands. The fix pins `shell-quote` to `>=1.8.4` via a `pnpm.overrides` entry, ensuring every transitive consumer in the dependency tree receives the patched version. Any Node.js project that processes user-influenced input through `shell-quote` and has not yet upgraded is at risk of

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` Node.js library that allowed attackers to bypass previously applied security fixes. Applications using `simple-git` versions below 3.32.3 remained exposed even after earlier patches, and upgrading to 3.32.3 — which introduced hardened argument parsing via new `@simple-git/argv-parser` and `@simple-git/args-pathspec` sub-packages — closes the bypass. This fix is especially urgent because the vulnerability affects

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 in parsed shell arguments could allow attackers to inject and execute arbitrary commands. The fix upgrades shell-quote to version 1.8.4 and pins the resolution in both `package.json` and `yarn.lock` to ensure the patched version is used across the entire dependency tree. Because this package is used in a production web application that processes user-influenced input,

critical

How Command Injection happens in Python PopClip Extensions and how to fix it

A critical command injection vulnerability was discovered in `contrib/Klipz.popclipext/Klipz.py`, where user-controlled clipboard content was concatenated directly into shell commands executed via `osascript`. The fix replaces unsafe string concatenation with `subprocess` and proper argument lists, and replaces the unsafe `pickle` serialization with `json` to eliminate a secondary deserialization risk. Together, these changes close two distinct attack surfaces in a single file.

critical

How Command Injection happens in Rust-generated Python scripts and how to fix it

A critical command injection vulnerability (CWE-78) was discovered in the Linux automation module of the `goose-mcp` crate, where Rust code generated Python scripts that passed user-controlled commands directly to `subprocess.run()` with `shell=True`. An attacker who could influence the `commands` parameter in `execute_system_script()` could inject arbitrary shell commands using metacharacters like `;`, `|`, or backticks. The fix replaces `shell=True` with `shlex.split()` and `shell=False`, and