Pillow: Infinite loop in MIC n_frames when the embedded TIFF
CVE-2026-112390 · GHSA-22hg-r758-rrw9 · published 21 Oct 2026
How serious is it
- Severity: high, CVSS score 8.2 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-112390?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
Pillow(PyPI) >= 7.2.0, < 13.0.0
How to check
- Run
pip show Pillowin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
Pillowto 13.0.0 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
```
============================================================================
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 …
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/python-pillow/Pillow/security/advisories/GHSA-22hg-r758-rrw9
- nvd.nist.gov/vuln/detail/CVE-2026-112390
- github.com/python-pillow/Pillow/commit/26cb3ed061173b817b9ee4b6f507cb0e1e293218
- github.com/python-pillow/Pillow/releases/tag/13.0.0
- github.com/advisories/GHSA-22hg-r758-rrw9
- github.com/python-pillow/Pillow
Merged from GitHub Advisory Database, OSV. Last updated 25 Oct 2026. All vulnerabilities · RSS · JSON