Wysoka

Pillow: Nieskończona pętla w MIC n_frames przy osadzonym TIFF

  • Ujawniono: 21.10.2026
  • Aktualizacja: 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
============================================================================

Spreparowany plik graficzny o rozmiarze 5.6 KB powoduje, że Pillow wpada w nieskończoną pętlę przy 100% obciążenia procesora, gdy aplikacja odpytuje o liczbę klatek obrazu. Dotyczy to wyłącznie instalacji posiadających opcjonalny pakiet olefile. Każda aplikacja, która otwiera niezaufany obraz, a następnie odczytuje im.n_frames, może mieć bezterminowo zablokowany proces roboczy. Obejmuje to powszechne sprawdzenie „czy ten obraz jest animowany?” oraz ponowne zapisywanie za pomocą save_all=True do formatów TIFF, PDF lub WebP. Wywołanie nigdy nie wraca, nie zgłasza żadnego wyjątku i nie zwiększa zużycia pamięci, więc zatrzymać je może jedynie zewnętrzny mechanizm nadzorujący (watchdog).

Plik to kontener Microsoft Image Composer (MIC), który Pillow rozpoznaje po bajtach sygnatury bez potrzeby posiadania rozszerzenia. Wewnątrz znajduje się strumień TIFF z trzema powiązanymi katalogami obrazów. MicImageFile dziedziczy po TiffImageFile i nadpisuje seek() oraz tell(), przez co „klatka” oznacza pojedynczy strumień w kontenerze. Nadal jednak dziedziczy TiffImageFile.n_frames (src/PIL/TiffImagePlugin.py:1212), który przechodzi łańcuch katalogów osadzonego TIFF za pomocą self._seek(self.tell() + 1), dopóki nie zostanie ustawione _n_frames. Dwa czynniki uniemożliwiają postęp tego przejścia:

  • MicImageFile.tell() (src/PIL/MicImagePlugin.py:86) zwraca stały indeks strumienia.
  • MicImageFile.seek() wywołuje TiffImageFile._open(), co resetuje _n_frames do wartości None.

W rezultacie pętla w nieskończoność przetwarza drugi katalog, ilekroć osadzony strumień ma trzy lub więcej katalogów. Iteracje Image.open(), load(), thumbnail(), convert(), is_animated i ImageSequence nie zawieszają się na tym pliku. Regresja została wprowadzona w 0b33a87b0d15 (kwiecień 2020, po raz pierwszy wydana w 7.2.0), co usunęło właściwość n_frames z MicImageFile. Dołączony skrypt odtwarzający problem tworzy plik w kodzie i sprawdza dwie kontrole, które zwracają odpowiednio 3 i 1 klatek; złośliwy plik nadal działa, dopóki nie zostanie zabity po 20 sekundach.

Szczegóły

MicImageFile (zarejestrowany dla Image.open, ilekroć zainstalowany jest olefile) dziedziczy po TiffImageFile i redefiniuje „klatkę” jako jeden strumień OLE *.ACI/Image, odpowiednio nadpisując seek() oraz tell(). Nie nadpisuje właściwości n_frames, dlatego uruchamiana jest odziedziczona implementacja TIFF, która opiera się na przesuwaniu tell() w miarę ładowania kolejnych IFD. MicImageFile.tell() zwraca indeks strumienia OLE, który nigdy nie zmienia się wewnątrz n_frames, więc pętla bez końca analizuje ten sam IFD.

Wystarczy plik złożony OLE2 o rozmiarze 5632 bajtów, którego strumień img0.ACI/Image jest plikiem TIFF o rozmiarze 351 bajtów z trzema powiązanymi katalogami IFD o wymiarach 1×1. Wywołanie Image.open() kończy się sukcesem (format MIC, wykryty po sygnaturze OLE, brak wymogu rozszerzenia); odczytanie im.n_frames nigdy nie powraca i obciąża jeden rdzeń w 100%. Zrzut stosu Pythona pobrany po 27 s:

  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

