GitHub Advisoryadvisory · PyPI

GHSA-v9pv-5gc8-w2m6 Pillow multi-frame loaders can reuse stale native image storage after mode transitions

CRITICAL Published 2026-10-22 Modified 2026-10-22 Official page ↗
Aliases
CVE-2026-96639
Ecosystem
PyPI
Packages
Pillow
Severity
Critical
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Weaknesses
CWE-125, CWE-787
Reviewed
2026-10-22

Affected packages

• PyPI Pillow: >= 11.0.0, < 13.0.0 → fixed in 13.0.0

Description

Summary Pillow 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. If 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 out-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. Details The 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`. Several 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. Verified affected surfaces: 1. PSD `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. 2. MPO/JPEG 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()`. Verified native memory corruption, crash, over-read behavior, and scoped harmless-marker command execution on Pillow 12.3.0. 3. DCX `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. 4. MIC 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. TIFF appears to be a useful positive control because its `seek()` path checks both size and mode before reusing storage. Impact • process crash / denial of service, • native out-of-bounds write, • native over-read in the MPO path. Stronger proven impact: • command execution in matching Python/Pillow runtime profiles through the MPO/JPEG path. Server-side deployment impact: • Create a local vulnerable web upload service that accepts an unauthenticated MPO upload and automatically processes it with Pillow. • The attack is one HTTP request and requires no user interaction after upload. • The fixed harmless command-output marker succeeded 10/10 times. • The `_im=None` control was clean 10/10 times. Reproduction Evidence Tested version: ```text Pillow 12.3.0 ``` MPO terminal proof environments: ```text Python 3.12.3 + Pillow 12.3.0, clean PyPI environment: marker 10/10, control 0/10 Python 3.13.14 + Pillow 12.3.0, prepared profile: marker 10/10, control 0/10 ``` Server-side web-upload proof: ```text control 1..10: MARKER_MISSING control_summary ok 0 missing 10 /10 vulnerable 1..10: MARKER_OK marker_output vulnerable_summary ok 10 missing 0 /10 ``` The terminal proof uses a fixed harmless marker command only. It does not use a reverse shell, persistence, data exfiltration, or arbitrary attacker-selected commands. I can provide the following private artifacts to maintainers: • minimal crash fixtures for PSD, MPO, DCX, and MIC, • non-weaponized MPO marker PoC, • local vulnerable web upload demo, • gdb/GEF geometry receipts, • control-run logs, • proposed regression-test shape. Minimal Reproduction Shape For the MPO path, the vulnerable consumer shape is: ```python from io import BytesIO from PIL import Image, ImageSequence source = BytesIO(open(path, "rb").read()) image = Image.open(source) iterator = ImageSequence.Iterator(image) next(iterator).load() next(iterator).load() ``` The crafted image shape is: ```text frame 0: mode L, dimensions W x H frame 1: same W x H, wider mode such as CMYK ``` Frame 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. Suggested Fix Direction Before reusing native `ImagingCore` storage, Pillow should verify that the retained storage is compatible with the current logical image mode and size. A 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. Caution: TIFF has specialized tile-size behavior and already has a mode/size guard, so a central fix should avoid regressing TIFF tile-buffer handling. Credit ```text Rotem Kama ``` Methodology Note AI-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.

Related

Alias · CVE (1)

Weakness · CWE (2)

  • CWE-125 Out-of-bounds Read weakness
  • CWE-787 Out-of-bounds Write weakness

References