Pillow: Bucle infinito en MIC n_frames con TIFF incrustado
============================================================================
Infinite loop in MIC n_frames when the embedded TIFF has 3+ IFDs
Location: src/PIL/MicImagePlugin.py:86 in MicImageFile.tell()
Commit: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
Un archivo de imagen manipulado de 5.6 KB hace que Pillow entre en un bucle infinito al 100 % de CPU cuando una aplicación consulta cuántos fotogramas tiene la imagen. Esto solo afecta a las instalaciones que cuentan con el paquete opcional olefile. Cualquier aplicación que abra una imagen que no sea de confianza y luego lea im.n_frames puede dejar un proceso de trabajo bloqueado indefinidamente. Esto incluye la comprobación común de «¿esta imagen está animada?» y el re-guardado con save_all=True en TIFF, PDF o WebP. La llamada nunca retorna, no genera ninguna excepción y no incrementa el uso de memoria, por lo que solo un watchdog externo puede detenerla.
El archivo es un contenedor Microsoft Image Composer (MIC), que Pillow reconoce por sus bytes mágicos sin necesidad de extensión. En su interior hay un flujo TIFF con tres directorios de imagen encadenados. MicImageFile hereda de TiffImageFile y sobrescribe seek() y tell(), de modo que un «fotograma» significa un flujo en el contenedor. Aún hereda TiffImageFile.n_frames (src/PIL/TiffImagePlugin.py:1212), que recorre la cadena de directorios del TIFF incrustado con self._seek(self.tell() + 1) hasta que se establece _n_frames. Dos aspectos impiden que dicho recorrido avance:
MicImageFile.tell()(src/PIL/MicImagePlugin.py:86) devuelve el índice de flujo constante.MicImageFile.seek()llama aTiffImageFile._open(), lo que restablece_n_framesaNone.
Como resultado, el bucle vuelve a procesar el segundo directorio indefinidamente siempre que el flujo incrustado tenga tres o más directorios. La iteración en Image.open(), load(), thumbnail(), convert(), is_animated y ImageSequence no se bloquea con este archivo. La regresión se introdujo en 0b33a87b0d15 (abril de 2020, publicado por primera vez en 7.2.0), eliminando la propiedad n_frames propia de MicImageFile. El reproductor adjunto genera el archivo en código y comprueba dos controles, los cuales devuelven 3 y 1 fotogramas como se esperaba; el archivo malicioso sigue ejecutándose cuando se cancela tras 20 segundos.
Detalles
MicImageFile (registrado para Image.open siempre que olefile esté instalado) hereda de TiffImageFile y redefine un «fotograma» como un flujo OLE *.ACI/Image, sobrescribiendo seek() y tell() en consecuencia. No sobrescribe la propiedad n_frames, por lo que se ejecuta la implementación de TIFF heredada, y esa implementación se basa en el avance de tell() a medida que se cargan los IFD. MicImageFile.tell() devuelve el índice de flujo OLE, que nunca cambia dentro de n_frames, por lo que el bucle vuelve a analizar el mismo IFD indefinidamente.
Un archivo compuesto OLE2 de 5632 bytes cuyo flujo img0.ACI/Image es un TIFF de 351 bytes con tres IFD encadenados de 1×1 es suficiente. Image.open() sobre él tiene éxito (formato MIC, detectado por la magia OLE, sin necesidad de extensión); evaluar im.n_frames entonces nunca retorna y mantiene un núcleo al 100 %. Una traza de pila de Python tomada a los 27 s es:
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
y la instrumentación de _seek muestra que se le llama con frame=1, tell()==0, _frame_pos==[8, 122], _n_frames is None en cada iteración —estado idéntico, por lo que el bucle no finaliza, no es simplemente lento. Los mismos bytes TIFF de 351 abiertos directamente devuelven n_frames == 3 en milisegundos, y el mismo contenedor alrededor de un TIFF de 1 IFD devuelve n_frames == 1.
El bucle, src/PIL/TiffImagePlugin.py:1212:
@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)
y las sobrescrituras que rompen su suposición de progreso, src/PIL/MicImagePlugin.py:76:
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
Para un TIFF normal, _seek(k) (TiffImagePlugin.py:1236) carga los IFD hasta k, agregando cada desplazamiento a _frame_pos, estableciendo _n_frames cuando el puntero siguiente de un IFD es 0 (o ya ha sido visto), y estableciendo el privado __frame = k; TiffImageFile.tell() devuelve __frame, por lo que self.tell() + 1 recorre la cadena hasta el final.
Para un archivo MIC, Image.open → MicImageFile._open establece self._n_frames = len(self.images) en la línea 69, pero luego llama a self.seek(0) → TiffImageFile._open (línea 82), que establece incondicionalmente self._n_frames = None (TiffImagePlugin.py:1203) y realiza _seek(0) en el flujo incrustado. Con el flujo de 3 IFD, el IFD 0 tiene un puntero siguiente distinto de cero, por lo que _n_frames permanece en None tras abrir. Luego se ejecuta n_frames: current = self.tell() es 0 (el índice OLE); _seek(len(_frame_pos)) = _seek(1) carga el IFD 1, cuyo puntero siguiente también es distinto de cero y aún no está en _frame_pos, por lo que _n_frames sigue siendo None. El cuerpo de while calcula self.tell() + 1 = 0 + 1 y llama de nuevo a _seek(1); len(_frame_pos) ya es 2, por lo que no se carga nada nuevo — el IFD 1 se vuelve a leer a través de tag_v2.load y _setup() y el estado se mantiene sin cambios. Esto se repite indefinidamente; nunca se llega al IFD 2. Dos IFD encadenados no son suficientes (el puntero siguiente de 0 en el IFD 1 establece _n_frames = 2 en el primer _seek(1)); tres o más sí lo son.
Tests/test_file_mic.py::test_n_frames solo tiene éxito porque el flujo de hopper.mic tiene un único IFD, por lo que _seek(0) establece _n_frames = 1 y nunca se entra en el bucle; la asignación de len(self.images) en la línea 69 solo es consumida por el _seek_check(0) inicial y se sobrescribe antes de que cualquier llamador pueda leer n_frames.
Un efecto secundario de esta discordancia: con un flujo de 2 IFD, n_frames devuelve 2 (IFD de TIFF) mientras que seek(1) indexa self.images[1] (flujos OLE) y lanza IndexError — visible con save_all=True.
Impacto
Cualquier aplicación que permita a Pillow abrir un archivo cargado y luego lea im.n_frames —la forma habitual de determinar si una imagen está animada (getattr(im, "n_frames", 1) > 1), y también lo que evalúan hasattr(im, "n_frames")/getattr(im, "n_frames", 1) en los propios módulos de Pillow TiffImagePlugin._save_all (:2333), PdfImagePlugin._save (:250) y WebPImagePlugin._save_all (:171) cuando la aplicación vuelve a guardar con save_all=True— puede hacer que un proceso de trabajo gire al 100 % de CPU indefinidamente con una carga de 5.6 KB. El archivo no necesita ninguna extensión particular (magia OLE; FpxImagePlugin lo rechaza por CLSID y MicImagePlugin lo acepta), los límites predeterminados no intervienen, la memoria no crece y no se genera ninguna excepción, por lo que solo un watchdog externo puede detenerlo. Condiciones previas: olefile instalado (es una dependencia opcional; sin ella no se registra el plugin MIC) y que la aplicación acceda a n_frames o a una ruta con save_all. Image.open() en sí, load(), thumbnail(), convert(), is_animated y la iteración con ImageSequence no se cuelgan con este archivo. Se trata de la misma clase de falta de finalización en archivos pequeños y puro Python que CVE-2021-28676 (FLI) y CVE-2021-28677 (EPS), y al igual que CVE-2019-19911 (FPX) depende de olefile; difiere en que no se alcanza mediante la ruta predeterminada de apertura/carga. La regresión se introdujo en 0b33a87b0d15 («Replaced property methods for n_frames and is_animated with normal properties», 2020-04-13), publicado por primera vez en 7.2.0, que sustituyó la propiedad propia n_frames de MicImageFile por el atributo _n_frames que TiffImageFile._open sobrescribe.
Reproducción
repro es un script de Python independiente que se ejecuta contra la versión instalada de Pillow (con olefile, que registra el plugin MIC). Construye cada entrada en código, escribe cada una en un archivo temporal llamado x sin extensión (de modo que Pillow identifica el formato a partir de sus bytes mágicos) y lo abre en un proceso secundario de Python que ejecuta Image.open(path) e imprime im.format y im.n_frames.
Construye dos elementos en código:
tiff(n): un TIFF denpáginas con fotogramas en escala de grises de 4×4 escrito por el propio Pillow (save_all=True), de modo que los IFD están encadenados exactamente como los encadena el escritor de Pillow. El último puntero a siguiente IFD es 0.mic(stream): un archivo compuesto OLE2 mínimo con un almacenamiento denominadoimg0.ACIque contiene un flujo llamadoImage, que es la estructura que buscaMicImageFile._open. El sector 0 es la FAT y el sector 1 es el directorio. El flujo comienza en el sector 2 y se rellena hasta 4096 bytes, por lo que el archivo no necesita una mini-FAT. El resultado es de 5632 bytes.
Ejecuta tres entradas, cada una en su propio proceso hijo con una detención forzada tras 20 segundos:
- control: el TIFF estándar de 3 páginas. Esperado:
TIFF n_frames = 3. - control: un MIC que envuelve un TIFF de 1 páginas. Esperado:
MIC n_frames = 1. - ataque: el mismo contenedor MIC envolviendo el TIFF de 3 páginas.
El tiempo de espera se aplica terminando el proceso hijo en lugar de una alarma interna del proceso, ya que una excepción TimeoutError generada mientras ImageFileDirectory_v2.load() está analizando sería capturada por su except OSError (TiffImagePlugin.py:958) y dejaría el IFD parcialmente analizado en lugar de detener el bucle.
El script imprime BUG cuando ambos controles terminan y el proceso hijo de ataque sigue ejecutándose tras 20 segundos; de lo contrario imprime OK. Con el parche adjunto aplicado, el caso de ataque imprime MIC n_frames = 1 y el script imprime OK.
Solución sugerida
El parche adjunto restablece el recuento de fotogramas a nivel de OLE una vez abierto el flujo TIFF incrustado, de modo que el n_frames heredado nunca entra en su bucle de recorrido de IFD y n_frames/seek() coinciden en qué constituye un fotograma. En MicImageFile.seek, src/PIL/MicImagePlugin.py:82, justo después de TiffImagePlugin.TiffImageFile._open(self):
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
Hacer esto en seek() restaura la invariante en cada búsqueda (seek), no solo en la primera. Se mantienen las asignaciones existentes en las líneas 69–70 de _open: el seek(0) → _seek_check(0) inicial lee n_frames antes de que se haya ejecutado TiffImageFile._open, por lo que todavía son necesarias. Propiedades de solo lectura directas n_frames/is_animated, como antes de 0b33a87b0, ya no son una opción inmediata porque TiffImageFile._seek asigna self.is_animated. Con el parche aplicado en un árbol limpio y recompilado, el archivo de ataque devuelve n_frames == 1 inmediatamente, un flujo de 2 IFD genera EOFError en seek(1) en lugar de IndexError, save_all=True a TIFF y PDF se completa, y Tests/test_file_mic.py y Tests/test_file_tiff.py pasan con éxito. No se incluye ninguna prueba de regresión, ya que requeriría una nueva estructura de prueba binaria MIC en Tests/images.
El parche superó esa verificación pero no ha sido revisado más allá como la forma final de la corrección. Deja dos aspectos intactos: no añade el restablecimiento de _im que TiffImageFile.seek realiza tras _seek (TiffImagePlugin.py:1229), lo cual importa al cambiar entre flujos OLE de diferente tamaño o modo; y TiffImageFile.n_frames todavía avanza a través del método sobreescribible tell() en lugar de su __frame privado, por lo que hacerlo iterar sobre __frame protegería a cualquier otra subclase que redefina tell().
El parche
Restablecer el recuento de fotogramas de flujo OLE en MicImageFile.seek() después de que TiffImageFile._open() lo restablezca, para que n_frames no entre en bucle infinito.
Aplica a src/PIL/MicImagePlugin.py en el commit anterior. Superó la comprobación mecánica —aplicado a un árbol limpio, recompilado, el reproductor dejó de activarse— pero un revisor no lo respaldó formalmente como la corrección definitiva.
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:
El reproductor
Reproductor — repro, 2,330 bytes
Su última línea es BUG cuando se desencadena el problema y OK cuando no lo hace. Al volver a ejecutarlo en una compilación limpia de ese commit imprimió BUG.
#!/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")
Qué hacer
Actualice Pillow a la versión 13.0.0 o posterior.
Software afectado
Haz clic en una tecnología para ver todas sus vulnerabilidades.
| Plataforma | Paquete | Versiones vulnerables | Corregida en |
|---|---|---|---|
| Python (PyPI) | pillow | >= 7.2.0, < 13.0.0 | 13.0.0 |
Referencias
- https://github.com/python-pillow/Pillow/security/advisories/GHSA-22hg-r758-rrw9
- https://nvd.nist.gov/vuln/detail/CVE-2026-112390
- https://github.com/python-pillow/Pillow/commit/26cb3ed061173b817b9ee4b6f507cb0e1e293218
- https://github.com/python-pillow/Pillow
- https://github.com/python-pillow/Pillow/releases/tag/13.0.0
- Vector 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- Debilidad (CWE)
CWE-835- Plataforma
- Python (PyPI)
- Paquete
pillow- Publicada
- 21.10.2026
- Actualizado
- 22.10.2026 00:06
Fuentes y licencias · Editado o traducido por DockRay; se mantienen los derechos y las licencias de las fuentes.
OSV / GitHub Advisory Database - GHSA-22hg-r758-rrw9 - 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.
- Entradas en total
- 89
- Primera → última
- 04.2014 10.2026
- Sin corrección
- 0
- Explotada en ataques
- 1
Distribución por gravedad
- Crítica 12
- Alta 57
- Media 18
- Baja 1
- Sin evaluar 1
Divulgaciones por año
- 2017: 1
- 2018: 0
- 2019: 1
- 2020: 10
- 2021: 20
- 2022: 8
- 2023: 3
- 2024: 2
- 2025: 1
- 2026: 31
antes: 12
-
Pillow: Bucle infinito en MIC n_frames con TIFF incrustado esta entrada
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.