---
id: "CVE-2026-112390"
kind: "vulnerability"
aliases: ["CVE-2026-112390", "GHSA-22hg-r758-rrw9"]
title: "Pillow: Infinite loop in MIC n_frames when the embedded TIFF"
severity: "high"
cvss: 8.2
epss: 0.0029100000392645597
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 7.2.0, < 13.0.0", "ecosystem": "pypi"}, {"name": "python pillow", "fixed": ["13.0.0"], "affected": ">= 7.2.0, < 13.0.0", "ecosystem": "vendor"}]
ecosystems: ["pypi", "vendor"]
tags: ["cwe-835", "image-upload", "package", "pillow", "python", "self-hosted"]
not_affected_if: ["no-untrusted-image-input", "olefile-not-installed", "pillow-13.0.0-or-later"]
published_at: "2026-10-19T21:17:07+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T22:28:57+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-22hg-r758-rrw9", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112390", "first_seen_at": "2026-10-23T21:57:19+00:00"}]
---

# Pillow: Infinite loop in MIC n_frames when the embedded TIFF

Pillow's MIC image plugin can be sent a crafted 5.6 KB file whose embedded TIFF has three or more chained directories, causing an infinite loop at 100% CPU when the application reads im.n_frames. An attacker can tie up a worker indefinitely with an uploaded image, and only an external watchdog stops it.

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

## Affected

- pypi:Pillow affected >= 7.2.0, < 13.0.0 fixed 13.0.0
- vendor:python pillow affected >= 7.2.0, < 13.0.0 fixed 13.0.0

## How to check

Run pip show pillow and pip show olefile; the bug needs both installed and Pillow 7.2.0 up to but not including 13.0.0.
Search application code for n_frames, is_animated checks via getattr(im, "n_frames"), and save_all=True re-saves of untrusted uploads.

## Fix

Upgrade Pillow to 13.0.0 or later (pip install -U "pillow>=13.0.0").
If you cannot upgrade yet, uninstall olefile or stop opening untrusted images with Pillow's MIC plugin; the source does not give a documented workaround beyond that.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-22hg-r758-rrw9
- 2026-10-23T21:57:19+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112390

## From the sources

```
============================================================================
Infinite loop in MIC n_frames when the embedded TIFF has 3+ IFDs
Location: src/PIL/MicImagePlugin.py:86  in MicImageFile.tell()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

A crafted 5.6 KB image file makes Pillow loop forever at 100% CPU when an application asks how many frames the image has. This only affects installations that have the optional `olefile` package. Any application that opens an untrusted image and then reads `im.n_frames` can have one worker tied up indefinitely. That includes the common "is this image animated?" check and re-saving with `save_all=True` to TIFF, PDF or WebP. The call never returns, raises no exception, and does not grow memory, so only an external watchdog stops it.

The file is a Microsoft Image Composer (MIC) container, which Pillow recognises by its magic bytes with no extension needed. Inside it is a TIFF stream with three chained image directories. `MicImageFile` subclasses `TiffImageFile` and overrides `seek()` and `tell()` so that a "frame" means one stream in the container. It still inherits `TiffImageFile.n_frames` (`src/PIL/TiffImagePlugin.py:1212`), which walks the embedded TIFF's directory chain with `self._seek(self.tell() + 1)` until `_n_frames` is set. Two things stop that walk from making progress:

- `MicImageFile.tell()` (`src/PIL/MicImagePlugin.py:86`) returns the constant stream index.
- `MicImageFile.seek()` calls `TiffImageFile._open()`, which resets `_n_frames` to `None`.

As a result, the loop re-parses the second directory forever whenever the embedded stream has three or more directories. `Image.open()`, `load()`, `thumbnail()`, `convert()`, `is_animated` and `ImageSequence` iteration do not hang on this file. The regression was introduced in 0b33a87b0d15 (April 2020, first released in 7.2.0), which removed `MicImageFile`'s own `n_frames` property. The attached reproducer builds the file in code and checks two controls, which return 3 and 1 frames as expected; the malicious file is still running when it is killed after 20 seconds.

## Details

`MicImageFile` (registered for `Image.open` whenever `olefile` is installed) subclasses `TiffImageFile` and redefines a "frame" as one OLE `*.ACI/Image` stream, overriding `seek()` and `tell()` accordingly. It does not override the `n_frames` property, so the inherited TIFF implementation runs, and that implementation relies on `tell()` advancing as IFDs are loaded. `MicImageFile.tell()` returns the OLE stream index, which never changes inside `n_frames`, so the loop re-parses the same IFD forever.

A 5632-byte OLE2 compound file whose `img0.ACI/Image` stream is a 351-byte TIFF with three chained 1×1 IFDs is enough. `Image.open()` on it succeeds (format `MIC`, detected by the OLE magic, no extension needed); evaluating `im.n_frames` then never returns and keeps one core at 100 %. A Python stack taken 27 s in is

```
  File "src/PIL/TiffImagePlugin.py", line 1622 in _setup
  File "src/PIL/TiffImagePlugin.py", line 1271 in _seek
  File "src/PIL/TiffImagePlugin.py", line 1219 in n_frames