a instrumentacja _seek pokazuje, że funkcja jest wywoływana z parametrami frame=1, tell()==0, _frame_pos==[8, 122], _n_frames is None przy każdej iteracji — identyczny stan oznacza, że pętla nie kończy działania, a nie jedynie działa wolno. Te same bajty TIFF 351 otwarte bezpośrednio zwracają n_frames == 3 w milisekundach, a ten sam kontener z TIFF o 1 IFD zwraca n_frames == 1.

Pętla, 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)

oraz nadpisania uniemożliwiające postęp, 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

Dla zwykłego pliku TIFF, _seek(k) (TiffImagePlugin.py:1236) ładuje IFD aż do k, dopisując każdy offset do _frame_pos, ustawiając _n_frames, gdy wskaźnik następnego IFD wynosi 0 (lub był już widziany), oraz ustawiając prywatne __frame = k; TiffImageFile.tell() zwraca __frame, więc self.tell() + 1 przechodzi łańcuch do końca.

Dla pliku MIC, Image.open → MicImageFile._open ustawia self._n_frames = len(self.images) w linii 69, lecz następnie wywołuje self.seek(0) → TiffImageFile._open (linia 82), co bezwarunkowo ustawia self._n_frames = None (TiffImagePlugin.py:1203) i przechodzi za pomocą _seek(0) do osadzonego strumienia. W strumieniu z 3 IFD, IFD 0 ma niezerowy wskaźnik do kolejnego IFD, dlatego _n_frames pozostaje na wartości None po otwarciu. Następnie uruchamiane jest n_frames: current = self.tell() wynosi 0 (indeks OLE); _seek(len(_frame_pos)) = _seek(1) ładuje IFD 1, którego wskaźnik do kolejnego IFD jest również niezerowy i nie ma go jeszcze w _frame_pos, więc _n_frames nadal ma wartość None. Ciało while oblicza self.tell() + 1 = 0 + 1 i ponownie wywołuje _seek(1); len(_frame_pos) wynosi już 2, więc nic nowego nie jest ładowane — IFD 1 jest odczytywany ponownie za pośrednictwem tag_v2.load i _setup(), a stan pozostaje bez zmian. Powtarza się to w nieskończoność; IFD 2 nigdy nie zostaje osiągnięty. Dwa powiązane IFD nie wystarczą (wskaźnik kolejnego IFD o wartości 0 w IFD 1 ustawia _n_frames = 2 przy pierwszym _seek(1)); trzy lub więcej już tak.

Tests/test_file_mic.py::test_n_frames przechodzi pomyślnie tylko dlatego, że strumień hopper.mic zawiera pojedynczy IFD, więc _seek(0) ustawia _n_frames = 1 i pętla nie jest wykonywana; przypisanie len(self.images) w linii 69 jest wykorzystywane tylko przez początkowe _seek_check(0) i jest nadpisywane, zanim jakikolwiek kod wywołujący odczyta n_frames.

Efekt uboczny tej rozbieżności: przy strumieniu z 2 IFD funkcja n_frames zwraca 2 (katalogi IFD TIFF), podczas gdy seek(1) indeksuje self.images[1] (strumienie OLE) i zgłasza IndexError — widoczne w save_all=True.

Wpływ

