Alta

Pillow: Desreferencia de NULL en ImagingExpand mediante filtros de rango en imágenes de tamaño cero

  • Publicada: 21.10.2026
  • Actualizado: 22.10.2026 00:08
============================================================================
NULL dereference in ImagingExpand via rank filters on zero-size images
Location: src/libImaging/Filter.c:79  in ImagingExpand()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Aplicar el filtro de mediana, mínimo, máximo o rango de Pillow a una imagen con ancho o alto cero provoca la caída de todo el proceso Python en lugar de lanzar una excepción. Este tipo de imágenes vacías se generan fácilmente mediante llamadas habituales, por ejemplo con una caja de recorte cuyos bordes izquierdo y derecho (o superior e inferior) son iguales, o mediante la segunda página de un archivo de imagen multipágina manipulado. Un atacante capaz de proporcionar coordenadas de recorte o un archivo cargado a una aplicación que ejecute uno de estos filtros puede provocar la caída del proceso de trabajo. El efecto es únicamente una caída (crash), sin divulgación ni corrupción de memoria.

El fallo reside en ImagingExpand() dentro de src/libImaging/Filter.c. Se trata del paso de relleno de bordes (edge-padding) que RankFilter.filter() siempre ejecuta a través del método principal expand(), por lo que MedianFilter, MinFilter y MaxFilter también lo recorren. Rellena los márgenes copiando los píxeles de los bordes de la entrada, leyendo imIn->image[y][0] y las filas 0 / ysize-1, y nunca verifica si la entrada contiene píxeles. Para una imagen vacía, ImagingAllocateArray deja los punteros de línea en NULL, por lo que la primera lectura desreferencia NULL y el proceso termina con SIGSEGV en cualquier modo.

Las imágenes de tamaño cero llegan a este punto únicamente a través de la API pública:

  • Image.crop(), ImageOps.crop() y Image.new();
  • un TIFF multipágina abierto por ruta cuyo segundo IFD declara ImageLength = 0, sobre el cual seek(1) y load() se ejecutan con éxito.

Cualquier otro ImageFilter devuelve una imagen vacía para las mismas entradas. El caso de altura cero ha fallado desde la importación original de PIL, y el caso de ancho cero desde Pillow 4.3.0. Esto es independiente de la corrección de margin == 0 en #9755: la ruta es la misma, pero aquí el valor degenerado es el tamaño de la imagen en lugar del tamaño del kernel. Presenta la misma estructura que la caída por dimensiones cero en ImagingOffset corregida en GHSA-f87v-9578-p47w.

El reproductor adjunto demuestra que un recorte de control de un píxel de ancho se filtra con normalidad, mientras que cuatro recortes vacíos procesados con los filtros de rango terminan cada uno con SIGSEGV. Se incluye un parche para salir de forma anticipada en ImagingExpand ante entradas vacías.

Detalles

ImagingExpand() (src/libImaging/Filter.c), la función auxiliar de relleno de bordes que ImageFilter.RankFilter ejecuta antes del filtro de rango en sí, lee el primer píxel de cada línea de entrada sin comprobar si existen píxeles. Para una imagen con ancho 0 o altura 0, los punteros de línea son NULL, por lo que Image.filter() con MedianFilter, MinFilter, MaxFilter o RankFilter provoca la terminación del intérprete con SIGSEGV en lugar de retornar o generar una excepción:

python
from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop((3, 0, 3, 10))   # size (0, 10); crop() accepts left == right
im.filter(ImageFilter.MedianFilter(3))               # SIGSEGV

Bajo gdb en la compilación estándar, el fallo se produce en ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105 (la expansión de EXPAND(UINT8, image8)), llamada desde _expand_image en src/_imaging.c:1151, con imIn->xsize = 0 y imIn->image[0] = 0x0. Ocurre lo mismo en todos los modos admitidos por los filtros de rango (L, RGB, RGBA, I, F), y el método principal expand(m) con m >= 1 se bloquea de la misma manera en cualquier modo, incluido P. La misma entrada procesada con BoxBlur, GaussianBlur, Kernel, ModeFilter y UnsharpMask devuelve una imagen de (0, 10); un recorte de (1, 10) procesado mediante MedianFilter también finaliza normalmente.

Las lecturas de borde no verificadas datan de la importación de PIL (9a640e315, 2010, por lo que afectan a todas las versiones de Pillow): el caso de altura cero ha fallado desde entonces, y el caso de ancho cero desde fd907fbdc ("Fix 0-width and 0-height images other way", Pillow 4.3.0), que dejó de respaldar imágenes de ancho cero con un bloque ficticio y dejó sus punteros de fila en NULL. Es adyacente a la solución de división por cero de margin == 0 en #9755 —misma ruta expand() → ImagingExpand(), pero el valor degenerado no controlado aquí es el tamaño de la imagen en lugar del tamaño del kernel.

