GitHub Advisoryadvisory · PyPI
GHSA-v9pv-5gc8-w2m6 Pillow multi-frame loaders can reuse stale native image storage after mode transitions
CRITICAL
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)
- CVE-2026-96639 Pillow is a Python imaging library. vulnerability
References
- https://github.com/python-pillow/Pillow/security/advisories/GHSA-v9pv-5gc8-w2m6 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-96639 advisory
- https://github.com/python-pillow/Pillow/pull/10105 web
- https://github.com/python-pillow/Pillow/commit/78d97d994579aef81aafe123773d525f40f2522a web
- https://github.com/python-pillow/Pillow package
- https://github.com/python-pillow/Pillow/releases/tag/13.0.0 web