# Pillow multi-frame loaders can reuse stale native image storage after mode transitions

Pillow multi-frame loaders reuse stale native ImagingCore storage after mode changes, letting crafted PSD/MPO/DCX/MIC images cause native OOB writes and RCE via MPO.

- Payout: Research
- Severity: critical
- Program: Pillow
- Found by: rotemkama
- Disclosed: 2026-10-22
- CVE: CVE-2026-96639
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-v9pv-5gc8-w2m6
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-v9pv-5gc8-w2m6
- Tags: area:oss domain:oss impact:dos impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:python technique:source-review vuln:dos vuln:memory-corruption vuln:rce

**Target:** Pillow multi-frame image decoders (PSD, MPO/JPEG, DCX, MIC) via ImageFile.load_prepare
**Root cause:** ImageFile.load_prepare() only reallocates native storage when _im is None, never checking that retained ImagingCore storage matches the current self.mode/self.size after a seek()/_open() mode transition.
**Steps:**
1. Craft a multi-frame image where frame 0 is mode `L` at W x H and frame 1 is same W x H but wider mode such as `CMYK` (MPO/JPEG path).
2. Open with `Image.open(source)` and iterate via `ImageSequence.Iterator(image)`.
3. Call `next(iterator).load()` for frame 0 to allocate narrow native `L` storage.
4. Call `next(iterator).load()` for frame 1; decoder writes wider-mode pixels into retained narrow buffer, causing native OOB write/over-read.
5. For PSD/DCX/MIC, trigger via their seek()/_open() paths that change mode/tile state without invalidating `_im`.
**How found:** Observed that load_prepare() reallocates only when `_im is None`; hypothesized that mode-changing seek()/_open() paths (PSD, MPO, DCX, MIC) leave stale native storage; used TIFF's guarded seek() as positive control to confirm the missing mode/size check.
**Exploitation:** Unauthenticated MPO upload to a Pillow-processing web service; single HTTP request, no user interaction. Frame 1 CMYK decode into stale L buffer corrupts native memory, achieving scoped command execution (harmless marker) 10/10 on Pillow 12.3.0 under Python 3.12.3 and 3.13.14; control with `_im=None` clean 0/10.
**Escalation:** From crash/DoS and OOB read/write to command execution in matching Python/Pillow runtime profiles via MPO/JPEG path.
**Insight:** Multi-frame decoders that mutate self._mode and decoder tiles on seek() are prime targets for stale-buffer bugs; audit every seek()/_open() path for invalidation of cached native storage, and use a format with an existing guard (TIFF) as a differential control.

## Proof of concept (copied exactly from the original report)

**MPO ImageSequence.Iterator frame.load() PoC**

```
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()
```

