---
id: "CVE-2026-96639"
kind: "vulnerability"
aliases: ["CVE-2026-96639", "GHSA-v9pv-5gc8-w2m6"]
title: "Pillow multi-frame loaders can reuse stale native image storage after mode transitions"
severity: "critical"
cvss: 9.800000190734863
epss: 0.0056500001810491085
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 11.0.0, < 13.0.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-125", "cwe-787", "image-processing", "package", "pillow", "python", "self-hosted", "web-upload"]
not_affected_if: ["no-untrusted-image-input", "pillow-13.0.0-or-later", "pillow-below-11.0.0"]
published_at: "2026-10-21T21:20:32+00:00"
first_seen_at: "2026-10-23T19:11:31+00:00"
updated_at: "2026-10-23T21:57:29+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-v9pv-5gc8-w2m6", "first_seen_at": "2026-10-23T19:11:31+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96639", "first_seen_at": "2026-10-23T21:57:29+00:00"}]
---

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

Pillow's multi-frame image loaders (PSD, MPO/JPEG, DCX, MIC) can reuse native image memory sized for an earlier frame's narrower mode when a later frame of the same size uses a wider mode. An attacker-supplied image file can trigger out-of-bounds memory writes that crash the process, and the reporter claims command execution through the MPO/JPEG path in a test environment.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:Pillow affected >= 11.0.0, < 13.0.0 fixed 13.0.0

## How to check

Run python -c "import PIL; print(PIL.__version__)" in each service's environment and check whether it is >= 11.0.0 and < 13.0.0.
Run pip show pillow or check the lockfile for the pinned Pillow version.
Identify services that open untrusted uploaded images with Pillow, especially multi-frame formats.

## Fix

Upgrade Pillow to 13.0.0 or later.
Until then, avoid processing untrusted MPO, PSD, DCX or MIC files, or run that processing in an isolated sandbox.

## Timeline

- 2026-10-23T19:11:31+00:00 ghsa https://github.com/advisories/GHSA-v9pv-5gc8-w2m6
- 2026-10-23T21:57:29+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-96639

## From the sources

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

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
