---
id: CVE-2026-96639
aliases:
  - GHSA-v9pv-5gc8-w2m6
  - BIT-pillow-2026-96639
  - PYSEC-2026-4339
title: >-
  Pillow multi-frame loaders can reuse stale native image storage after mode
  transitions
summary: >-
  Pillow multi-frame loaders can reuse stale native image storage after mode
  transitions
severity: critical
cvss: 9.8
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'
vendor: pillow
product: pillow
ecosystem: pip
affected:
  - 'pillow >= 11.0.0, < 13.0.0'
patched:
  - pillow 13.0.0
published: '2026-10-22'
updated: '2026-10-29'
sourceUpdated: '2026-10-29T10:55:37.663049285Z'
source: OSV
sourceUrl: 'https://osv.dev/vulnerability/GHSA-v9pv-5gc8-w2m6'
references:
  - url: >-
      https://github.com/python-pillow/Pillow/security/advisories/GHSA-v9pv-5gc8-w2m6
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-96639'
  - url: 'https://github.com/python-pillow/Pillow/pull/10105'
  - url: >-
      https://github.com/python-pillow/Pillow/commit/78d97d994579aef81aafe123773d525f40f2522a
  - url: 'https://github.com/python-pillow/Pillow'
  - url: 'https://github.com/python-pillow/Pillow/releases/tag/13.0.0'
  - url: 'https://github.com/advisories/GHSA-v9pv-5gc8-w2m6'
tags:
  - osv
  - pip
  - ghsa
  - cve.org
epss: 0.00659
epssPercentile: 0.51436
cwe:
  - CWE-125
  - CWE-787
ingestedAt: '2026-10-21T22:18:33.756Z'
---

## Overview

### 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 <server-user>
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.

## Affected packages

- `pillow >= 11.0.0, < 13.0.0`

## Remediation

Upgrade to a patched release:

- `pillow 13.0.0`