```

and instrumenting `_seek` shows it being called with `frame=1`, `tell()==0`, `_frame_pos==[8, 122]`, `_n_frames is None` on every iteration — identical state, so the loop is non-terminating, not merely slow. The same 351 TIFF bytes opened directly give `n_frames == 3` in milliseconds, and the same container around a 1-IFD TIFF gives `n_frames == 1`.

The loop, `src/PIL/TiffImagePlugin.py:1212`:

```python
    @property
    def n_frames(self) -> int:
        current_n_frames = self._n_frames
        if current_n_frames is None:
            current = self.tell()
            self._seek(len(self._frame_pos))
            while self._n_frames is None:
                self._seek(self.tell() + 1)      # 1219
            self.seek(current)
```

and the overrides that break its progress assumption, `src/PIL/MicImagePlugin.py:76`:

```python
    def seek(self, frame: int) -> None:
        …
        self.fp = self.ole.openstream(filename)
        TiffImagePlugin.TiffImageFile._open(self)   # 82: resets _n_frames to None
        self.frame = frame

    def tell(self) -> int:
        return self.frame                            # 87: OLE stream index, not TIFF __frame
```

For a plain TIFF, `_seek(k)` (`TiffImagePlugin.py:1236`) loads IFDs up to `k`, appending each offset to `_frame_pos`, setting `_n_frames` when an IFD's next-pointer is 0 (or already seen), and setting the private `__frame = k`; `TiffImageFile.tell()` returns `__frame`, so `self.tell() + 1` walks the chain to its end.

For a MIC file, `Image.open` → `MicImageFile._open` sets `self._n_frames = len(self.images)` at line 69, but then calls `self.seek(0)` → `TiffImageFile._open` (line 82), which unconditionally sets `self._n_frames = None` (`TiffImagePlugin.py:1203`) and `_seek(0)`s into the embedded stream. With the 3-IFD stream, IFD 0 has a non-zero next pointer, so `_n_frames` stays `None` after open. `n_frames` then runs: `current = self.tell()` is `0` (the OLE index); `_seek(len(_frame_pos))` = `_seek(1)` loads IFD 1, whose next pointer is also non-zero and not yet in `_frame_pos`, so `_n_frames` is still `None`. The `while` body computes `self.tell() + 1` = `0 + 1` and calls `_seek(1)` again; `len(_frame_pos)` is already 2, so nothing new is loaded — IFD 1 is re-read via `tag_v2.load` and `_setup()` and the state is unchanged. This repeats indefinitely; IFD 2 is never reached. Two chained IFDs are not enough (IFD 1's next pointer of 0 sets `_n_frames = 2` on the first `_seek(1)`); three or more are.

`Tests/test_file_mic.py::test_n_frames` passes only because `hopper.mic`'s stream has a single IFD, so `_seek(0)` sets `_n_frames = 1` and the loop is never entered; the `len(self.images)` assignment at line 69 is only consumed by the initial `_seek_check(0)` and is overwritten before any caller can read `n_frames`.

A side effect of the same mismatch: with a 2-IFD stream `n_frames` returns 2 (TIFF IFDs) while `seek(1)` indexes `self.images[1]` (OLE streams) and raises `IndexError` — visible with `save_all=True`.

## Impact

Any application that lets Pillow open an uploaded file and then reads `im.n_frames` — the usual way to decide whether an image is animated (`getattr(im, "n_frames", 1) > 1`), and also what `hasattr(im, "n_frames")`/`getattr(im, "n_frames", 1)` in Pillow's own `TiffImagePlugin._save_all` (`:2333`), `PdfImagePlugin._save` (`:250`) and `WebPImagePlugin._save_all` (`:171`) evaluate when the app re-saves with `save_all=True` — can be made to spin one worker at 100 % CPU indefinitely with a 5.6 KB upload. The file needs no particular extension (OLE magic; `FpxImagePlugin` rejects it on CLSID and `MicImagePlugin` accepts it), default limits do not intervene, memory does not grow, and no exception is raised, so only an external watchdog ends it. Preconditions: `olefile` installed (it is an optional dependency; without it the MIC plugin is not registered) and the application touching `n_frames` or a `save_all` path. `Image.open()` itself, `load()`, `thumbnail()`, `convert()`, `is_animated` and `ImageSequence` iteration do not hang on this file. This is the same class of tiny-file, pure-Python non-termination as CVE-2021-28676 (FLI) and CVE-2021-28677 (EPS), and like CVE-2019-19911 (FPX) it depends on `olefile`; it differs in not being reached by the default open/load path. The regression was introduced in `0b33a87b0d15` ("Replaced property methods for n_frames and is_animated with normal properties", 2020-04-13), first released in 7.2.0, which replaced `MicImageFile`'s own `n_frames` property with the `_n_frames` attribute that `TiffImageFile._open` overwrites.

## Reproducing

`repro` is a single Python script that runs against the installed Pillow (with `olefile`, which is what registers the MIC plugin). It builds every input in cod

---

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