La ruta:

python
# src/PIL/ImageFilter.py:111-116   RankFilter.filter  (MedianFilter/MinFilter/MaxFilter subclass it)
        image = image.expand(self.size // 2)
        return image.rankfilter(self.size, self.rank)

expand(m) con m > 0 es ImagingExpand(self->image, m) (src/_imaging.c:1142-1151). Solo valida margin, asigna una salida de (xsize+2*margin) × (ysize+2*margin) —que no está vacía, por lo que la asignación tiene éxito— y luego rellena el margen duplicando los píxeles de borde de la entrada:

c
// src/libImaging/Filter.c:70-101
    imOut =
        ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
    …
#define EXPAND_LINE(type, image, yin, yout)                       \
    {                                                             \
        for (x = 0; x < margin; x++) {                            \
            imOut->image[yout][x] = imIn->image[yin][0];          \   // line 79
        }                                                         \
        …                                                         \
        for (x = 0; x < margin; x++) {                            \
            imOut->image[yout][margin + imIn->xsize + x] =        \
                imIn->image[yin][imIn->xsize - 1];                \   // [-1] when xsize == 0
        }                                                         \
    }

#define EXPAND(type, image)                                                      \
    {                                                                            \
        for (y = 0; y < margin; y++) {                                           \
            EXPAND_LINE(type, image, 0, y);                                      \   // line 0 of a 0-line image
        }                                                                        \
        …
        for (y = 0; y < margin; y++) {                                           \
            EXPAND_LINE(type, image, imIn->ysize - 1, margin + imIn->ysize + y); \   // line -1 when ysize == 0
        }                                                                        \
    }

Por qué los punteros de línea son NULL: ImagingNewPrologueSubtype asigna con calloc el arreglo de punteros im->image (src/libImaging/Storage.c:216, al menos una entrada), y ImagingAllocateArray retorna antes de asignar ninguno de ellos en caso de una imagen vacía:

c
// src/libImaging/Storage.c:423-426
    /* 0-width or 0-height image. No need to do anything */
    if (!im->linesize || !im->ysize) {
        return im;
    }

Por lo tanto, con xsize == 0 cada imIn->image[y] es NULL y imIn->image[yin][0] genera un fallo; con ysize == 0 el pase del margen superior lee imIn->image[0] (el único espacio NULL reservado con calloc) y falla del mismo modo, y el pase del margen inferior indexaría imIn->image[-1]. Jamás se alcanza ImagingRankFilter.

Las imágenes de tamaño cero son objetos comunes en Pillow y llegan a filter() exclusivamente mediante la API pública:

  • Image.crop(box) solo rechaza cajas invertidas (src/PIL/Image.py:1385-1390); left == right o upper == lower genera una imagen de 0×N / N×0. ImageOps.crop(im, border) con border == width // 2, y Image.new(mode, (0, n)), hacen lo mismo.
  • Desde un archivo: ImageFile.__init__ rechaza un tamaño de primer cuadro igual a cero (src/PIL/ImageFile.py:167-173), pero TiffImagePlugin._setup() toma ImageWidth/ImageLength de cualquier IFD posterior tal cual en seek() (src/PIL/TiffImagePlugin.py:1468-1481). Para una página sin comprimir de una imagen abierta por ruta, ImageFile.load() toma la rama mmap (src/PIL/ImageFile.py:307, 325-347) y Image.core.map_buffer construye una imagen central 8×0 sin errores (src/map.c:127-141), por lo que im.seek(1); im.load() tiene éxito y una llamada posterior a im.filter(ImageFilter.MedianFilter(3)) produce un fallo de segmentación en ImagingExpand exactamente como se describe (verificado en gdb: imIn->ysize = 0, imIn->image[0] = 0x0). Basta con un TIFF de dos páginas de 384 bytes.

Impacto

Denegación de servicio: todo el proceso anfitrión (proceso de trabajo web, proceso de pipeline) termina con SIGSEGV en lugar de que la solicitud falle con una excepción de Python. Es explotable por un atacante remoto de dos formas realistas: (a) una aplicación que recorta una imagen cargada según coordenadas dadas por el usuario y luego aplica un filtro de mediana/mínimo/máximo —una caja con x0 == x1 o y0 == y1 pasa crop() y provoca la caída del proceso; (b) una aplicación que abre un TIFF cargado desde disco por ruta, itera sus páginas y aplica un filtro de rango a cada una —un segundo IFD manipulado con ImageLength = 0 provoca su caída. El acceso causante del fallo es una lectura en la dirección 0 (o un pequeño desplazamiento negativo fijo desde el arreglo de punteros en los casos [-1], el cual no se alcanza porque la primera lectura ya falla), por lo que no existe divulgación ni corrupción de memoria más allá del bloqueo. Una vulnerabilidad similar con imágenes de dimensión cero que bloqueaban código C mediante la API pública se corrigió en ImagingOffset en 39d14a0db (GHSA-f87v-9578-p47w).

Reproducción

repro es un script de Python que utiliza la instalación existente de Pillow tal cual (sin recompilar). Ejecuta cada caso en un intérprete hijo independiente, con los volcados de memoria (core dumps) deshabilitados. De esta forma, el fallo de segmentación se refleja en el estado de salida del proceso hijo mientras el script continúa su ejecución. Cada hijo construye Image.new("L", (10, 10)).crop(box), imprime su tamaño, llama a im.filter(...) y muestra el tamaño resultante.

  • Control: un recorte de (1, 10) ((3, 0, 4, 10)) procesado mediante MedianFilter(3). El filtro de rango acepta imágenes estrechas y debe finalizar con normalidad.
  • PoC: los filtros de rango aplicados a recortes vacíos.
    • Un recorte de (0, 10) (left == right, (3, 0, 3, 10)) mediante MedianFilter(3) y MaxFilter(5).
    • Un recorte de (10, 0) (upper == lower, (0, 3, 10, 3)) mediante MinFilter(3).
    • Un recorte de (0, 0) ((3, 3, 3, 3)) mediante RankFilter(3, 4).

Salida esperada en la compilación estándar:

  crop(3, 0, 4, 10)    MedianFilter(3)    (1, 10) -> (1, 10)
  crop(3, 0, 3, 10)    MedianFilter(3)    (0, 10) -> killed by SIGSEGV
  crop(3, 0, 3, 10)    MaxFilter(5)       (0, 10) -> killed by SIGSEGV
  crop(0, 3, 10, 3)    MinFilter(3)       (10, 0) -> killed by SIGSEGV
  crop(3, 3, 3, 3)     RankFilter(3, 4)   (0, 0) -> killed by SIGSEGV
BUG

El tamaño impreso antes de -> muestra que la imagen vacía se creó sin errores. La caída ocurre dentro de filter(), en ImagingExpand. La última línea es BUG si el caso de control retornó correctamente y al menos un proceso hijo con filtro de rango finalizó por SIGSEGV. En caso contrario es OK. El script cubre solo la vía del recorte; la vía mediante TIFF descrita anteriormente falla del mismo modo (im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3)) en un TIFF de dos páginas abierto por ruta cuyo segundo IFD tiene ImageLength = 0). Con el parche adjunto, cada línea de la PoC muestra el tamaño sin cambios (por ejemplo (0, 10) -> (0, 10)) y el script imprime OK.

