FFmpeg: Tres Demuxers, Un Mismo Pecado — SIGFPE en VPK, Memory Leak en VQF y Hang en Smacker

Tamaños absurdos que nadie valida. Contadores que nadie contrasta con el tamaño real del archivo. Memoria que se pide y nunca se devuelve. Este artículo agrupa tres hallazgos del sistema autónomo Sherlock contra demuxers de FFmpeg —VPK (división por cero, CWE-369), VQF (leak escalable, CWE-401) y Smacker (bucle sin techo, CWE-400)— porque los tres comparten la misma lección: el header del contenedor se parsea antes de validarse. Uno ya tiene parche en 8.1.2; los otros dos siguen abiertos.

/índice_del_análisis +

00. Resumen Técnico

#DemuxerBugCWEPoCEfectoEstado fix
Alibavformat/vpk.c División por cero en vpk_read_packet (vpk.c:89) CWE-36921 B SIGFPE → crash determinista del proceso (repro 3/3) Parcheada en 8.1.2
Blibavformat/vqf.c av_malloc(tag_len) sin av_free en error path (vqf.c:64) CWE-40113 B Leak que escala con tag_len: medido hasta ~677 MB por archivo Sin parche en 8.1.2
Clibavformat/smacker.c Bucle for(i<frames) sin validar contra tamaño del archivo (smacker.c:225) CWE-400105 B Hang de CPU procesando 13.565.951 frames lógicos (timeout 3/3) Sin parche en 8.1.2

Las tres pruebas se validaron con la regla de las dos herramientas (sanitizer + confirmador independiente) y descarte sistemático de hipótesis alternativas. Todas afectaban la rama 7.1.1 al momento del hallazgo; el fix de VPK llegó después en 8.1.2.

01. Por Qué los Demuxers son una Mina

FFmpeg es el traductor universal de medios: servidores de streaming, pipelines de transcodificación, generadores de miniaturas, analizadores forenses, bots de Telegram y Discord —todos terminan confiando sus entradas a libavformat. Y el primer contacto con cualquier archivo es siempre el mismo: el demuxer, que lee el header del contenedor y decide qué hacer con el resto.

Ese primer contacto es crítico porque ocurre antes de cualquier decodificación costosa y antes de que el sistema tenga motivos para desconfiar. Formatos exóticos como VPK (paquetes de sonido de videojuegos), VQF (audio TwinVQ de los 90) o Smacker (vídeo FMV de juegos DOS) tienen parsers menos transitados que MP4 o MKV, y menos tránsito suele significar menos ojos. El fuzzing autónomo no distingue formatos fashion de formatos olvidados: los barre todos con la misma vara.

02. El Patrón Común: Parsear Antes de Validar

Los tres bugs pertenecen a clases distintas (aritmética, gestión de recursos, complejidad algorítmica) pero nacen de la misma decisión de diseño: confiar en campos del header controlados por el atacante sin contrastarlos ni entre sí ni contra el tamaño real del input.

input malformado 21 B · 13 B · 105 B — solo headers VPK · vpk.c:89 nb_channels = 0 confiado last_block_size / nb_channels → IDIV → SIGFPE (crash) CRASH · CWE-369 VQF · vqf.c:64 tag_len confiado → av_malloc error path sin av_free → leak escala hasta ~677 MB LEAK · CWE-401 SMK2 · smacker.c:225 frames = 13565951 confiado for(i < frames) sin techo vs 105 B reales de archivo HANG · CWE-400 Tres clases de bug, una misma causa raíz: el header se parsea antes de validarse.
Figura 1 — La familia al completo: un input minúsculo entra al demuxer; cada parser convierte su campo no validado en un modo de fallo distinto.

03. VPK — División por Cero (CWE-369) · Parcheada

