Hoch

Pillow: Endlosschleife in MIC n_frames bei eingebettetem TIFF

  • Veröffentlicht: 21.10.2026
  • Aktualisiert: 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
============================================================================

Eine manipulierte Datei mit 5.6 KB führt dazu, dass Pillow in einer Endlosschleife bei 100 % CPU-Last verharrt, sobald eine Anwendung die Anzahl der Frames des Bildes abfragt. Dies betrifft ausschließlich Installationen, in denen das optionale Paket olefile vorhanden ist. Jede Anwendung, die ein nicht vertrauenswürdiges Bild öffnet und anschließend im.n_frames ausliest, kann dazu führen, dass ein Worker-Prozess unbegrenzt blockiert wird. Dies schließt die gängige Prüfung „Ist dieses Bild animiert?“ sowie das erneute Speichern mit save_all=True nach TIFF, PDF oder WebP ein. Der Aufruf kehrt nie zurück, löst keine Ausnahme aus und verbraucht nicht mehr Speicher, sodass nur ein externer Watchdog den Vorgang stoppen kann.

Bei der Datei handelt es sich um einen Microsoft Image Composer (MIC)-Container, den Pillow anhand seiner Magic Bytes ohne Dateiendung erkennt. Darin befindet sich ein TIFF-Stream mit drei verketteten Bildverzeichnissen. MicImageFile erbt von TiffImageFile und überschreibt seek() sowie tell(), sodass ein „Frame“ einem Stream im Container entspricht. Die Klasse erbt weiterhin TiffImageFile.n_frames (src/PIL/TiffImagePlugin.py:1212), das die Verzeichniskette des eingebetteten TIFFs mittels self._seek(self.tell() + 1) durchläuft, bis _n_frames gesetzt ist. Zwei Faktoren verhindern dabei das Voranschreiten:

  • MicImageFile.tell() (src/PIL/MicImagePlugin.py:86) gibt den konstanten Stream-Index zurück.
  • MicImageFile.seek() ruft TiffImageFile._open() auf, wodurch _n_frames auf None zurückgesetzt wird.

Infolgedessen analysiert die Schleife das zweite Verzeichnis dauerhaft neu, sobald der eingebettete Stream drei oder mehr Verzeichnisse enthält. Die Iteration über Image.open(), load(), thumbnail(), convert(), is_animated und ImageSequence hängt bei dieser Datei nicht. Die Regression wurde in 0b33a87b0d15 eingeführt (April 2020, erstmals veröffentlicht in 7.2.0), wodurch die eigene n_frames-Eigenschaft von MicImageFile entfernt wurde. Das beigefügte Reproduktionsskript erstellt die Datei im Code und prüft zwei Kontrollen, die wie erwartet 3 und 1 Frames zurückgeben; die schädliche Datei läuft noch, wenn sie nach 20 Sekunden abgebrochen wird.

Details

MicImageFile (registriert für Image.open, wenn olefile installiert ist) erbt von TiffImageFile und definiert einen „Frame“ als einen einzelnen OLE-*.ACI/Image-Stream neu, wobei seek() und tell() entsprechend überschrieben werden. Die Eigenschaft n_frames wird nicht überschrieben, weshalb die geerbte TIFF-Implementierung ausgeführt wird, die darauf angewiesen ist, dass tell() beim Laden von IFDs voranschreitet. MicImageFile.tell() gibt den OLE-Stream-Index zurück, der sich innerhalb von n_frames nie ändert, sodass die Schleife dasselbe IFD dauerhaft erneut parst.

Eine 5632-Byte große OLE2-Compound-Datei, deren img0.ACI/Image-Stream ein 351-Byte großes TIFF mit drei verketteten 1×1-IFDs ist, reicht aus. Image.open() ist erfolgreich (Format MIC, erkannt anhand der OLE-Magic, keine Erweiterung erforderlich); das Auswerten von im.n_frames kehrt dann nie zurück und belegt einen Kern zu 100 %. Ein nach 27 s erstellter Python-Stack lautet:

  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

und eine Instrumentierung von _seek zeigt, dass es in jeder Iteration mit frame=1, tell()==0, _frame_pos==[8, 122], _n_frames is None aufgerufen wird – identischer Zustand, die Schleife bricht also nicht ab und ist nicht bloß langsam. Dieselben 351 TIFF-Bytes direkt geöffnet ergeben n_frames == 3 in Millisekunden, und derselbe Container um ein TIFF mit 1 IFD ergibt n_frames == 1.

Die Schleife, 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)

