---
id: "CVE-2026-112401"
kind: "vulnerability"
aliases: ["CVE-2026-112401", "GHSA-74r2-7ff9-mjj2"]
title: "Pillow: Quadratic GIF comment concatenation allows DoS in Image.open()"
severity: "medium"
cvss: 6.9
epss: 0.003710000077262521
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 5.4.0, < 13.0.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-407", "media-upload", "package", "pillow", "python", "self-hosted", "thumbnailer"]
not_affected_if: ["no-user-uploaded-images", "pillow-13.0.0-or-later", "pillow-below-5.4.0", "static-export"]
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-74r2-7ff9-mjj2", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112401", "first_seen_at": "2026-10-23T21:57:19+00:00"}]
---

# Pillow: Quadratic GIF comment concatenation allows DoS in Image.open()

Pillow's GIF parser builds comment data by repeated bytes concatenation, so opening a crafted GIF with Image.open() takes time that grows with the square of the file size. An attacker who can upload an image to a service that calls Pillow can tie up a worker process at full CPU for minutes using a small file.

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

## Affected

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

## How to check

pip show pillow or python -c "import PIL; print(PIL.__version__)" and confirm the version is 5.4.0 or later and below 13.0.0
Grep the lockfile or requirements for pillow to find every service that pins it
Check whether those services call Image.open() on user-uploaded files without a size or time limit

## Fix

Upgrade Pillow to 13.0.0 or later
If an upgrade is not possible, cap upload size for GIF files in the services that open uploads

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-74r2-7ff9-mjj2
- 2026-10-23T21:57:19+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112401

## From the sources

```
============================================================================
Quadratic GIF comment concatenation allows DoS in Image.open()
Location: src/PIL/GifImagePlugin.py:266  in GifImageFile._seek()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

Opening a crafted GIF file with `Image.open()` takes time that grows with the square of the file's size, so anyone who can submit an image to a service that uses Pillow can keep one worker busy at full processor load for a long time with a small file. A 2 MB file takes about 5 seconds to open, a 4 MB file about 35 seconds, and an 8 MB file about 3 minutes. An ordinary GIF of the same size opens in well under a second. The cost is paid inside `Image.open()` itself, with default settings, before the application has seen the image's size or format.

The bug is in `GifImageFile._seek()` in `src/PIL/GifImagePlugin.py`, which builds `info["comment"]` by appending to an immutable `bytes` object with no size limit. It appends once per 255-byte sub-block (`comment += block`, line 261) and once per comment extension (`info["comment"] += b"\n" + comment`, line 266). Each append copies everything collected so far. A file made of many tiny comment extensions forces a full re-copy for each one. Because `_open()` ends with `self._seek(0)`, any code that only opens an uploaded file to identify it, read its dimensions, or make a thumbnail pays the full cost. The decompression-bomb check does not help, since the image is 1×1. Memory use stays linear, so the effect is processor exhaustion and latency, bounded by whatever upload size the service allows (about 1 second at 1 MB). This is the same kind of issue as the EPS open-phase slowdown fixed as CVE-2021-28677, here in the GIF comment handling. The quadratic pattern was introduced in commit b2b737b68b06 ("Allow arbitrary number of comment extension subblocks", November 2018) and has shipped since Pillow 5.4.0. The attached script builds 2 MB and 4 MB test files and compares each against a byte-identical control whose extensions are labelled as plain text instead of comment. The 4 MB comment file took about 34 seconds to open, against 0.26 seconds for its control. A patch that collects the pieces in a list and joins them once is also attached.

## Details

`GifImageFile._seek()` collects GIF comment extensions into `info["comment"]` using `bytes` concatenation, once per 255-byte sub-block and once per comment extension, with no limit on the total. `bytes` is immutable, so every `+=` allocates a new object and copies everything accumulated so far; the work done by `Image.open()` is therefore quadratic in the amount of comment data in frame 0.

A 1×1 GIF89a whose body is nothing but the 5-byte comment extension `21 FE 01 41 00` repeated makes this directly observable. `Image.open(BytesIO(data))` on such a file, stock build at this commit, 2-CPU machine:

| file size | comment extensions (`21 FE …`) | same bytes labelled plain-text extension (`21 01 …`) |
|---|---|---|
| 1 MB | 1.2 s | 0.09 s |
| 2 MB | 4.9 s | 0.13 s |
| 4 MB | 35 s | 0.26 s |
| 8 MB | 183 s | — |

The control column is the identical byte stream with the extension label changed from `0xFE` to `0x01`; it goes through the same loop and the same `self.data()` reads but is discarded instead of concatenated, and scales linearly. No `load()` is needed and no warning or exception is raised; `im.info["comment"]` simply ends up holding the multi-megabyte blob.

The code, `src/PIL/GifImagePlugin.py:229-281` (trimmed):

```python
            elif s == b"!":
                s = self.fp.read(1)
                block = self.data()
                …
                elif s[0] == 254:
                    #
                    # comment extension
                    #
                    comment = b""

                    # Read this comment block
                    while block:
                        comment += block                      # :261  one copy per sub-block
                        block = self.data()

                    if "comment" in info:
                        # If multiple comment blocks in frame, separate with \n
                        info["comment"] += b"\n" + comment    # :266  one copy per extension
                    else:
                        info["comment"] = comment
                    s = b""
                    continue
                …
                while self.data():                            # every other extension: read and drop
                    pass
