libwebp: DoS por Área de Imagen No Validada en el Decoder Lossless VP8L

Treinta y cuatro bytes. Eso basta para que un servidor de imágenes se pase segundos —o minutos— decodificando una foto que no existe. El decoder lossless de libwebp acepta declaraciones de hasta 16384×16384 píxeles sin comprobar el área total, justo el control que su hermano lossy sí aplica. Hallazgo del sistema autónomo Sherlock: un estudio de lo que ocurre cuando dos caminos de código que deberían ser gemelos no comparten las mismas reglas.

/índice_del_análisis +

00. Resumen Técnico

Objetivolibwebp 1.4.0 — decoder lossless VP8L (vp8l_dec.c)
VulnerabilidadAusencia de validación de área de imagen (width × height) antes de asignar buffers y procesar filas
CWECWE-400 (Uncontrolled Resource Consumption) · VRT: recurso/CPU
SeveridadLOW — consumo de CPU, sin corrupción de memoria
DisparadorWebP lossless de 34 bytes declarando dimensiones gigantes (≤16384×16384)
EfectoDecode con coste ∝ área declarada → timeout reproducible 3/3 con límite de 3 s
Causa raízReadImageInfo (VP8LDecodeImage, vp8l_dec.c:1764) no consulta MAX_IMAGE_AREA; el path lossy sí lo hace
ValidaciónDoble herramienta: libFuzzer (timeout instrumentado) + timeout de shell como control independiente (exit 124)
Estado fix upstreamSin parche conocido al cierre — divulgación graduada: mecanismo completo, receta byte a byte reservada

01. Contexto: libwebp Está en Todas Partes

Cada navegador, cada CDN moderna, cada servicio de optimización de imágenes y cada app móvil que muestra fotos pasa por libwebp en algún momento. Su modo lossless (chunk VP8L) promete compresión sin pérdida rivalizando con PNG, y es precisamente el modo elegido para capturas, iconos y gráficos.

La historia reciente de la biblioteca —CVE-2023-4863 y su eco en libvpx— demostró hasta qué punto el ecosistema entero hereda sus bugs. Este hallazgo es más modesto en impacto pero igual de revelador sobre el proceso: la memoria de los CVEs pasados no garantiza que todos los caminos del código hayan aprendido la misma lección.

02. La Asimetría Lossy vs Lossless

El formato WebP tiene dos motores de decodificación: el predictivo con pérdida (VP8, heredado de WebM) y el entrópico sin pérdida (VP8L). Ambos terminan entregando píxeles al mismo cliente, pero nacieron de linajes distintos y eso se nota en sus defensas: el path lossy comprueba que el producto width × height no supere el límite razonable de la biblioteca (MAX_IMAGE_AREA) antes de comprometerse; el lossless lee anchura y altura del header y arranca sin preguntar cuántos píxeles implica esa multiplicación.

header WebP width=16000 · height=16000 (34 B) ✓ PATH LOSSY (VP8) if (width * height > MAX_IMAGE_AREA) → rechazo temprano · coste O(1) error limpio, proceso sano ✗ PATH LOSSLESS (VP8L) ReadImageInfo: dims ≤ 16384 c/u → OK ningún check de area · coste O(área) DecodeImageData → ProcessRows → EmitRows 256M píxeles fantasma · CPU 100% timeout > 3 s reproducible 3/3 256M píxeles nunca se procesan: el check existe y funciona.
Figura 1 — Mismo header mentiroso, dos destinos. A la izquierda, el path con pérdida rechaza en tiempo constante. A la derecha, el lossless convierte 34 bytes en millones de operaciones de emisión de filas.

03. Anatomía: La Cadena que Nadie Frena

El recorrido desde la API pública hasta el bucle caro deja ver exactamente dónde se coló el agujero:

# Cadena de llamadas observada en el stack del hang LLVMFuzzerTestOneInput (fuzz_webp_decode.c:9) → Decode (webp_dec.c:631) → DecodeInto (webp_dec.c:511) → VP8LDecodeImage (vp8l_dec.c:1764) → ReadImageInfo # acepta width×height ≤ 16384² SIN check de área ← aquí falta el guardiaDecodeImageData (vp8l_dec.c:1206) → ProcessRows (vp8l_dec.c:837) → EmitRows (vp8l_dec.c:654) → ConvertBGRAToRGBA_SSE2 (lossless_sse2.c) # tiempo ∝ píxeles declarados

El check ausente tiene forma trivialmente conocida —el mismo patrón que su gemelo lossy ya aplica—:

// Guardia esperada en el path VP8L (presente en el path lossy): if ((uint64_t)width * height > MAX_IMAGE_AREA) return VP8_STATUS_OUT_OF_MEMORY;

Nótese el detalle de buen artesano que importa al corregir: la multiplicación debe hacerse en aritmética de 64 bits o con comprobación previa, porque 16384 × 16384 = 268.435.456 cabe justo en 32 bits —pero variantes con dimensiones máximas distintas podrían desbordar la propia comprobación. Los fixes apresurados que multiplican en 32 bits crean la siguiente generación de bugs.

04. El PoC de 34 Bytes

El archivo trampa sigue la anatomía mínima de un WebP lossless: cabecera RIFF con el chunk VP8L, y dentro de él, el bit de firma lossless seguido de los campos de anchura, altura y versión codificados en la tabla de bits inicial. Lo único que importa es la pareja dimensional: valores cercanos al tope permitido por el formato, cuya multiplicación produce cientos de millones de píxeles «virtuales» que el decoder intentará materializar fila a fila.

# Estructura conceptual (receta completa reservada hasta parche upstream) RIFF.... # contenedor estándar VP8L.... # chunk lossless firma 0x2f # bit de formato lossless width-1 = 14 bits # valor alto declarado height-1 = 14 bits # valor alto declarado alpha/version...# resto del mini-header # Total: 34 bytes. Coste de decode declarado: ~256M píxeles.
Divulgación graduada

Al publicar este análisis no conocemos parche upstream aplicado. Publicamos el mecanismo, la cadena de llamadas y la estructura del archivo, pero no el binario ni el volcado hexadecimal completo: suficiente para reproducir, auditar y corregir, sin servir el disparador listo para usar.

05. Evidencia de Validación

Para un DoS temporal, la doble herramienta toma otra forma: el fuzzer instrumentado detecta el exceso de tiempo, y un control sin instrumentación —el clásico timeout(1) de shell— confirma que el hang pertenece al decode real, no al harness.

Terminal mostrando libFuzzer reportando timeout tras 3 segundos al decodificar el WebP lossless de 34 bytes
Evidencia A — libFuzzer: «ERROR: libFuzzer: timeout after 3 seconds» sobre el PoC de 34 B. Reproducido 3/3 ejecuciones.
Stack de gdb detenido en la cadena DecodeImageData, ProcessRows y EmitRows del decoder VP8L durante el hang
Evidencia B — Stack del hang capturado con gdb: DecodeImageData (vp8l_dec.c:1206) → ProcessRows (:837) → EmitRows (:654) → conversión SSE2. El bucle de emisión de filas es el consumidor de CPU.
# Tool B — control independiente sin instrumentación $ timeout 3 ./fuzz_webp_decode -runs=1 poc_34b.webp ; echo rc=$? rc=124 # 124 = killed by timeout: el decode no termina solo # Contraste con una imagen legítima equivalente (sanity check) $ timeout 3 ./fuzz_webp_decode -runs=1 foto_normal.webp ; echo rc=$? Executed 1 inputs... rc=0 # decode correcto en milisegundos

06. Acotación: Por Qué es LOW

El valor del hallazgo

Más allá del impacto individual, este caso entrega al sistema una firma reutilizable: «comparar defensas entre paths gemelos». Donde dos implementaciones resuelven el mismo problema, cualquier control presente en una y ausente en la otra es candidato automático a hallazgo. Esa heurística ya está alimentando nuevas campañas de análisis.

07. Impacto Realista y Mitigación

El escenario de explotación natural es cualquier servicio que decodifique WebP subido por usuarios: plataformas que generan miniaturas, antivirus y DLP que reconstruyen imágenes para analizarlas, apps de mensajería que preprocesan stickers. Con concurrencia, cada archivo de 34 bytes ocupa un worker durante segundos; una cola pequeña de ellos degrada el servicio entero.

08. Conclusión

Los sistemas grandes no fallan por ignorancia global sino por inconsistencia local: alguien aprendió la lección del área máxima en un camino del código y nadie la llevó al otro. Treinta y cuatro bytes son suficientes para notar la diferencia. La próxima vez que audites un parser con múltiples modos, compara sus modos entre sí —la lista de controles que uno tiene y el otro no suele leerse como una lista de hallazgos pendientes.

/Más hallazgos de la serie Sherlock

Siete vulnerabilidades confirmadas en un mes de caza autónoma con modelos locales de IA. Todos los análisis con evidencia y diagramas.

09. Referencias