Pillow: Desreferencia de NULL en ImagingExpand mediante filtros de rango en imágenes de tamaño cero
============================================================================
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()yImage.new();- un TIFF multipágina abierto por ruta cuyo segundo IFD declara
ImageLength = 0, sobre el cualseek(1)yload()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:
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:
# 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:
// 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:
// 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 == rightoupper == lowergenera una imagen de 0×N / N×0.ImageOps.crop(im, border)conborder == width // 2, yImage.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), peroTiffImagePlugin._setup()tomaImageWidth/ImageLengthde cualquier IFD posterior tal cual enseek()(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) yImage.core.map_bufferconstruye una imagen central8×0sin errores (src/map.c:127-141), por lo queim.seek(1); im.load()tiene éxito y una llamada posterior aim.filter(ImageFilter.MedianFilter(3))produce un fallo de segmentación enImagingExpandexactamente 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 medianteMedianFilter(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)) medianteMedianFilter(3)yMaxFilter(5). - Un recorte de
(10, 0)(upper == lower,(0, 3, 10, 3)) medianteMinFilter(3). - Un recorte de
(0, 0)((3, 3, 3, 3)) medianteRankFilter(3, 4).
- Un recorte de
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):
--- 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 --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.
#!/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")
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 | < 13.0.0 | 13.0.0 |
Referencias
- https://github.com/python-pillow/Pillow/security/advisories/GHSA-27h8-gf7j-v5hc
- https://nvd.nist.gov/vuln/detail/CVE-2026-112392
- https://github.com/python-pillow/Pillow/commit/443e3130a8c11594fd0e296afad497e11e678961
- 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-476- Plataforma
- Python (PyPI)
- Paquete
pillow- Publicada
- 21.10.2026
- Actualizado
- 22.10.2026 10:00
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.
- 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: Desreferencia de NULL en ImagingExpand mediante filtros de rango en imágenes de tamaño cero 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.