```

`self.data()` (`:97-102`) reads one length byte and then that many bytes, so each call is cheap and bounded; the cost is entirely in the two `+=` lines. For a single comment extension of N bytes delivered as N/255 sub-blocks, line 261 copies about N²/510 bytes in total (4 MB → 2.3 s, 8 MB → 9.5 s, 16 MB → 38 s measured). Splitting the same data into many small comment extensions is worse, because line 266 re-copies the whole of `info["comment"]` once per extension: with 1-byte extensions a file of F bytes causes roughly F²/25 bytes of copying, which is the table above (growth ×4–7 per doubling once the buffer outgrows the CPU caches).

This runs during `Image.open()`: `_open()` ends with `self._seek(0)` (`:135`), so any application that merely opens an uploaded file to identify it, read its size, or thumbnail it pays the full cost before it has made any decision about the image. `Image._decompression_bomb_check` is not involved (the image is 1×1), and unlike PNG text chunks (`PngImagePlugin.MAX_TEXT_CHUNK` / `MAX_TEXT_MEMORY`) there is no cap on GIF comment size. Later frames repeat the same loop for their own comments via `seek()` / `n_frames`.

With both `+=` sites replaced by list-append and a single `b"".join()` (as in the attached patch), the same 16 MB single-extension file opens in 0.03 s, confirming the concatenation is the whole cost.

## Impact

Anyone who can get a GIF to a service that calls `PIL.Image.open()` on it — avatar/media uploads, thumbnailers, format sniffing, anything using `ImageOps`/`thumbnail()` on user files — can pin one worker process at 100 % CPU for about 5 s with a 2 MB file, 35 s with a 4 MB file and 3 minutes with an 8 MB file, under default settings, with the time growing quadratically in the permitted upload size. A benign GIF of the same size opens in well under a second, so request-cost assumptions based on file size do not hold. Memory use is only linear (the comment is retained in `im.info["comment"]`), so the effect is CPU/latency denial of service of the worker pool rather than memory exhaustion; a handful of concurrent uploads is enough to saturate a typical pool. Upload size limits bound it (≈1 s at 1 MB).

This is the same shape as CVE-2021-28677 ("EPS DOS on _open", `docs/releasenotes/8.2.0.rst`), where a quadratic method of accumulating data in the open phase, before the image was accepted, was treated as a denial-of-service vulnerability; here the format is GIF and the accumulator is the comment extension.

The quadratic accumulation was introduced in `b2b737b68b06` ("Allow arbitrary number of comment extension subblocks", 2018-11-27), first released in Pillow 5.4.0.

## Reproducing

`repro` is a single Python script that uses the installed Pillow. Run it with no arguments. It needs no files, no network and no rebuild.

It builds two GIFs of the same length, at 2 MB and at 4 MB. Each is the 13-byte GIF89a header for a 1×1 screen, followed by `size/5` copies of the 5-byte extension `21 <label> 01 41 00`, then a minimal 1×1 image and the trailer:

- **comments**: label `0xFE`. Each extension is a 1-byte comment, so `_seek()` appends each one to `info["comment"]` with `+=` (`GifImagePlugin.py:266`).
- **control**: label `0x01` (plain-text extension). The bytes are the same apart from the label. `_seek()` reads them through the same loop and the same `self.data()` calls, then throws them away.

---

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