Każda aplikacja, która pozwala bibliotece Pillow otworzyć przesłany plik, a następnie odczytuje im.n_frames — typowy sposób na ustalenie, czy obraz jest animowany (getattr(im, "n_frames", 1) > 1), a także to, co wywołują hasattr(im, "n_frames")/getattr(im, "n_frames", 1) w modułach Pillow TiffImagePlugin._save_all (:2333), PdfImagePlugin._save (:250) i WebPImagePlugin._save_all (:171), gdy aplikacja zapisuje obraz z parametrem save_all=True — może zostać doprowadzona do nieskończonego obciążenia jednego procesu roboczego na poziomie 100% CPU przez przesłanie pliku o rozmiarze zaledwie 5.6 KB. Plik nie wymaga żadnego konkretnego rozszerzenia (sygnatura OLE; FpxImagePlugin odrzuca go na podstawie CLSID, a MicImagePlugin go akceptuje), domyślne limity nie interweniują, pamięć nie rośnie i nie jest zgłaszany żaden wyjątek, więc jedynie zewnętrzny watchdog może zakończyć proces. Warunki wstępne: zainstalowany pakiet olefile (jest zależnością opcjonalną; bez niego wtyczka MIC nie jest zarejestrowana) oraz odczytanie n_frames lub ścieżki z save_all. Samo Image.open(), load(), thumbnail(), convert(), is_animated oraz iteracja po ImageSequence nie zawieszają się na tym pliku. Jest to ta sama klasa problemu braku zakończenia w czystym Pythonie na miniaturowych plikach co CVE-2021-28676 (FLI) oraz CVE-2021-28677 (EPS), i podobnie jak CVE-2019-19911 (FPX) zależy od olefile; różni się tym, że domyślna ścieżka open/load nie wywołuje problemu. Regresja została wprowadzona w commicie 0b33a87b0d15 („Replaced property methods for n_frames and is_animated with normal properties”, 2020-04-13), po raz pierwszy wydanym w 7.2.0, który zastąpił własną właściwość n_frames klasy MicImageFile atrybutem _n_frames, nadpisywanym przez TiffImageFile._open.

Odtworzenie problemu

repro to pojedynczy skrypt w Pythonie uruchamiany na zainstalowanym Pillow (z pakietem olefile, który rejestruje wtyczkę MIC). Buduje wszystkie dane wejściowe w kodzie, zapisuje każdy do pliku tymczasowego o nazwie x bez rozszerzenia (dzięki czemu Pillow identyfikuje format po bajtach sygnatury) i otwiera go w procesie potomnym Pythona, który wykonuje Image.open(path) oraz wyświetla im.format i im.n_frames.

Skrypt generuje w kodzie dwa elementy:

  • tiff(n): n-stronicowy TIFF z klatkami w skali szarości o wymiarach 4×4 zapisany przez samo Pillow (save_all=True), dzięki czemu IFD są powiązane dokładnie tak, jak łączy je Pillow. Ostatni wskaźnik następnego IFD wynosi 0.
  • mic(stream): minimalny plik złożony OLE2 z magazynem o nazwie img0.ACI, który zawiera strumień o nazwie Image — czyli układ, którego oczekuje MicImageFile._open. Sektor 0 to FAT, a sektor 1 to katalog. Strumień zaczyna się od sektora 2 i jest dopełniony do 4096 bajtów, więc plik nie wymaga mini-FAT. Wynikowy plik ma 5632 bajtów.

Skrypt testuje trzy przypadki, każdy w osobnym procesie potomnym z wymuszonym zabiciem po 20 sekundach:

  1. kontrola: zwykły 3-stronicowy TIFF. Oczekiwany wynik: TIFF n_frames = 3.
  2. kontrola: MIC opakowujący 1-stronicowy TIFF. Oczekiwany wynik: MIC n_frames = 1.
  3. atak: ten sam kontener MIC opakowujący 3-stronicowy TIFF.

Limit czasu jest egzekwowany przez zabicie procesu potomnego zamiast wewnętrznego alarmu w procesie, ponieważ wyjątek TimeoutError zgłoszony podczas parsowania przez ImageFileDirectory_v2.load() zostałby przechwycony przez except OSError (TiffImagePlugin.py:958) i pozostawiłby IFD częściowo sparsowany zamiast zatrzymać pętlę.

Skrypt wyświetla BUG, gdy oba przypadki kontrolne powrócą, a proces ataku nadal działa po 20 sekundach; w przeciwnym razie wyświetla OK. Po zastosowaniu dołączonej łatki przypadek ataku wyświetla MIC n_frames = 1, a skrypt wypisuje OK.

