GHSA-22hg-r758-rrw9: Pip/Pillow vulnerability

Published Oct 21, 2026
·
Updated

============================================================================ Infinite loop in MIC nframes 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.nframes can have one worker tied up indefinitely. That includes the common "is this image animated?" check and re-saving with saveall=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.nframes (src/PIL/TiffImagePlugin.py:1212), which walks the embedded TIFF's directory chain with self.seek(self.tell() + 1) until nframes 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 nframes 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(), isanimated 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 nframes 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 nframes 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 nframes, 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.nframes 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 nframes

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

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

python @property def nframes(self) -> int: currentnframes = self.nframes if currentnframes is None: current = self.tell() self.seek(len(self.framepos)) while self.nframes 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 nframes 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 framepos, setting nframes 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.nframes = len(self.images) at line 69, but then calls self.seek(0) → TiffImageFile.open (line 82), which unconditionally sets self.nframes = 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 nframes stays None after open. nframes then runs: current = self.tell() is 0 (the OLE index); seek(len(framepos)) = seek(1) loads IFD 1, whose next pointer is also non-zero and not yet in framepos, so nframes is still None. The while body computes self.tell() + 1 = 0 + 1 and calls seek(1) again; len(framepos) is already 2, so nothing new is loaded — IFD 1 is re-read via tagv2.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 nframes = 2 on the first seek(1)); three or more are.

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

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

Impact

Any application that lets Pillow open an uploaded file and then reads im.nframes — the usual way to decide whether an image is animated (getattr(im, "nframes", 1) > 1), and also what hasattr(im, "nframes")/getattr(im, "nframes", 1) in Pillow's own TiffImagePlugin.saveall (:2333), PdfImagePlugin.save (:250) and WebPImagePlugin.saveall (:171) evaluate when the app re-saves with saveall=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 nframes or a saveall path. Image.open() itself, load(), thumbnail(), convert(), isanimated 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 nframes and isanimated with normal properties", 2020-04-13), first released in 7.2.0, which replaced MicImageFile's own nframes property with the nframes 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.nframes.

It builds two things in code:

- tiff(n): an n-page TIFF of 4×4 greyscale frames written by Pillow itself (saveall=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 nframes = 3. 2. control: a MIC wrapping a 1-page TIFF. Expected MIC nframes = 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 ImageFileDirectoryv2.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 nframes = 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 nframes never enters its IFD-walking loop and nframes/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.nframes = len(self.images) + self.isanimated = self.nframes > 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) → seekcheck(0) reads nframes before TiffImageFile.open has run, so they are still needed. Plain read-only nframes/isanimated properties, as before 0b33a87b0, are no longer a drop-in option because TiffImageFile.seek assigns self.isanimated. With the patch applied to a clean tree and rebuilt, the attack file returns nframes == 1 immediately, a 2-IFD stream raises EOFError on seek(1) instead of IndexError, saveall=True to TIFF and PDF completes, and Tests/testfilemic.py and Tests/testfiletiff.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.nframes 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 nframes 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.nframes = len(self.images) + self.isanimated = self.nframes > 1 + self.frame = frame def tell(self) -> int:

The reproducer

<details> <summary>Reproducer — <code>repro</code>, 2,330 bytes (click to open)</summary>

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.nframes, 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.nframes 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", saveall=True, appendimages=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, 'nframes =', im.nframes)" 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], captureoutput=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") </details>

---

Affected Software

1 affected componentFixes available
pip/Pillow>=7.2.0<13.0.0
13.0.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/Pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0
  2. Upgrade

    Upgrade Pillow to a version that resolves this vulnerability.

    Patch a707c6b21c313880d0771e54965d142dd42358b6

Event History

Oct 21, 2026
Advisory Published
via GitHub·08:39 PM
Data Sourced
via GitHub·08:39 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are exposed?

Only Pillow installations that have the optional olefile package are affected. An application must open an untrusted MIC image and read im.n_frames, directly or through operations such as an animation check or saving with save_all=True to TIFF, PDF, or WebP.

2

What does an attacker need to provide?

The attacker needs to supply a crafted Microsoft Image Composer (MIC) container containing a TIFF stream with three chained image directories. The file does not need a MIC filename extension because Pillow identifies it from its magic bytes.

3

What is the operational impact of exploitation?

The affected call loops indefinitely at 100% CPU, without returning or raising an exception, tying up one worker. Memory does not grow, so an external watchdog is the described mechanism for stopping the stuck operation.

4

How can I determine whether my application reaches the vulnerable path?

Check whether the Pillow environment includes olefile and whether application processing of untrusted images reads im.n_frames. Also review image handling that detects animation or re-saves images with save_all=True to TIFF, PDF, or WebP.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203