und die Überschreibungen, die die Fortschrittsannahme verletzen, 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

Bei einem normalen TIFF lädt _seek(k) (TiffImagePlugin.py:1236) IFDs bis zu k, hängt jeden Offset an _frame_pos an, setzt _n_frames, wenn der Next-Zeiger eines IFD 0 (oder bereits gesehen) ist, und setzt das private __frame = k; TiffImageFile.tell() gibt __frame zurück, sodass self.tell() + 1 die Kette bis zum Ende durchläuft.

Bei einer MIC-Datei setzt Image.open → MicImageFile._open den Wert self._n_frames = len(self.images) in Zeile 69, ruft dann jedoch self.seek(0) → TiffImageFile._open auf (Zeile 82), was bedingungslos self._n_frames = None setzt (TiffImagePlugin.py:1203) und mittels _seek(0) in den eingebetteten Stream wechselt. Bei dem Stream mit 3 IFDs hat IFD 0 einen Next-Zeiger ungleich null, weshalb _n_frames nach dem Öffnen auf None bleibt. Anschließend läuft n_frames: current = self.tell() ist 0 (der OLE-Index); _seek(len(_frame_pos)) = _seek(1) lädt IFD 1, dessen Next-Zeiger ebenfalls ungleich null und noch nicht in _frame_pos vorhanden ist, weshalb _n_frames weiterhin None ist. Der Rumpf von while berechnet self.tell() + 1 = 0 + 1 und ruft _seek(1) erneut auf; len(_frame_pos) ist bereits 2, daher wird nichts Neues geladen – IFD 1 wird über tag_v2.load und _setup() erneut gelesen und der Zustand bleibt unverändert. Dies wiederholt sich endlos; IFD 2 wird nie erreicht. Zwei verkettete IFDs reichen nicht aus (der Next-Zeiger von IFD 1 von 0 setzt beim ersten _seek(1) den Wert _n_frames = 2); drei oder mehr reichen aus.

Tests/test_file_mic.py::test_n_frames funktioniert nur, weil der Stream von hopper.mic ein einzelnes IFD besitzt, sodass _seek(0) den Wert _n_frames = 1 setzt und die Schleife nie betreten wird; die Zuweisung von len(self.images) in Zeile 69 wird nur vom anfänglichen _seek_check(0) konsumiert und überschrieben, bevor ein Aufrufer n_frames lesen kann.

Ein Nebeneffekt derselben Diskrepanz: Bei einem Stream mit 2 IFDs gibt n_frames den Wert 2 zurück (TIFF-IFDs), während seek(1) self.images[1] (OLE-Streams) indiziert und IndexError auslöst – sichtbar bei save_all=True.

Auswirkungen

Jede Anwendung, die es Pillow erlaubt, eine hochgeladene Datei zu öffnen und danach im.n_frames auszulesen – die typische Methode, um festzustellen, ob ein Bild animiert ist (getattr(im, "n_frames", 1) > 1), sowie das, was hasattr(im, "n_frames")/getattr(im, "n_frames", 1) in Pillows eigenem TiffImagePlugin._save_all (:2333), PdfImagePlugin._save (:250) und WebPImagePlugin._save_all (:171) auswerten, wenn die Anwendung erneut mit save_all=True speichert –, kann durch einen Upload von 5.6 KB dazu gebracht werden, einen Worker unbegrenzt bei 100 % CPU drehen zu lassen. Die Datei benötigt keine bestimmte Dateiendung (OLE-Magic; FpxImagePlugin weist sie anhand der CLSID ab und MicImagePlugin akzeptiert sie), Standardlimits greifen nicht, der Speicher wächst nicht und es wird keine Ausnahme ausgelöst, sodass nur ein externer Watchdog den Prozess beendet. Voraussetzungen: olefile ist installiert (es ist eine optionale Abhängigkeit; ohne sie ist das MIC-Plugin nicht registriert) und die Anwendung greift auf n_frames oder einen Pfad mit save_all zu. Image.open() selbst, load(), thumbnail(), convert(), is_animated und die Iteration über ImageSequence hängen bei dieser Datei nicht. Dies ist dieselbe Klasse reiner Python-Nicht-Terminierung bei winzigen Dateien wie CVE-2021-28676 (FLI) und CVE-2021-28677 (EPS), und wie CVE-2019-19911 (FPX) hängt es von olefile ab; es unterscheidet sich dadurch, dass es nicht über den Standardpfad open/load erreicht wird. Die Regression wurde in 0b33a87b0d15 eingeführt („Replaced property methods for n_frames and is_animated with normal properties“, 2020-04-13), erstmals veröffentlicht in 7.2.0, wodurch die eigene n_frames-Eigenschaft von MicImageFile durch das Attribut _n_frames ersetzt wurde, das von TiffImageFile._open überschrieben wird.

