Live › vulnerability › CVE-2026-112390
Go to dashboardPillow'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 written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-23 19:11 UTC, 3h before NVD. All times on this page are UTC.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| Pillow | pypi | >= 7.2.0, < 13.0.0 | 13.0.0 |
| python pillow | vendor | >= 7.2.0, < 13.0.0 | 13.0.0 |
pip show pillow and pip show olefile; the bug needs both installed and Pillow 7.2.0 up to but not including 13.0.013.0.0 or later (pip install -U "pillow>=13.0.0")Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
============================================================================
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.
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:
@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:
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 = and calls again; is already 2, so nothing new is loaded — IFD 1 is re-read via and 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 on the first ); 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.
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, , , , and 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 ; it differs in not being reached by the default open/load path. The regression was introduced in ("Replaced property methods for n_frames and is_animated with normal properties", 2020-04-13), first released in 7.2.0, which replaced 's own property with the attribute that overwrites.
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
This page as .md · .json · in your agent: get_signal("CVE-2026-112390")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.
self.tell() + 10 + 1_seek(1)len(_frame_pos)tag_v2.load_setup()_n_frames = 2_seek(1)load()thumbnail()convert()is_animatedImageSequenceolefile0b33a87b0d15MicImageFilen_frames_n_framesTiffImageFile._open