---
id: "CVE-2026-112399"
kind: "vulnerability"
aliases: ["CVE-2026-112399", "GHSA-5xm2-6676-qcp9"]
title: "Pillow: APNG background disposal allocates full canvas before decompression bomb check"
severity: "high"
cvss: 8.7
epss: 0.0029100000392645597
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 8.1.0, < 13.0.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-789", "file-upload", "image-processing", "package", "pillow", "python", "web-apps"]
not_affected_if: ["no-untrusted-image-input", "pillow-13.0.0-or-later", "pillow-below-8.1.0"]
published_at: "2026-10-19T22:17:07+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:19+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-5xm2-6676-qcp9", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112399", "first_seen_at": "2026-10-23T21:57:19+00:00"}]
---

# Pillow: APNG background disposal allocates full canvas before decompression bomb check

Pillow's PNG plugin allocates and zero-fills a full canvas sized by the file's declared width and height before Pillow's decompression-bomb check runs, so a roughly 126-byte animated PNG can force a multi-gigabyte memory spike. An attacker who can get an application to call Image.open() on an uploaded file can exhaust memory or get the worker process OOM-killed.

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

## Affected

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

## How to check

Run: python -c "import PIL; print(PIL.__version__)" or pip show pillow
Affected if the version is >= 8.1.0 and < 13.0.0.
Find untrusted image uploads that reach Image.open() with default settings.

## Fix

Upgrade Pillow to 13.0.0 or later (pip install -U "pillow>=13.0.0").

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-5xm2-6676-qcp9
- 2026-10-23T21:57:19+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112399

## From the sources

```
============================================================================
APNG background disposal allocates full canvas before decompression bomb check
Location: src/PIL/PngImagePlugin.py:1000  in PngImageFile._seek()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

A 126-byte animated PNG file makes Pillow's `Image.open()` allocate and fill memory sized by the image dimensions the file claims, before Pillow's built-in check for oversized images runs. A file claiming 22000×22000 pixels costs about 1.9 GB. Anyone who can get an application to open an image they supply, with default settings and no further calls such as `load()`, can cause a multi-gigabyte memory spike per file. If the claimed size exceeds the machine's free memory, the operating system kills the process outright.

The bug is in `PngImageFile._seek()` in `src/PIL/PngImagePlugin.py` (around line 1000). For any APNG, `_open()` calls `self._seek(0)`. When the first `fcTL` chunk requests `OP_BACKGROUND` disposal, `_seek()` calls `Image.core.fill(self.mode, self.size)` with the full IHDR size, which allocates and zero-fills width×height×4 bytes. `OP_PREVIOUS` takes the same path, because frame 0 rewrites it to `OP_BACKGROUND`. This all happens inside the plugin factory, so `Image.open()`'s `_decompression_bomb_check(im.size)` only runs after the memory has been touched. A 1×1 frame rectangle passes the only checks on the way.

Below available memory, the allocation is freed when `DecompressionBombError` propagates, so sustained pressure needs repeated or concurrent uploads. A 45000×45000 header gets the process killed by the out-of-memory killer on an 8 GB host. The attached reproducer opens two such files that differ in one byte (`dispose_op` 0 vs 1). Both end in `DecompressionBombError`, but peak resident memory is 17 MB for the first and 1864 MB for the second. The eager fill was introduced in 5e4e0fa6ee67 ("Use disposal settings from previous frame", December 2020) and first shipped in 8.1.0. Two candidate patches with a regression test are attached.

## Details

For an APNG whose first `fcTL` chunk requests `OP_BACKGROUND` disposal, `PngImageFile._open()` creates and zero-fills a core image of the full IHDR-declared size. This happens inside the plugin factory, i.e. before `Image.open()` gets to `_decompression_bomb_check(im.size)`, so the check that is meant to reject oversized headers without allocating anything runs only after the allocation has been made and touched.

The following 126-byte file (all chunk lengths and CRCs valid) makes `Image.open(io.BytesIO(data))`, with default `MAX_IMAGE_PIXELS` and no `load()`, reach 1.86 GB resident (`ru_maxrss`) and then raise `DecompressionBombError`. The byte-identical file with `dispose_op=0` raises the same error at 17 MB.

```
IHDR  width=22000 height=22000 bit_depth=8 colour_type=6 (RGBA)
acTL  num_frames=2 num_plays=0
fcTL  seq=0 w=1 h=1 x=0 y=0 delay=1/10 dispose_op=1 (OP_BACKGROUND) blend_op=0
IDAT  zlib(00 00 00 00 00)                      # one 1x1 RGBA scanline
IEND
```

With `width=height=45000` (8.1 GB canvas) the same call attempts the full allocation: under a 4 GiB `RLIMIT_AS` it fails with `MemoryError` from the allocator instead of `DecompressionBombError`; without a cap, on an 8 GB host with no swap, the process is OOM-killed.

### Code path

`Image.open()` runs the bomb check only after the plugin's constructor (and therefore `_open()`) has returned:

```python
# src/PIL/Image.py:3699-3701
                    im = factory(fp, filename)
                    _decompression_bomb_check(im.size)
                    return im