Sugerowana poprawka

Załączona łatka przywraca liczbę klatek na poziomie OLE po otwarciu osadzonego strumienia TIFF, dzięki czemu odziedziczona funkcja n_frames nigdy nie wchodzi w pętlę przechodzenia IFD, a n_frames/seek() są zgodne co do definicji klatki. W MicImageFile.seek, src/PIL/MicImagePlugin.py:82, zaraz po 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

Wykonanie tego w seek() przywraca niezmiennik przy każdym wywołaniu seek, nie tylko przy pierwszym. Istniejące przypisania w liniach 69–70 w _open zostały zachowane: początkowe seek(0) → _seek_check(0) odczytuje n_frames, zanim uruchomione zostanie TiffImageFile._open, więc są one nadal potrzebne. Zwykłe właściwości tylko do odczytu n_frames/is_animated, tak jak przed 0b33a87b0, nie mogą być zastosowane bezpośrednio, ponieważ TiffImageFile._seek przypisuje self.is_animated. Po nałożeniu łatki na czyste źródła i ponownym zbudowaniu, złośliwy plik natychmiast zwraca n_frames == 1, strumień z 2 IFD zgłasza EOFError przy seek(1) zamiast IndexError, zapis save_all=True do TIFF i PDF kończy się powodzeniem, a testy Tests/test_file_mic.py i Tests/test_file_tiff.py przechodzą. Nie dołączono testu regresyjnego, gdyż wymagałby on nowego binarnego pliku MIC w Tests/images.

Łatka przeszła tę weryfikację, lecz nie została poddana dalszemu przeglądowi jako ostateczna forma rozwiązania. Pozostawia dwie kwestie: nie dodaje resetowania _im, które wykonuje TiffImageFile.seek po _seek (TiffImagePlugin.py:1229), co ma znaczenie przy przełączaniu między strumieniami OLE o różnych rozmiarach lub trybach; ponadto TiffImageFile.n_frames nadal przesuwa się za pomocą nadpisywalnego tell() zamiast prywatnego __frame, więc iterowanie po __frame zabezpieczyłoby każdą inną podklasę redefiniującą tell().

Łatka

Przywrócenie liczby klatek strumienia OLE w MicImageFile.seek() po zresetowaniu przez TiffImageFile._open(), aby n_frames nie zapętlało się w nieskończoność.

Dotyczy src/PIL/MicImagePlugin.py we wskazanym commicie. Przeszła mechaniczną kontrolę — nałożona na czyste źródła, po przebudowie odtwarzacz przestał wywoływać błąd — jednak recenzent nie zatwierdził jej jako właściwej poprawki.

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:

Skrypt odtwarzający

Odtwarzacz — repro, 2,330 bajtów

Ostatnia linia to BUG, gdy problem występuje, oraz OK, gdy nie występuje. Przy ponownym uruchomieniu na czystej kompilacji tego commitu skrypt wypisał 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")

Co trzeba zrobić

Zaktualizuj bibliotekę Pillow do wersji 13.0.0 lub nowszej.

Czego dotyczy

Kliknij technologię, aby zobaczyć wszystkie dotyczące jej podatności.

PlatformaPaczkaPodatne wersjePoprawka w wersji
Python (PyPI) pillow >= 7.2.0, < 13.0.0 13.0.0

Odnośniki

Ocena CVSS -
Wektor CVSS
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
Słabość (CWE)
CWE-835
Platforma
Python (PyPI)
Paczka
pillow
Ujawniono
21.10.2026
Aktualizacja
22.10.2026 00:06

Źródła i licencje · Redakcja lub tłumaczenie: DockRay. Zachowano prawa źródeł i obowiązujące licencje.

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

Historia podatności: pillow

Wszystkie opisane podatności tej paczki (Python (PyPI)), od najnowszej. Widać, czy to jednorazowa wpadka, czy paczka, której trzeba pilnować.