Corrección sugerida

El parche adjunto gestiona la entrada vacía en ImagingExpand (src/libImaging/Filter.c), inmediatamente después de las comprobaciones de margin y antes de la asignación de ImagingNewDirty y los bucles de replicación. Con xsize == 0 o ysize == 0 no hay píxeles de borde que duplicar, por lo que devuelve una imagen puesta a cero (ImagingNew, no ImagingNewDirty) del tamaño expandido, copiando la paleta de entrada. Esto mantiene el contrato de tamaño de ImagingExpand, de modo que ImagingRankFilter genera una salida de xsize - 2*margin × ysize - 2*margin, es decir, el tamaño vacío original, coincidiendo con los demás filtros. Esto replica los retornos tempranos para dimensiones cero ya presentes en src/libImaging/BoxBlur.c (127004368) y src/libImaging/Offset.c (39d14a0db):

diff
--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
         return (Imaging)ImagingError_ValueError("filter size too large");
     }
 
+    if (imIn->xsize == 0 || imIn->ysize == 0) {
+        /* No edge pixels to replicate (and no lines to read from), so
+           return a zeroed image of the expanded size */
+        imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+        if (!imOut) {
+            return NULL;
+        }
+        ImagingCopyPalette(imOut, imIn);
+        return imOut;
+    }
+
     imOut =
         ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
     if (!imOut) {

El parche también añade test_rankfilter_zero_dimension junto a test_rankfilter_error en Tests/test_image_filter.py, que filtra Image.new("L", size) para size en ((0, 6), (6, 0), (0, 0)) con MinFilter, MedianFilter, MaxFilter y RankFilter(3, 2), comprobando que se mantenga el tamaño. Tras aplicarlo a un repositorio limpio y recompilar, el reproductor muestra OK y Tests/test_image_filter.py pasa correctamente. Una comprobación a nivel de Python en RankFilter.filter (if 0 in image.size: return image.copy()) también evitaría la caída, pero la validación a nivel de C protege a cualquier otro invocador de expand().

El parche

Devolver una imagen inicializada a cero desde ImagingExpand cuando la entrada tiene ancho o alto cero, en lugar de leer punteros de línea NULL

Aplica a src/libImaging/Filter.c en el commit anterior.

diff
diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py
index 974d4b7a3..8dc8e1a1a 100644
--- a/Tests/test_image_filter.py
+++ b/Tests/test_image_filter.py
@@ -155,6 +155,23 @@ def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None:
         im.filter(filter).getpixel((1, 1))
 
 
+@pytest.mark.parametrize(
+    "filter",
+    (
+        ImageFilter.MinFilter,
+        ImageFilter.MedianFilter,
+        ImageFilter.MaxFilter,
+        ImageFilter.RankFilter(3, 2),
+    ),
+)
+@pytest.mark.parametrize("size", ((0, 6), (6, 0), (0, 0)))
+def test_rankfilter_zero_dimension(
+    filter: ImageFilter.RankFilter, size: tuple[int, int]
+) -> None:
+    im = Image.new("L", size)
+    assert im.filter(filter).size == size
+
+
 def test_rankfilter_properties() -> None:
     rankfilter = ImageFilter.RankFilter(3, 2)
 
diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c
index 3492a871f..d702db5f9 100644
--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
         return (Imaging)ImagingError_ValueError("filter size too large");
     }
 