Reproduktion

repro ist ein einzelnes Python-Skript, das gegen das installierte Pillow ausgeführt wird (mit olefile, wodurch das MIC-Plugin registriert wird). Es erstellt jede Eingabe im Code, schreibt jede in eine temporäre Datei namens x ohne Erweiterung (sodass Pillow das Format anhand der Magic Bytes identifiziert) und öffnet sie in einem untergeordneten Python-Prozess, der Image.open(path) ausführt und im.format sowie im.n_frames ausgibt.

Es baut zwei Elemente im Code auf:

  • tiff(n): ein n-seitiges TIFF aus Graustufen-Frames der Größe 4×4, geschrieben von Pillow selbst (save_all=True), sodass die IFDs exakt so verkettet sind, wie Pillows eigener Writer sie verkettet. Der letzte Next-IFD-Zeiger ist 0.
  • mic(stream): eine minimale OLE2-Compound-Datei mit einem Storage namens img0.ACI, der einen Stream namens Image enthält, was dem Layout entspricht, nach dem MicImageFile._open sucht. Sektor 0 ist die FAT und Sektor 1 ist das Verzeichnis. Der Stream beginnt bei Sektor 2 und wird auf 4096 Bytes aufgefüllt, sodass die Datei keine Mini-FAT benötigt. Das Ergebnis ist 5632 Bytes groß.

Es führt drei Eingaben aus, jeweils in einem eigenen Kindprozess mit einem harten Abbruch nach 20 Sekunden:

  1. Kontrolle: das reine 3-seitige TIFF. Erwartet: TIFF n_frames = 3.
  2. Kontrolle: ein MIC, das ein 1-seitiges TIFF kapselt. Erwartet: MIC n_frames = 1.
  3. Angriff: derselbe MIC-Container, der das 3-seitige TIFF kapselt.

Das Timeout wird erzwungen, indem das Kind beendet wird, anstatt über einen prozessinternen Alarm, da ein während des Parsens von ImageFileDirectory_v2.load() ausgelöstes TimeoutError von dessen except OSError (TiffImagePlugin.py:958) abgefangen würde und das IFD halb geparst hinterließe, anstatt die Schleife anzuhalten.

Das Skript gibt BUG aus, wenn beide Kontrollen zurückkehren und der Angriffs-Kindprozess nach 20 Sekunden noch läuft; andernfalls gibt es OK aus. Mit dem beigefügten Patch gibt der Angriffsfall MIC n_frames = 1 aus und das Skript gibt OK aus.

Vorgeschlagene Behebung

Der beigefügte Patch stellt die Frame-Anzahl auf OLE-Ebene wieder her, nachdem der eingebettete TIFF-Stream geöffnet wurde, sodass das geerbte n_frames seine IFD-Durchlaufschleife nie betritt und sich n_frames/seek() darüber einig sind, was ein Frame ist. In MicImageFile.seek, src/PIL/MicImagePlugin.py:82, direkt nach 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

Dies in seek() durchzuführen stellt die Invariante bei jedem Seek wieder her, nicht nur beim ersten. Die bestehenden Zuweisungen in den Zeilen 69–70 von _open bleiben erhalten: Das anfängliche seek(0) → _seek_check(0) liest n_frames, bevor TiffImageFile._open ausgeführt wurde, sodass sie weiterhin benötigt werden. Reine schreibgeschützte Eigenschaften n_frames/is_animated, wie vor 0b33a87b0, sind keine direkte Option mehr, da TiffImageFile._seek self.is_animated zuweist. Wurde der Patch auf einen sauberen Quellbaum angewendet und neu gebaut, liefert die Angriffsdatei sofort n_frames == 1 zurück, ein Stream mit 2 IFDs löst EOFError bei seek(1) statt IndexError aus, save_all=True nach TIFF und PDF wird abgeschlossen und Tests/test_file_mic.py sowie Tests/test_file_tiff.py verlaufen erfolgreich. Es ist kein Regressionstest enthalten, da dafür ein neues binäres MIC-Fixture in Tests/images erforderlich wäre.