```

For a plain PNG that ordering is fine: `PngImageFile._open()` only parses chunks up to the first `IDAT` and records `self._size` from `IHDR`; nothing is allocated from it. For an APNG (`acTL` seen, so `im_n_frames` is set) `_open()` additionally positions on frame 0:

```python
# src/PIL/PngImagePlugin.py:845-853
        if self.png.im_n_frames is not None:
            …
            self._seek(0)
```

`_seek(0)` copies `disposal` and `bbox` from the `fcTL` chunk into `self.dispose_op` / `self.dispose_extent` and then prepares the disposal image for the *next* frame eagerly, using the full image size:

```python
# src/PIL/PngImagePlugin.py:991-1001
        if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:
            self.dispose_op = Disposal.OP_BACKGROUND

        self.dispose = None
        if self.dispose_op == Disposal.OP_PREVIOUS:
            …
        elif self.dispose_op == Disposal.OP_BACKGROUND:
            self.dispose = Image.core.fill(self.mode, self.size)
            self.dispose = self._crop(self.dispose, self.dispose_extent)
```

`Image.core.fill` is `_fill()` in `src/_imaging.c:711`, which does `ImagingNewDirty(mode, xsize, ysize)` followed by `ImagingFill()`; `ImagingFill` (`src/libImaging/Fill.c:23`) writes every row, so the whole `width*height*4` bytes become resident rather than merely reserved. `self.size` here is the IHDR size (22000×22000), not the 1×1 frame rectangle. The `_crop` on the next line does call `_decompression_bomb_check`, but on the `fcTL` rectangle (1×1) and only after the fill has completed. Lines 991-992 rewrite `OP_PREVIOUS` to `OP_BACKGROUND` on frame 0, so `dispose_op=2` takes the same path (verified: identical 1.86 GB peak). When the first `fcTL` rectangle is large, the `_crop` copy is a second extent-sized buffer on top of the full-canvas fill, so the transient peak can reach about twice the canvas: a 13000×13000 full-frame `fcTL` (below the error threshold, so `Image.open()` succeeds with only a `DecompressionBombWarning`) peaks at 1.31 GB for a 0.66 GB canvas.

The only validation `chunk_fcTL` applies (`src/PIL/PngImagePlugin.py:740-745`) is that the frame rectangle fits inside the IHDR size, which a 1×1 frame always does; IHDR width/height themselves are unconstrained 32-bit values at this point. Control then returns to `_open_core`, `_decompression_bomb_check((22000, 22000))` raises, and the 1.9 GB core image is released when the half-constructed `PngImageFile` is discarded.

The eager full-size fill in `_seek()` was introduced in `5e4e0fa6ee67` ("Use disposal settings from previous frame", 2020-12-24) and first shipped in 8.1.0; before it, the `OP_BACKGROUND` fill lived in `load_end()` and could not be reached from `Image.open()`.

## Impact

Any application that calls `Image.open()` on untrusted bytes with default settings — the configuration in which the decompression-bomb check is supposed to make that call cheap and safe — can be made to allocate and write an attacker-chosen amount of memory from a ~126-byte PNG upload, before any error is reported and without `load()`, frame iteration, or `LOAD_TRUNCATED_IMAGES`. The amount is set directly by the IHDR width/height (4 bytes per pixel for colour type 6): values below the host's free memory cause a transient multi-GB spike per request (≈15,000,000× amplification at 22000×22000) that is freed when the exception propagates, so concurrent or repeated uploads are needed to sustain pressure; values above it get the worker process OOM-killed outright. PNG is accepted by essentially every upload pipeline, and the file passes `_accept` and chunk CRC checks as a well-formed APNG.

This is the same class as the recent bomb-check additions for WMF (load at a different DPI), FITS, GIF and FontFile — an allocation sized from file-declared numbers on a path `_decompression_bomb_check` does not cover — but here the uncovered path is `Image.open()` itself rather than a later `load()`/`seek()`.

## Reproducing

`repro` is a single Python script (no build step, no companion files). It builds two PNGs in memory, opens each one with plain `Image.open(io.BytesIO(data))` in a fresh child interpreter using default settings and no `load()`, and has each child report the exception it got and its own peak RSS (`ru_m

---

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