El formato VPK guarda audio por bloques. Al leer un paquete, el demuxer calcula el tamaño del último bloque dividiendo entre el número de canales declarado en el header. Si ese número llega a cero —y nada en el código lo impide— la instrucción de división entera de x86 (IDIV) lanza una excepción aritmética que mata el proceso:

// libavformat/vpk.c:89 (rama vulnerable) size = last_block_size / par->ch_layout.nb_channels; // nb_channels == 0 → SIGFPE # Fix aplicado upstream en 8.1.2: if (par->ch_layout.nb_channels <= 0) return AVERROR_INVALIDDATA;

El PoC completo cabe en lo que tarda en parpadear un cursor: 21 bytes con el magic VPK y nb_channels=0. Como este caso sí está parcheado en la versión estable actual, aquí va el volcado íntegro capturado durante la validación:

# poc_vpk_fpe.bin (21 bytes) 204b 5056 f872 b900 01ff 72f8 5650 4b29 2a20 5320 53
Terminal mostrando el reporte de AddressSanitizer: FPE on unknown address en vpk_read_packet, libavformat/vpk.c:89
Evidencia A — ASAN: «FPE on unknown address» en vpk_read_packet (libavformat/vpk.c:89:46). Reproducido 3/3 ejecuciones consecutivas; gdb confirmó el divisor exacto.
# Confirmación independiente con gdb (build instrumentada) $ gdb -batch -ex run --args fuzz_target -runs=1 poc_vpk_fpe.bin Program received signal SIGFPE, Arithmetic exception. vpk_read_packet (...) at libavformat/vpk.c:89 89: size = last_block_size / par->ch_layout.nb_channels; (gdb) p par->ch_layout.nb_channels $1 = 0

04. VQF — Memory Leak Escalable (CWE-401) · Sin Parche

Los metadatos del formato VQF usan etiquetas longitud-valor donde la longitud (tag_len) viene en el propio header. El helper add_metadata reserva esa cantidad con av_malloc y, si la lectura posterior falla —algo trivial de forzar declarando un tag_len mayor que el archivo—, devuelve error sin liberar el buffer:

// libavformat/vqf.c:64 — camino del leak (esqueleto) static int add_metadata(...) { ptr = av_malloc(tag_len); // adquisición controlada por el header if (avio_read(pb, ptr, len) != len) // falla si len > bytes disponibles return AVERROR(EIO); // ← return SIN av_free(ptr) → CWE-401 } // caller: vqf_read_header (vqf.c:173)

Lo que convierte un leak mundano en un problema operativo es la escalabilidad: tag_len es un entero del header, así que cada archivo malformado puede ordenar reservas enormes antes de fallar. En las mediciones, metadata gigante produjo fugas medidas entre 285 MB y 677 MB por archivo —el techo teórico ronda el gigabyte—. Un servicio que procese archivos subidos por usuarios puede ser agotado de memoria con una cola modesta de archivos de trece bytes.

Terminal mostrando el reporte de LeakSanitizer: bloque asignado en av_malloc dentro de add_metadata vqf.c:64 sin liberar, con caller vqf_read_header vqf.c:173
Evidencia B — LeakSanitizer: bloque originado en av_malloc de add_metadata (libavformat/vqf.c:64:11), caller vqf_read_header (vqf.c:173:19). Valgrind confirmó el mismo malloc-sin-free de forma independiente.
Divulgación graduada

Este bug sigue abierto en 8.1.2. Publicamos el mecanismo completo y la evidencia, pero no el binario del PoC ni su receta byte a byte: basta la descripción para que el mantenedor reproduzca y corrija, y evitamos servir munición lista mientras no exista parche.

05. Smacker — Bucle de 13,5 Millones de Frames (CWE-400) · Sin Parche

El demuxer Smacker lee el contador frames del header y reserva tablas iterando hasta ese valor. Existe un check —frames < 0xFFFFFF— pensado precisamente para cortar valores absurdos, pero tiene una rendija aritmética: compara contra una constante, no contra el tamaño del archivo. Declarar 13.565.951 frames (menos de 16M) pasa el check sin despeinarse, y luego el parser intenta dar 13,5 millones de vueltas procesando tablas… alimentado por un archivo de 105 bytes.