Wszystkie (89) w archiwum
Wszystkich wpisów
89
Pierwsza → ostatnia
04.2014 10.2026
Bez poprawki
0
Wykorzystywana w atakach
1

Rozkład istotności

  • Krytyczna 12
  • Wysoka 57
  • Średnia 18
  • Niska 1
  • Nieoceniona 1

Ujawnienia w latach

  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

wcześniej: 12

Istotność:
  1. Pillow: Nieskończona pętla w MIC n_frames przy osadzonym TIFF ten wpis
    Wysoka CVE-2026-112390 >= 7.2.0, < 13.0.0 13.0.0
  2. Krytyczna CVE-2026-112403 < 13.0.0 13.0.0
  3. Wysoka · 7.5 CVE-2026-59200 >= 5.1.0, < 12.3.0 12.3.0
  4. Wysoka CVE-2026-42311 >= 10.3.0, < 12.2.0 12.2.0
  5. Wysoka · 7.5 CVE-2026-40192 >= 10.3.0, < 12.2.0 12.2.0
  6. Wysoka CVE-2026-25990 >= 10.3.0, < 12.1.1 12.1.1
  7. Wysoka · 7.1 CVE-2025-48379 >= 11.2.0, < 11.3.0 11.3.0
  8. Wysoka · 6.7 CVE-2024-28219 < 10.3.0 10.3.0
  9. Krytyczna · 8.1 CVE-2023-50447 < 10.2.0 10.2.0
  10. Wysoka · 8.8 Wykorzystywana w atakach CVE-2023-4863 < 10.0.1 10.0.1
  11. Wysoka · 7.5 CVE-2022-45199 >= 9.2.0, < 9.3.0 9.3.0
  12. Wysoka · 7.5 CVE-2022-30595 >= 9.1.0, < 9.1.1 9.1.1
  13. Wysoka · 9.1 CVE-2022-24303 < 9.0.1 9.0.1
  14. Krytyczna · 9.8 CVE-2022-22817 < 9.0.1 9.0.1
  15. Wysoka · 7.5 CVE-2021-23437 >= 5.2.0, < 8.3.2 8.3.2
  16. Krytyczna · 9.8 CVE-2021-34552 < 8.3.0 8.3.0
  17. Wysoka · 7.5 CVE-2021-28677 < 8.2.0 8.2.0
  18. Wysoka · 7.5 CVE-2021-28676 < 8.2.0 8.2.0
  19. Wysoka · 9.1 CVE-2021-25288 >= 2.4.0, < 8.2.0 8.2.0
  20. Wysoka · 9.1 CVE-2021-25287 >= 2.4.0, < 8.2.0 8.2.0
  21. Wysoka · 5.5 CVE-2021-28675 < 8.2.0 8.2.0
  22. Wysoka · 7.5 CVE-2021-25293 >= 4.3.0, < 8.1.1 8.1.1
Pokaż więcej (20) Widać 40 z 69

Dowiedz się, które podatności dotyczą Twojej aplikacji

Po podłączeniu integracji DockRay i włączeniu raportowania oprogramowania DockRay porównuje zgłoszone wersje komponentów ze znanymi podatnościami i powiadamia o potwierdzonych dopasowaniach. Zakres zależy od obsługiwanej technologii i dostępnych biuletynów.

Podłącz swoją aplikację
Newsletter DockRay

Bezpieczeństwo aplikacji w Twojej skrzynce

Najważniejsze podatności, konkretne zalecenia i praktyczne wskazówki dla Twoich aplikacji. Wybierz technologie i śledź to, co ma znaczenie dla Twojego projektu.

  • Codzienne zestawienie nowych podatności, także krytycznych.
  • Powiadomienia o ważnych aktualizacjach.
  • Praktyczne porady o bezpieczeństwie i monitoringu aplikacji.

Pozostaw puste, aby otrzymywać informacje o wszystkich technologiach.

Zapis jest bezpłatny. Wypiszesz się jednym kliknięciem w wiadomości.