{"id":"GHSA-v9pv-5gc8-w2m6","summary":"Pillow multi-frame loaders can reuse stale native image storage after mode transitions","details":"### Summary\nPillow multi-frame image loaders can retain native `ImagingCore` storage allocated for one frame while changing the Python-side image `mode` and decoder tiles for a later frame.\n\nIf frame 0 allocates storage for a narrow mode such as `L`, and a later frame of the same dimensions switches to a wider mode such as `RGB` or `CMYK`, Pillow may decode wider pixels into the stale narrow native buffer. This causes native\nout-of-bounds writes and process crashes. In the MPO/JPEG path, I also demonstrated command execution in matching Python/Pillow runtime profiles through Pillow's documented `ImageSequence.Iterator` + `frame.load()` path.\n\n### Details\nThe root cause appears to be that `ImageFile.load_prepare()` only reallocates native storage when `_im is None`. It does not verify that the retained native `ImagingCore` storage still matches `self.mode` and `self.size`.\n\nSeveral multi-frame `seek()` / `_open()` paths can update `self._mode` and decoder tiles for a later frame without invalidating `_im`. The decoder then writes or reads according to the new frame mode while the native storage still belongs to the old frame mode.\n\nVerified affected surfaces:\n\n1. **PSD**\n\n   `PsdImageFile.seek()` can switch from grayscale/native `L` storage to RGB layer decoding while retaining `_im`. This produces attacker-controlled native out-of-bounds writes and reliable crashes.\n\n2. **MPO/JPEG**\n\n   A crafted two-frame MPO can make frame 0 allocate `L` storage, then make frame 1 decode `CMYK` / `RGB` data into the retained `L` storage. The documented `ImageSequence.Iterator` path reaches this with `frame.load()`.\n   Verified native memory corruption, crash, over-read behavior, and scoped harmless-marker command execution on Pillow 12.3.0.\n\n3. **DCX**\n\n   `DcxImageFile.seek()` calls `PcxImageFile._open()`, which changes mode/tile  state without a native-storage mode guard. A crafted DCX with `L` then `RGB` PCX frames produces retained-storage mismatch and crash.\n\n4. **MIC**\n\n   MIC can call `TiffImageFile._open()` directly and bypass TIFF's normal guarded `seek()` behavior. With a `NextIFD`-based reachable frame transition, this produces retained-storage mismatch and crash.\n\nTIFF appears to be a useful positive control because its `seek()` path checks\nboth size and mode before reusing storage.\n\n### Impact\n- process crash / denial of service,\n- native out-of-bounds write,\n- native over-read in the MPO path.\n\nStronger proven impact:\n\n- command execution in matching Python/Pillow runtime profiles through the MPO/JPEG path.\n\nServer-side deployment impact:\n\n- Create a local vulnerable web upload service that accepts an unauthenticated MPO upload and automatically processes it with Pillow.\n- The attack is one HTTP request and requires no user interaction after upload.\n- The fixed harmless command-output marker succeeded 10/10 times.\n- The `_im=None` control was clean 10/10 times.\n\n\n## Reproduction Evidence\n\nTested version:\n\n```text\nPillow 12.3.0\n```\n\nMPO terminal proof environments:\n\n```text\nPython 3.12.3 + Pillow 12.3.0, clean PyPI environment:\nmarker 10/10, control 0/10\n\nPython 3.13.14 + Pillow 12.3.0, prepared profile:\nmarker 10/10, control 0/10\n```\n\nServer-side web-upload proof:\n\n```text\ncontrol 1..10: MARKER_MISSING\ncontrol_summary ok 0 missing 10 /10\n\nvulnerable 1..10: MARKER_OK marker_output \u003cserver-user\u003e\nvulnerable_summary ok 10 missing 0 /10\n```\n\nThe terminal proof uses a fixed harmless marker command only. It does not use a reverse shell, persistence, data exfiltration, or arbitrary attacker-selected commands.\n\nI can provide the following private artifacts to maintainers:\n\n- minimal crash fixtures for PSD, MPO, DCX, and MIC,\n- non-weaponized MPO marker PoC,\n- local vulnerable web upload demo,\n- gdb/GEF geometry receipts,\n- control-run logs,\n- proposed regression-test shape.\n\n## Minimal Reproduction Shape\n\nFor the MPO path, the vulnerable consumer shape is:\n\n```python\nfrom io import BytesIO\nfrom PIL import Image, ImageSequence\n\nsource = BytesIO(open(path, \"rb\").read())\nimage = Image.open(source)\niterator = ImageSequence.Iterator(image)\nnext(iterator).load()\nnext(iterator).load()\n```\n\nThe crafted image shape is:\n\n```text\nframe 0: mode L, dimensions W x H\nframe 1: same W x H, wider mode such as CMYK\n```\n\nFrame 0 allocates native `L` storage. Frame 1 changes the logical mode and decoder state but can reuse the old native storage. The frame 1 decoder writes using the wider mode into the narrow storage.\n\n## Suggested Fix Direction\n\nBefore reusing native `ImagingCore` storage, Pillow should verify that the retained storage is compatible with the current logical image mode and size.\n\nA central guard in `ImageFile.load_prepare()` could compare native storage mode/size against `self.mode` / `self.size` and allocate fresh storage when they differ. Alternatively, every mode-changing `seek()` / `_open()` path should explicitly invalidate `_im` before decoding.\n\nCaution: TIFF has specialized tile-size behavior and already has a mode/size guard, so a central fix should avoid regressing TIFF tile-buffer handling.\n\n\n## Credit\n\n```text\nRotem Kama\n```\n\n## Methodology Note\n\nAI-assisted analysis was used for discovery and verification support. Claims in this report are limited to the local artifacts, controls, and command outputs described above.","aliases":["CVE-2026-96639"],"modified":"2026-10-22T16:15:06.500339985Z","published":"2026-10-22T16:00:15Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-22T16:00:15Z","nvd_published_at":"2026-10-21T21:20:32Z","cwe_ids":["CWE-125","CWE-787"],"severity":"CRITICAL"},"references":[{"type":"WEB","url":"https://github.com/python-pillow/Pillow/security/advisories/GHSA-v9pv-5gc8-w2m6"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-96639"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/pull/10105"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/commit/78d97d994579aef81aafe123773d525f40f2522a"},{"type":"PACKAGE","url":"https://github.com/python-pillow/Pillow"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/releases/tag/13.0.0"}],"affected":[{"package":{"name":"pillow","ecosystem":"PyPI","purl":"pkg:pypi/pillow"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"11.0.0"},{"fixed":"13.0.0"}]}],"versions":["11.0.0","11.1.0","11.2.1","11.3.0","12.0.0","12.1.0","12.1.1","12.2.0","12.3.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-v9pv-5gc8-w2m6/GHSA-v9pv-5gc8-w2m6.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]}