// Header del PoC (poc_smk_hang.bin, 105 B) magic = SMK2 width = 0 height = 0xFE000000 frames = 13565951 # 13.5M < 0xFFFFFF → el check existente NO aplica fps = 0 // libavformat/smacker.c:225 for (i = 0; i < smk->frames; i++) // 13.5M iteraciones sobre 105 B de input ... // → CPU al 100%, hang indefinido

La regla de oro que falta aquí es simple: ningún contador derivado del header puede superar proporcionalmente el tamaño del input. Trece millones de frames exigen, como mínimo, trece millones de estructuras de tabla en algún sitio; si el archivo pesa 105 bytes, alguien miente.

Terminal mostrando el timeout de libFuzzer tras ejecutar el PoC Smacker: SUMMARY libFuzzer timeout, con stack de gdb apuntando a smacker_read_header smacker.c:225
Evidencia C — libFuzzer: «SUMMARY: libFuzzer: timeout» con el PoC de 105 B (magic SMK2), reproducible 3/3. gdb situó el stack caliente en smacker_read_header (smacker.c:225).

06. Metodología: Dos Herramientas y Ocho Hipótesis Alternativas

Cada hallazgo pasó por el mismo embudo antes de ganarse su ficha: reproducción determinista (3/3), doble herramienta de verificación y descarte explícito de explicaciones alternativas. Las hipótesis que el sistema evaluó y mató por cada caso:

CasoHipótesis alternativaPrueba de descarte
VPKFalso positivo del fuzzerRepro manual 3/3 + stack en gdb
Comportamiento by designUn VPK válido declara nb_channels ≥ 1; el header malformado lo fuerza a 0
Artefacto de build instrumentadaSIGFPE es señal de hardware (IDIV), no del sanitizer
VQFRuido de LeakSanitizerValgrind detectó el mismo malloc sin free
Solo ocurre con inputs raros del corpusEl error path se alcanza con cualquier tag_len > tamaño disponible
Leak acotado e irrelevanteMedición directa: 285–677 MB de fuga por archivo con tag_len grande
SmackerEl check de frames protege13.5M < 16M: el umbral existente no cubre este rango
El loop termina pronto por EOFNinguna salida anticipada: timeout sostenido 3/3 con CPU saturada

07. Impacto Realista y Mitigación

Ninguno de los tres es corrupción de memoria, pero los tres rompen la disponibilidad —la tercera pata de la tríada— y lo hacen con inputs ridículamente pequeños. El escenario común: servicios multiusuario que demuxan contenido externo (plataformas de vídeo bajo demanda con transcodificación automática, analizadores de malware que procesan muestras, bots que generan previsualizaciones). En todos ellos, un worker muerto por SIGFPE, un pool agotado por leaks acumulativos o un hilo colgado eternamente en un bucle se traducen en latencia para todos los usuarios.

08. Conclusión

Agrupar estos tres hallazgos en un solo artículo es deliberado: por separado parecen anécdotas de formatos exóticos; juntos dibujan una debilidad estructural del proceso de parseo de headers que se repite en demuxer tras demuxer. Es exactamente el tipo de patrón que un sistema de caza autónomo explora bien: no busca un bug, busca la forma en que una base de código piensa mal y la empuja hasta que se rompe en cada esquina posible.

Para quien audite parsers propios, las tres preguntas que estos casos obligan a hacerse: ¿qué campos del header uso en aritmética sin verificar? ¿qué reservo que pueda quedar huérfano en un error path? ¿qué bucle cuya duración depende del input carece de techo? Si alguna respuesta incomoda, hay un PoC de veintiún bytes esperando a ser escrito.

/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