+    if (imIn->xsize == 0 || imIn->ysize == 0) {
+        /* No edge pixels to replicate (and no lines to read from), so
+           return a zeroed image of the expanded size */
+        imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+        if (!imOut) {
+            return NULL;
+        }
+        ImagingCopyPalette(imOut, imIn);
+        return imOut;
+    }
+
     imOut =
         ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
     if (!imOut) {

El reproductor

<details> <summary>Reproducer — <code>repro</code>, 1,333 bytes (haga clic para abrir)</summary>

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.

python
#!/usr/bin/env python3
# Pillow: rank filters (Median/Min/Max/RankFilter) on a 0-wide or 0-high image
# SIGSEGV in ImagingExpand (src/libImaging/Filter.c) - NULL line pointer read.
# Each case runs in a child interpreter; BUG if the control returns and any
# zero-size case dies of SIGSEGV.
import signal, subprocess, sys

CHILD = """
import resource; resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop(%s)      # public API; crop accepts x0==x1 / y0==y1
print(im.size, "->", end=" ", flush=True)
print(im.filter(ImageFilter.%s).size)
"""

def run(box, flt):
    p = subprocess.run([sys.executable, "-c", CHILD % (box, flt)],
                       capture_output=True, text=True)
    out = p.stdout.strip()
    if p.returncode < 0:
        out += " killed by " + signal.Signals(-p.returncode).name
    print(f"  crop{box!s:<16} {flt:<18} {out}")
    return p.returncode

ctrl = run((3, 0, 4, 10), "MedianFilter(3)")          # (1,10): must work
rcs = [run((3, 0, 3, 10), "MedianFilter(3)"),        # (0,10)
       run((3, 0, 3, 10), "MaxFilter(5)"),
       run((0, 3, 10, 3), "MinFilter(3)"),           # (10,0)
       run((3, 3, 3, 3), "RankFilter(3, 4)")]        # (0,0)
bug = ctrl == 0 and any(rc == -signal.SIGSEGV for rc in rcs)
print("BUG" if bug else "OK")
</details>

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.

PlataformaPaqueteVersiones vulnerablesCorregida en
Python (PyPI) pillow < 13.0.0 13.0.0

Referencias

Puntuación CVSS -
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-476
Plataforma
Python (PyPI)
Paquete
pillow
Publicada
21.10.2026
Actualizado
22.10.2026 00:08

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

OSV / GitHub Advisory Database - GHSA-27h8-gf7j-v5hc - 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 (89) en el archivo
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

  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

antes: 12

Gravedad:
  1. Alta CVE-2026-112390 >= 7.2.0, < 13.0.0 13.0.0
  2. Pillow: Desreferencia de NULL en ImagingExpand mediante filtros de rango en imágenes de tamaño cero esta entrada
    Alta CVE-2026-112392 < 13.0.0 13.0.0
  3. Crítica CVE-2026-112900 >= 2.6.0, < 13.0.0 13.0.0
  4. Alta · 7.5 CVE-2026-59200 >= 5.1.0, < 12.3.0 12.3.0
  5. Alta CVE-2026-42311 >= 10.3.0, < 12.2.0 12.2.0
Mostrar más (20) Mostrando 20 de 69

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.