Der Patch hat diese Prüfung bestanden, wurde jedoch darüber hinaus nicht als endgültige Form der Behebung geprüft. Zwei Punkte lässt er unberührt: Er fügt nicht den Reset von _im hinzu, den TiffImageFile.seek nach _seek durchführt (TiffImagePlugin.py:1229), was beim Wechsel zwischen OLE-Streams unterschiedlicher Größe oder Modi von Bedeutung ist; und TiffImageFile.n_frames schreitet weiterhin über das überschreibbare tell() statt über sein privates __frame voran, sodass eine Iteration über __frame jede andere Unterklasse schützen würde, die tell() neu definiert.

Der Patch

Wiederherstellung der OLE-Stream-Frame-Anzahl in MicImageFile.seek(), nachdem TiffImageFile._open() diese zurücksetzt, damit n_frames nicht endlos schleift.

Wendet sich an src/PIL/MicImagePlugin.py beim oben genannten Commit. Er hat die mechanische Prüfung bestanden – auf einen sauberen Quellbaum angewendet, neu gebaut, der Reproducer löste nicht mehr aus –, wurde jedoch von einem Reviewer nicht als die richtige Behebung bestätigt.

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:

Reproduktion

Reproducer — repro, 2,330 Bytes

Die letzte Zeile lautet BUG, wenn das Problem auftritt, und OK, wenn dies nicht der Fall ist. Bei einem erneuten Durchlauf auf einem sauberen Build dieses Commits wurde BUG ausgegeben.

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")

Was zu tun ist

Aktualisieren Sie Pillow auf Version 13.0.0 oder höher.

Betroffene Software

Klicken Sie auf eine Technologie, um alle zugehörigen Schwachstellen anzuzeigen.

PlattformPaketAnfällige VersionenBehoben in
Python (PyPI) pillow >= 7.2.0, < 13.0.0 13.0.0

Verweise

CVSS-Wert -
CVSS-Vektor
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
Schwäche (CWE)
CWE-835
Plattform
Python (PyPI)
Paket
pillow
Veröffentlicht
21.10.2026
Aktualisiert
22.10.2026 00:06

Quellen und Lizenzen · Von DockRay bearbeitet oder übersetzt; Rechte und Lizenzen der Quellen bleiben erhalten.

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

Schwachstellenhistorie: pillow

Alle veröffentlichten Schwachstellen dieses Pakets (Python (PyPI)), neueste zuerst. Zeigt, ob es ein einmaliger Ausrutscher ist oder ein Paket, das man im Auge behalten muss.

Alle (89) im Archiv
Einträge insgesamt
89
Erste → letzte
04.2014 10.2026
Ohne Fix
0
In Angriffen ausgenutzt
1

Verteilung nach Schweregrad

  • Kritisch 12
  • Hoch 57
  • Mittel 18
  • Niedrig 1
  • Nicht bewertet 1

Veröffentlichungen pro Jahr

  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

früher: 12

Schweregrad:
  1. Pillow: Endlosschleife in MIC n_frames bei eingebettetem TIFF dieser Eintrag
    Hoch CVE-2026-112390 >= 7.2.0, < 13.0.0 13.0.0
  2. Kritisch CVE-2026-112900 >= 2.6.0, < 13.0.0 13.0.0
  3. Kritisch CVE-2026-112403 < 13.0.0 13.0.0
  4. Hoch · 7.5 CVE-2026-59200 >= 5.1.0, < 12.3.0 12.3.0
  5. Hoch CVE-2026-42311 >= 10.3.0, < 12.2.0 12.2.0
Mehr anzeigen (20) 20 von 69 angezeigt

Erkennen Sie Schwachstellen in Ihrer Anwendung

Mit der DockRay-Integration und aktivierter Softwareinventarisierung prüft DockRay gemeldete Komponentenversionen auf bekannte Schwachstellen und benachrichtigt Sie über bestätigte Treffer. Die Abdeckung hängt von der unterstützten Technologie und den verfügbaren Meldungen ab.

Anwendung verbinden
DockRay Newsletter

Anwendungssicherheit in Ihrem Postfach

Wichtige Schwachstellen, konkrete Empfehlungen und praktische Tipps für Ihre Anwendungen. Wählen Sie Ihre Technologien und verfolgen Sie, was für Ihr Projekt relevant ist.

  • Eine tägliche Zusammenfassung neuer Schwachstellen, auch kritischer.
  • Benachrichtigungen über wichtige Updates.
  • Praktische Tipps zur Sicherheit und Überwachung von Anwendungen.

Leer lassen, um Meldungen zu allen Technologien zu erhalten.

Kostenlos anmelden. In jeder E-Mail mit einem Klick abmelden.