Pillow: GIF seek behält verworfene Größe nach DecompressionBombError bei und ermöglicht DoS
============================================================================
GIF seek keeps rejected size after DecompressionBombError, allowing DoS
Location: src/PIL/GifImagePlugin.py:293 in GifImageFile._seek()
Commit: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
Eine GIF-Datei von etwa 60 Bytes kann dazu führen, dass Pillow Gigabytes an Arbeitsspeicher allokiert. Dies betrifft Anwendungen, die den beim Wechsel zu einem späteren Animations-Frame ausgelösten Fehler bezüglich eines zu großen Bildes abfangen und dasselbe Bildobjekt anschließend weiterverwenden. Pillow lehnt den übergroßen Frame zwar ordnungsgemäß ab, speichert die verworfenen Dimensionen jedoch trotzdem im Bildobjekt, woraufhin nachfolgende Operationen ihren Speicherbedarf anhand dieser Dimensionen bemessen. Ein Angreifer, der eine GIF-Datei an einen solchen Dienst übermitteln kann, kann dessen Speicher erschöpfen: Der Prozess bricht nach Zuweisung von Gigabytes mit einem Speicherüberlauf-Fehler (Out-of-Memory) ab oder wird vom Betriebssystem beendet. Eine Speicherkorruption tritt nicht auf.
Der Fehler befindet sich in GifImageFile._seek() (src/PIL/GifImagePlugin.py:292-294). Wenn die Ausdehnung eines Frames das Bild vergrößert, weist der Code die neue Größe self._size zu und ruft erst danach Image._decompression_bomb_check() auf. Nichts stellt die vorherige Größe wieder her, wenn DecompressionBombError ausgelöst wird, da seek() ein Rollback nur bei EOFError durchführt. Nach dem abgefangenen Fehler meldet im.size die abgelehnte Größe, während der Pixelpuffer im.im weiterhin der 1×1 große erste Frame ist. Da sich die Ausdehnung aus einem 16-Bit-Offset plus einer 16-Bit-Größe zusammensetzt, kann diese abgelehnte Größe bis zu 131070×131070 betragen.
Im beigefügten Reproduktionsbeispiel beträgt die abgelehnte Größe 40000×40000. Ein wiederholter Aufruf von seek(1), gefolgt von load(), erreicht schließlich load_prepare(), was Image.core.fill() mit dieser Größe aufruft, um ein einzelnes Pixel zu dekodieren. Der Spitzenwert des Arbeitsspeichers steigt von 17 MiB auf 1700 MiB, bevor eine Begrenzung des Adressraums von 3 GiB die nächste Allokation stoppt. Auch Image.new("RGB", im.size, ...) und rotate(..., expand=True) allokieren Speicher auf Basis der veralteten Größe.
Die Standardpfade sind nicht betroffen: einfaches Image.open() mit load(), ImageSequence.Iterator sowie n_frames/is_animated. Die Reihenfolge wurde in c9f1b35e9810 („Added GIF decompression bomb check“, 2022-06-30, erstmals veröffentlicht in 9.2.0) eingeführt; die Prüfung wurde für CVE-2022-45198 hinzugefügt. Die beiden späteren Prüfungen der Entsorgungs-Ausdehnung in derselben Funktion validieren bereits vor der Verwendung der Größe, und der beigefügte Patch gleicht diese Prüfung an dieselbe Reihenfolge an.
Details
GifImageFile._seek() ermöglicht es einem späteren Frame, das Bild zu vergrößern, und führt die Decompression-Bomb-Prüfung für die neue Größe erst durch, nachdem sie im Objekt gespeichert wurde:
# 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) löst für alles über 2 * MAX_IMAGE_PIXELS (178 956 970 px standardmäßig) DecompressionBombError aus, aber zu diesem Zeitpunkt enthält self._size bereits den abgelehnten Wert und nichts stellt ihn wieder her: seek() (GifImagePlugin.py:166-179) führt einen Rollback nur bei EOFError durch, und _seek(0) vergrößert _size nur. Das von der Anwendung weiterhin gehaltene Objekt meldet daher im.size == (40000, 40000), während im.im der 1×1-Puffer von Frame 0 ist. Vergleiche die beiden Prüfungen der Entsorgungs-Ausdehnung wenige Zeilen weiter unten in derselben Funktion (GifImagePlugin.py:383 und :407), die _decompression_bomb_check(dispose_size) vor dem entsprechenden Image.core.fill() aufrufen, sowie TiffImagePlugin.load_prepare() (src/PIL/TiffImagePlugin.py:1319-1321), das _tile_size vor der Allokation daraus überprüft.
Mit diesem 60-Byte-GIF (Hexadezimal):
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() ist erfolgreich (im.size == (1, 1)), im.seek(1) löst Folgendes aus:
DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwards im.size == (40000, 40000),
im.im.size == (1, 1), im.tell() == 0.
Was als Nächstes geschieht, hängt davon ab, was die Anwendung mit dem Objekt macht, jedoch beziehen mehrere gewöhnliche Operationen ihre Abmessungen von im.size statt aus dem C-Puffer:
- Erneuter Versuch von
im.seek(1): Das fehlgeschlagene_seekbeließself.__offset == 0und den Dateizeiger direkt hinter dem 9-Byte-Deskriptor, sodass der erneute Versuch alles parst, was folgt – in der obigen Datei ein gültiger 1×1-Frame – und erfolgreich ist.im.load()führt dannGifImageFile.load_prepare()aus:wodurch ein 40000×40000 „P“-Bild (1.6 GB) allokiert und mitpython# 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)memsetinitialisiert wird, um eine 1×1-Kachel hinein zu dekodieren, undload_end()(GifImagePlugin.py:485-491) füllt ein zweites und konvertiert es nach RGB (weitere 1.6 GB + 6.4 GB). im.rotate(angle, expand=True)für einen Winkel, der kein Vielfaches von 90 ist, oder das gängige Idiom zum Reduzieren auf einen HintergrundImage.new("RGB", im.size, bg)allokieren ebenfalls ausim.size(gemessen an einem auf 20000×20000 manipulierten Objekt:rotate(45, expand=True)schließt ab und beansprucht 776 MiB;Image.new("RGB", im.size, "white")beansprucht 1.5 GiB). Operationen, dieim.sizeals Box gegen den realen Puffer übergeben (thumbnail,resize,save), schlagen stattdessen mitValueError/SystemErrorfehl.
Die Ausdehnungsfelder sind ein 16-Bit-Offset plus eine 16-Bit-Größe, daher kann die gespeicherte Größe auf 131070×131070 getrieben werden (17.2 Gpx; 17 GB für Modus „P“, 69 GB für „RGB“/„RGBA“-Speicher), das 96-fache des Fehlerschwellenwerts.
n_frames / is_animated sind nicht betroffen: Sie rufen _seek(frame, update_image=False) auf, was die Zuweisung zu _size überspringt (die obige Datei meldet n_frames == 2, is_animated == True und belässt im.size bei 1×1).
Auswirkungen
Ein entfernter Benutzer, der ein GIF an einen Dienst hochladen kann, der (a) Animations-Frames ansteuert und (b) die Ausnahme von seek() abfängt – ein allgemeines except Exception um eine while True: im.seek(im.tell()+1)-Frame-Sammelschleife herum oder ein explizites except DecompressionBombError: log and continue – und dann dasselbe Image-Objekt weiterverwendet, löst letztendlich Allokationen von 1.6 GB bis zu mehreren Dutzend GB aus einer 60-Byte-Datei aus: MemoryError, nachdem der Prozess Gigabytes zugewiesen hat, oder ein OOM-Kill des Workers, wenn kein Adressraumlimit gesetzt ist. Die MAX_IMAGE_PIXELS-Schutzprüfung schlägt zwar an, hält die abgewiesenen Abmessungen jedoch nicht aus dem zu schützenden Objekt fern.
Die Vorbedingung entspricht realem Anwendungsverhalten, stellt jedoch nicht den Standardpfad dar: Image.open(); im.load() ist nicht betroffen, und ImageSequence.Iterator leitet den Fehler aus der for-Anweisung weiter (ein try innerhalb des Schleifenkörpers fängt ihn nicht ab). Daher geringer Schweregrad. Dies grenzt an die Prüfung für GIF-Ausdehnungsbomben an, die für CVE-2022-45198 hinzugefügt wurde: Die Reihenfolge wurde mit dieser Prüfung in c9f1b35e9810 („Added GIF decompression bomb check“, 2022-06-30) eingeführt und erstmals in Pillow 9.2.0 veröffentlicht. Zuvor erfolgte dieselbe Größenvergrößerung gänzlich ohne Prüfung; die dort eingeführte Prüfung läuft zu spät ab.
Reproduktion
repro ist ein einzelnes Python-Skript, das gegen das installierte Pillow ausgeführt wird. Es benötigt etwa 1.7 GB freien Arbeitsspeicher.
setup_env()begrenzt den Adressraum des Prozesses mittelsRLIMIT_ASauf 3 GiB, sodass die überdimensionierten Allokationen inMemoryErrorstatt im OOM-Killer enden. Anschließend öffnet es das oben gezeigte 60-Byte-GIF, das im Speicher durchbomb_gif()erzeugt wird. Das GIF enthält einen 1×1-Bildschirm und einen 1×1-Frame 0, sodassImage.open()seine eigene Bomb-Prüfung besteht. Danach folgt ein einfacher Frame-1-Bilddeskriptor, der 40000×40000 deklariert (1.6 Gpx, etwa das 9-fache des Fehlerschwellenwerts). Ein realer 1×1-Frame folgt darauf.poc()ruftim.seek(1)auf und fängtDecompressionBombErrorab, so wie es eine Anwendung tun würde, die fehlerhafte Frames überspringt.- Erste Prüfung: Der Zustand ist inkonsistent. Das Skript gibt
im.sizeneben dem realen Pixelpufferim.im.sizeaus. Hätte der abgelehnte Frame das Objekt unverändert gelassen, wären beide(1, 1). Im betroffenen Code istim.sizejedoch(40000, 40000). - Zweite Prüfung: Speicherverbrauch. Das Skript wiederholt
im.seek(1), was nun den nachfolgenden 1×1-Frame parst, und ruftim.load()auf. Es gibt die Spitzen-RSS des Prozesses vor und nach dem Aufruf aus.load_prepare()bemisst seinImage.core.fill()nachim.size, sodass das Laden eines einzelnen Pixels einen 1.6 GB großen Puffer füllt, bevor die 3-GiB-Grenze die nächste Allokation stoppt.
Erwartete Ausgabe auf dem betroffenen Codebestand:
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
Das Skript gibt BUG nur aus, wenn beide Prüfungen zutreffen: Die über dem Limit liegende Größe verblieb nach der Ausnahme im Objekt und die Spitzen-RSS stieg beim Laden eines 1×1-Frames um mehr als 1000 MiB. Andernfalls gibt es OK aus. Mit dem beigefügten Patch gibt es im.size=(1, 1) aus, die Spitzen-RSS bleibt bei etwa 23 MiB und die letzte Zeile lautet OK.
Vorgeschlagene Fehlerbehebung
Der beigefügte Patch validiert vor der Mutation in GifImageFile._seek() (src/PIL/GifImagePlugin.py:292-294): Die vergrößerte Ausdehnung wird in einer lokalen Variablen berechnet, an _decompression_bomb_check() übergeben und erst danach self._size zugewiesen, was der Reihenfolge der beiden dispose_size-Prüfungen weiter unten in derselben Funktion entspricht. Ein abgewiesener Frame belässt das Objekt daher in seinem Zustand vor dem Suchlauf; gültige Dateien verhalten sich wie zuvor und die Ausnahme wird weiterhin an derselben Stelle ausgelöst:
--- 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 durchlaufen update_image=False und sind nicht betroffen. Der Patch erweitert auch test_exception_gif_extents in Tests/test_decompression_bomb.py, um sicherzustellen, dass im.size nach DecompressionBombError unverändert bleibt. Er ändert seek() selbst nicht; optional könnte seek() auch DecompressionBombError wie EOFError behandeln und erneut zu last_frame springen, damit ein wiederholtes seek() das Parsen nicht mitten im abgelehnten Frame fortsetzt.
Der Patch
GIF: Decompression-Bomb-Prüfung für die Ausdehnung eines vergrößernden Frames ausführen, bevor sie in self._size gespeichert wird
Gilt für src/PIL/GifImagePlugin.py beim obigen Commit.
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]
Der Reproduzierer
Reproduzierer — repro, 2,474 Bytes
Die letzte Zeile lautet BUG, wenn das Problem auftritt, und OK, wenn nicht. Bei einem erneuten Durchlauf auf einem sauberen Build dieses Commits wurde BUG ausgegeben.
#!/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")
Was zu tun ist
Aktualisieren Sie Pillow auf Version 13.0.0 oder höher oder wenden Sie den bereitgestellten Patch an.
Betroffene Software
Klicken Sie auf eine Technologie, um alle zugehörigen Schwachstellen anzuzeigen.
| Plattform | Paket | Anfällige Versionen | Behoben in |
|---|---|---|---|
| Python (PyPI) | pillow | >= 9.2.0, < 13.0.0 | 13.0.0 |
Verweise
- 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
- CVSS-Vektor
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- Schwäche (CWE)
CWE-400- Plattform
- Python (PyPI)
- Paket
pillow- Veröffentlicht
- 20.10.2026
- Aktualisiert
- 20.10.2026 23:31
Quellen und Lizenzen · Von DockRay bearbeitet oder übersetzt; Rechte und Lizenzen der Quellen bleiben erhalten.
OSV / GitHub Advisory Database - GHSA-65qm-jvmh-6pqg - 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.
- Einträge insgesamt
- 81
- Erste → letzte
- 04.2014 10.2026
- Ohne Fix
- 0
- In Angriffen ausgenutzt
- 1
Verteilung nach Schweregrad
- Kritisch 10
- Hoch 53
- Mittel 16
- Niedrig 1
- Nicht bewertet 1
Veröffentlichungen pro Jahr
- 2017: 1
- 2018: 0
- 2019: 1
- 2020: 10
- 2021: 20
- 2022: 8
- 2023: 3
- 2024: 2
- 2025: 1
- 2026: 23
früher: 12
-
Pillow: GIF seek behält verworfene Größe nach DecompressionBombError bei und ermöglicht DoS dieser Eintrag
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.