1. Resumen de Inteligencia
Sherlock es un sistema de descubrimiento y validación de vulnerabilidades que opera de forma continua sobre objetivos open source seleccionados por su superficie de ataque: parsers de archivos, demuxers multimedia, motores de expresiones regulares y librerías de compresión. La tesis es simple: la mayor parte del trabajo de bug hunting es repetible —construir harnesses, lanzar campañas de fuzzing, hacer triage de crashes, verificar reproducibilidad, descartar duplicados— y todo lo repetible se puede automatizar con agentes de IA bien dirigidos.
Lo que la IA no sustituye es el juicio: decidir si un crash importa, cuál es su causa raíz, si tiene arreglo upstream y qué vale la pena madurar. Por eso el sistema separa músculo (ejecución masiva, correlación de logs, redacción de fichas) de criterio (políticas de severidad, verificación CVE, decisiones de escalada). El resultado del primer mes: siete hallazgos LOW confirmados y documentados, tres de ellos aún sin fix upstream al momento de escribir este artículo, y cero falsos positivos llegaron a la fase de reporte.
Serie completa de hallazgos
Este artículo es el centro de la serie. Cada hallazgo tiene su propio análisis técnico con evidencias, fragmentos de PoC y diagramas: 7-Zip IsFolderEncrypted , FFmpeg VPK/VQF/Smacker , libwebp VP8L , PCRE2 JIT y libarchive CAB/LZX .
2. De SAINT GRAIL a Sherlock: Por Qué un Cazador Autónomo
SAINT GRAIL nació como plataforma de investigación asistida: un humano dirige, la IA documenta, visualiza y ejecuta PoCs. Su migración a Go (ver resultados ) demostró que el enfoque funcionaba para investigación dirigida —los PoCs de TOCTOU en Chromium y CRC bypass en 7-Zip salieron de ese ciclo—. Pero asistida significa que el cuello de botella soy yo: entre sesiones, entre campañas, entre ideas.
Sherlock invierte el modelo. El sistema propone objetivos según criterios públicos (superficie de parsers, densidad histórica de CVEs, actividad upstream), construye sus propios harnesses de fuzzing, ejecuta campañas largas mientras la máquina no hace nada más, y solo escala a atención humana cuando algo supera el filtro de validación. La diferencia práctica es brutal: donde antes había sesiones de caza de unas horas, ahora hay presencia de caza permanente. Los hallazgos no llegan cuando busco; llegan cuando ocurren.
Hay una segunda motivación, menos obvia: formar criterio de severidad honesto . La industria del bug bounty está llena de inflación —un hang se reporta como DoS crítico, un leak teórico como RCE—. Decidí que Sherlock publicaría con severidad estricta: si es un DoS limpio, es LOW; si no hay primitiva estable hacia control de flujo, no es HIGH aunque el crash sea espectacular. Esta serie es también un ejercicio de calibración pública.
3. Arquitectura del Sistema
La arquitectura tiene cuatro capas: objetivos (qué cazar), generación (cómo meter inputs), validación (qué es real), y memoria (qué se aprende). Todo corre en hardware propio; ningún byte de los objetivos ni de los crashes sale de la máquina salvo en forma de reportes ya depurados.
Figura 1 — Pipeline completo. Las cajas con borde verde son puntos donde el criterio pesa más que el músculo. El bucle inferior devuelve aprendizaje al objetivo: cada hallazgo afina la siguiente campaña.
Tres decisiones de diseño explican la mayoría del rendimiento:
Harnesses generados por agentes con revisión estructural: los workers escriben drivers de libFuzzer leyendo la API pública del objetivo; un paso de verificación comprueba que el driver compila contra las versiones objetivo y que el corpus semilla ejercita rutas reales (cobertura medida, no supuesta).
Señales amplias, validación estrecha: la campaña captura cualquier anomalía (crash, hang, leak, slow-unit); el triage solo promueve lo que reproduce con dos herramientas independientes. Todo lo demás vuelve al corpus como ruido etiquetado.
CVE-check antes de celebrar: cada candidato pasa por diffing contra upstream y búsqueda en NVD/GHSA. En un mes evitó celebrar al menos tres "descubrimientos" que eran CVEs conocidos o fixes recientes —el sistema los reclasifica como REFERENCE y aprende el patrón.
4. Modelos Locales: Pequeños, Especializados, Siempre Despiertos
El corazón generativo son modelos locales servidos on-premise con Ollama : derivados afinados de Llama 3.2 3B con prompts de sistema estrictos por rol —un worker genera harnesses, otro redacta fichas de hallazgo en formato rígido, otro resume logs de campañas—. Contexto de 4096 tokens, temperatura baja (0.25) y muestreo conservador: en esta tarea no queremos creatividad, queremos cumplimiento de formato y fidelidad al input.
# Perfil de un worker local (Modelfile resumido)
FROM llama3.2:3b
PARAMETER temperature 0.25
PARAMETER num_ctx 4096
SYSTEM """
Eres un worker de triage. Recibes un log de crash.
Devuelve SOLO JSON válido con: cwe_suspect, frame_sospechoso,
tool_origen, reproducible_si/no, siguiente_comando.
Prohibido inventar símbolos que no estén en el log.
"""
¿Por qué 3B y no un modelo gigante? Tres razones. Primero, latencia y coste : un mes de campaña genera miles de eventos; clasificarlos con un modelo enorme sería caro y lento, y la tarea (extraer campos de un log sin inventar nada) no necesita capacidad de razonamiento profunda. Segundo, determinismo operativo : con keep-alive infinito y flash attention, los workers responden en milisegundos y nunca duermen. Tercero, privacidad : los crashes contienen mapas de memoria de software de terceros; no hay motivo para enviarlos a nadie.
Para las pocas tareas que sí requieren músculo —diseñar una estrategia de grooming de heap, analizar un diff complejo— el orquestador escala a un modelo externo potente (DeepSeek, ya integrado públicamente en SAINT GRAIL ), pero con datos mínimos y solo bajo decisión explícita. La regla es lo local por defecto, lo remoto por excepción .
Lección contraintuitiva
Los modelos pequeños con roles estrechos y formato obligado fallan menos que uno grande con libertad total. Cuando la salida correcta es "JSON con cinco campos", la inteligencia general sobra y la disciplina falta. Afinar el prompt vale más que agrandar el modelo.
5. El Ciclo de Vida de un Hallazgo
Cada candidato atraviesa estados explícitos. Nada salta pasos: un crash que no puede pasar de LEAD a VALIDANDO muere en silencio y alimenta estadísticas, no reportes.
Figura 2 — Máquina de estados. La ruta verde exige reproducibilidad cruzada, ficha completa y verificación de novedad. REFERENCE captura los duplicados: no son fracasos, son entrenamiento del filtro.
6. Toolchain de Validación: Dos Herramientas o No Es Real
La regla de oro del sistema: ningún hallazgo se declara CONFIRMED sin reproducirse con al menos dos instrumentos independientes . Un fuzzer que dice timeout no basta —hay que verlo colgado desde fuera (timeout de shell), ver el stack con gdb, o detectarlo con sanitizadores distintos. Así se eliminaron todos los falsos positivos del primer mes:
Señal Instrumento primario Validador independiente Ejemplo real
Crash de memoria
Build ASAN (heap-buffer-overflow, FPE)
valgrind memcheck + stack en gdb
OOB read en 7-Zip
Hang / CPU burn
libFuzzer -timeout=N
timeout POSIX (exit 124) + gdb attach
VP8L en libwebp , Smacker en FFmpeg
Fuga de memoria
LeakSanitizer
valgrind --leak-check=full
add_metadata en FFmpeg VQF
Memoria no inicializada
A/B contra build parcheada upstream
Divergencia de exit code con mismo input
history guard CAB/LZX
Bucle de compilación
Fuzzer JIT con timeout
Control sin JIT termina en 0 ms → aísla la fase
get_framesize en PCRE2
Completan la caja de herramientas el patch-diffing sistemático (git diff entre versiones objetivo para saber qué cambió y qué no), la consulta inversa de advisories (GHSA/NVD) para evitar duplicados, y builds gemelas ASAN + producción de cada objetivo: la primera para diagnosticar, la segunda para demostrar que el bug afecta al binario que usa gente real, no solo al instrumentado.
7. Política de Severidad Honesta: Por Qué Publicamos los LOW
La política interna es RCE-first : HIGH exige ejecución de código o primitiva de escritura estable hacia control de PC; MEDIUM exige corrupción de metadatos del allocator o lectura cruzada explotable; todo lo demás —hangs, leaks, FPE limpios, lecturas de 1 byte fuera de límites— es LOW y se trata como tal. Bajo esa regla, seis de los siete hallazgos del mes son LOW, y así se publican.
¿Por qué invertir tiempo en publicar LOWs? Porque el valor de un LOW honesto supera su puntuación: documentan clases enteras de error (validación de dimensiones ausente, error path sin free, bucle sin tope derivable del tamaño del archivo) que aparecen una y otra vez en software real, y porque el DoS barato sigue siendo munición de denegación de servicio contra servicios que parsean archivos de usuarios —proxies de medios, antivirus de correo, plataformas que aceptan uploads—. Un hang reproducible con 34 bytes merece ser conocido.
Calibración pública
Cada ficha de esta serie declara su severidad, su estado de fix upstream y su clase CWE. Cuando el fix llega, se actualiza. Cuando el hallazgo resulta duplicado de un advisory reciente, se marca REFERENCE con la ventana de detección —como ocurrió con un caso de libarchive detectado a siete días de su fix upstream—.
8. La Cosecha de Agosto: Inventario de Hallazgos
Siete hallazgos confirmados, cinco objetivos, seis artículos técnicos con evidencias completas. Todos reproducibles, todos con ficha, generador de PoC y análisis de causa raíz:
Objetivo Vulnerabilidad CWE Estado fix Análisis
7-Zip 26.00–26.02
Heap OOB read en IsFolderEncrypted por re-parse divergente de CodersData
CWE-125
Sin fix / sin CVE
Artículo →
FFmpeg 7.1.1 (demuxer VPK)
División por cero → SIGFPE determinista
CWE-369
Parcheado en 8.1.2
Artículo →
FFmpeg 7.1.1 (demuxer VQF)
Memory leak en error path de add_metadata, escala hasta ~1 GB
CWE-401
Sin fix
FFmpeg 7.1.1 (demuxer Smacker)
Bucle de 13.5M frames declarados en archivo de 105 B
CWE-400
Sin fix
libwebp 1.4.0
Decode VP8L lossless sin check de área máxima → CPU burn
CWE-400
Verificación upstream pendiente
Artículo →
PCRE2 10.48
Compilación JIT no termina con recursión dentro de negative lookahead
CWE-400
Verificación upstream pendiente
Artículo →
libarchive 3.8.9
Lectura de heap no inicializada en history guard del path CAB/LZX
CWE-457
Fix upstream reciente (ventana 7 días)
Artículo →
Además del inventario público, el sistema acumula leads activos en estados tempranos (un binder expuesto en un stack móvil, un caso BLE de enumeración GATT sin emparejamiento en entorno controlado, una escalada write-primitive en curso sobre FreeType). Esos no se publican hasta validarlos —y algunos quizá nunca lo merezcan.
9. Divulgación Responsable: Qué Se Publica y Qué No
Publicar hallazgos encontrados por un sistema autónomo obliga a ser más estricto que nunca con qué se cuenta. La política editorial de esta serie:
Mecanismo siempre, arma nunca: cada artículo explica la causa raíz con el detalle suficiente para que un mantenedor la entienda y la arregle, pero los PoCs completos y generadores listos para ejecutar se reservan para issues privadas a mantenedores mientras no exista fix.
Graduación según estado del fix: el caso de FFmpeg VPK (parcheado en 8.1.2) permite mostrar hexdump y evidencia completa; los casos sin fix describen estructura y comportamiento sin servir el archivo malicioso masticado.
Evidencia redactada: las capturas de terminal de la serie provienen de logs reales de validación, regenerados con rutas de build anonimizadas. Nada de infraestructura personal, nombres internos ni tooling privado aparece en ninguna imagen.
Nada de terceros: ningún hallazgo contra servicios ajenos, dispositivos identificables o infraestructura real se publica aquí; eso, si llega algún día, va por canales de coordinación correspondientes.
La línea
Un sistema autónomo que caza bugs puede convertirse en fábrica de exploits si alguien le quita la ética del bucle. La decisión de publicar, reportar o archivar es y seguirá siendo humana. La IA propone; el criterio dispone.
10. Lecciones Operando la Malla IA
Un mes de operación continua deja aprendizajes que no aparecen en los papers:
El dedupe es el producto. De cada diez señales interesantes, seis son duplicados de advisories recientes y dos son falsos positivos del harness. El sistema que mejor filtra gana, no el que más encuentra.
Los timeouts enseñan más que los crashes. Los SIGFPE y OOB reads vienen con stack claro; los hangs obligan a entender qué fase se cuelga (¿matching runtime o compilación JIT?, ¿decodificación o indexación?) —y ahí vive el insight interesante, como muestran los casos de PCRE2 y Smacker .
La fecha del fix upstream cambia todo. Distinguir 0-day de dup-race exige leer diffs con fecha, no solo buscar el texto del advisory. Una divergencia detectada a siete días del fix es otra historia que una detectada a siete meses.
Formato rígido de salida = fiabilidad medible. Exigir JSON con campos fijos a los workers permitió medir tasas de error y corregir prompts como quien corrige tests, sin drama.
El hardware que sobra por la noche es un activo. Las campañas aprovechan horas valle; el coste marginal de una noche más de fuzzing es cero y los hallazgos no entienden de horarios.
11. Conclusión
Sherlock demuestra que una operación de vulnerability research sostenida no requiere un equipo ni un presupuesto cloud: requiere disciplina de validación , modelos locales baratos bien dirigidos y una política de severidad que no mienta. Los siete LOW de este mes no van a salir en titulares, y precisamente por eso importan: son la base estadística honesta sobre la que construir la búsqueda de los MEDIUM y HIGH que sí cambian risk assessments.
La próxima iteración del sistema apunta a tres frentes: escalada automática de leads con hipótesis de groom de heap, integración del grafo de conocimiento de SAINT GRAIL para correlacionar hallazgos entre objetivos, y campañas dirigidas por cobertura diferencial entre versiones. La cosecha seguirá siendo pública, con severidad honesta y sin armas servidas en bandeja.
/Explora los hallazgos de la serie
Cada vulnerabilidad tiene su análisis técnico completo: causa raíz, evidencia reproducible, fragmentos de PoC y diagramas del mecanismo.
7-Zip: OOB Read →
FFmpeg: VPK · VQF · Smacker →
libwebp: VP8L DoS →
PCRE2: JIT Hang →
libarchive: Uninit Read →
SAINT GRAIL: La Plataforma →
SAINT GRAIL Go: Resultados →
12. Referencias
Continuidad. Lectura fuera del heap por un parser que reinterpreta lo que otro ya consumio.
// En 30 segundos
Un archivo .7z de 24 bytes manipulados hace que el binario más instalado del mundo lea memoria que no le pertenece: heap OOB read confirmado en 7-Zip 26.00 · 26.01 · 26.02 .
Dos parsers, una estructura: ReadFolder consume el flag 0x10 de CodersData; IsFolderEncrypted lo ignora, se desincroniza byte a byte y compara contra k_AES valores leídos tras el final del buffer.
Disparador: 7zz l -slt o 7zz x (consultan kpidEncrypted). Clase: CWE-125 .
Sin CVE, sin parche, ausente de los advisories GHSL-2026-116…122. Evidencia triple: ASAN + valgrind (build producción) + gdb, x86 y x64. Severidad honesta: LOW .
13. Resumen Técnico
Objetivo 7-Zip / 7-Zip ZS — binario CLI 7zz, versiones 26.00 · 26.01 · 26.02
Vulnerabilidad Heap buffer over-read en NArchive::N7z::CHandler::IsFolderEncrypted (7zHandler.cpp:308)
CWE CWE-125 (Out-of-bounds Read) · VRT: MCR (Memory Corruption — Read)
Severidad LOW — solo lectura, 1–6 bytes controlables, sin camino directo a escritura
Disparador 7zz l -slt archivo.7z y 7zz x archivo.7z (consultan kpidEncrypted)
Novedad Sin CVE asignado y ausente de los advisories GHSL-2026-116…122 (parcheados en 26.01); git diff 26.01..26.02 confirma que 7zHandler.cpp no cambió
Validación ASAN heap-buffer-overflow + valgrind invalid read sobre builds gemelas (instrumentada y producción), x86 y x64
Estado fix upstream Sin parche al cierre de este análisis — divulgación graduada: mecanismo sí, generador completo no
14. Contexto: por qué 7-Zip importa
7-Zip está en todas partes: escritorios Windows, gateways antivirus que descomprimen adjuntos, pipelines CI/CD que extraen artefactos, appliances de correo. Cuando un parser de archivos comprimidos lee de más, la pregunta inmediata es ¿quién más lo hace? — porque cada producto que incruste el código afectado hereda el bug. La racha de advisories GHSL-2026-116…122 sobre 7-Zip 26.00 demostró que la superficie sigue siendo fértil; todos esos fueron parcheados en la serie 26.01.
Este hallazgo no está entre ellos. El diff entre 26.01 y 26.02 no toca el handler del formato 7z, y las pruebas confirman que el crash reproduce igual en las tres versiones. Es decir: un bug de clase similar a los ya corregidos, pero en una ruta distinta que nadie había cubierto .
ℹ
Cómo lo encontró el sistema
La campaña de fuzzing sobre el formato 7z produjo un crash con firma estable (heap-buffer-overflow READ of size 1 siempre en la misma línea). El triage automático clasificó el stack, la verificación cruzada lo reprodujo en build producción vía valgrind, y el paso de novedad descartó duplicados contra los advisories conocidos. Detalles del pipeline en el artículo central de la serie .
15. La superficie: kpidEncrypted y los comandos disparadores
El bug vive en una consulta de propiedad. Cuando el usuario lista o extrae un archivo, la consola pregunta por cada carpeta interna si está cifrada mediante la propiedad kpidEncrypted. Esa consulta termina invocando a IsFolderEncrypted, que — en vez de reutilizar el resultado estructurado del parseo original — decide volver a caminar los bytes crudos de CodersData buscando el ID del coder AES.
Comando Consulta kpidEncrypted ¿Dispara el OOB?
7zz l -slt poc.7zSí (listado técnico por ítem) Sí — crash
7zz x poc.7zSí (antes de decidir pedir contraseña) Sí — crash
7zz t poc.7zNo (el test no consulta cifrado) No
Esta asimetría tiene consecuencias prácticas: entornos que solo prueban integridad de archivos (t) no verán el problema, mientras que cualquier flujo que liste o extraiga — exactamente lo que hace un gateway de correo o un gestor de archivos — lo pisa de lleno.
16. Anatomía del bug: dos parsers, una estructura
El formato 7z describe cada carpeta con una secuencia de codificadores dentro de CodersData. Cada coder empieza con un byte de flags donde el bit bajo guarda el tamaño del ID (& 0x0F) y el bit 0x20 indica propiedades adicionales. Existe además un flag especial — 0x10 — que anuncia que detrás del ID vienen dos números: numInStreams y numOutStreams.
El parser principal (CInArchive::ReadFolder, 7zIn.cpp:521) interpreta ese flag correctamente y consume ambos números durante la apertura. Pero IsFolderEncrypted (7zHandler.cpp:291–317) re-parsea la misma región solo con reglas de id+props : nunca mira el bit 0x10. Dos lectores, una misma secuencia de bytes, dos posiciones finales distintas. El resto es aritmética del caos.
c // 7zHandler.cpp:302-316 — bucle vulnerable (línea 308 = lectura OOB)
CNum numCoders = inByte.ReadNum();
for (; numCoders != 0; numCoders--) {
const Byte mainByte = inByte.ReadByte();
const unsigned idSize = (mainByte & 0xF);
const Byte *longID = inByte.GetPtr();
UInt64 id64 = 0;
for (unsigned j = 0; j < idSize; j++)
id64 = (id64 << 8) | longID[j]; // ← OOB READ sin check de límites
inByte.SkipDataNoCheck(idSize);
if (id64 == k_AES) return true;
if ((mainByte & 0x20) != 0)
inByte.SkipDataNoCheck(inByte.ReadNum());
}
// Nota: ningún manejo de 0x10 → numIn/numOutStreams se interpretan
// como si fueran datos de coders siguientes (desync garantizado)
El contraste dentro del propio archivo fuente lo confirma: otra función del mismo handler, GetFolderInfo (7zHandler.cpp:400–434), sí procesa el flag 0x10 y consume los dos contadores. El bug no es de diseño del formato: es de implementación inconsistente entre dos lectores de la misma estructura .
17. El desync, byte a byte
Con el archivo trampa del generador, CodersData ocupa exactamente 24 bytes en el heap. Mira cómo el re-parse pierde la pista:
Figura 1 — El mismo buffer, dos lectores. En verde el parser correcto (consume 0x10 y termina limpio). En rojo el re-parse: confunde contadores de streams con datos de coders, aterriza en la zona de packStreams, fabrica un "coder fantasma" con idSize=10 y lee un byte más allá del final del allocation de 24 bytes.
La secuencia exacta del desync queda así:
text# CodersData del PoC (24 bytes, modo desync)
pos 00: 02 # numCoders = 2
pos 01: 31 # coder1: flags (idSize=1, bit 0x20 set)
pos 02: 00 # id[0]
pos 03: 0a # ← numInStreams=10 (lo come ReadFolder,
pos 04: 01 # ← numOutStreams=1 lo ignora IsFolderEncrypted)
pos 05: 05 # propsSize = 5
pos 06..0a: 5D 00 00 00 00 # props LZMA2
pos 0b: 00 # coder2 real
pos 0c..0d: 00 00 # bond
pos 0e..17: 0a 09 08 07 06 05 04 03 02 01 # packStreams
# Lectura según IsFolderEncrypted:
pos 03: ReadNum() → 0x0a = 10 # cree que es propsSize (¡no es 0x05!)
skip 10 bytes # devora props + coder2 + bond + 4 packStreams
pos 0e: mainByte=0x0a # packStream[0] disfrazado de nuevo coder
idSize = 10 decimal # 0x0a & 0xF
longID[0..9] → pos 15..24 # el último byte YA está fuera del heap chunk
18. El PoC: estructura del archivo trampa
El generador interno construye un .7z mínimo cuyo header declara una carpeta con el patrón anterior. La clave no es criptografía sino contabilidad de bytes : hay que elegir numInStreams de modo que el skip del re-parse aterrice exactamente en la zona de packStreams, y que el primer byte de esa zona tenga el nibble bajo deseado.
python# Flujo del generador (esqueleto conceptual — el script completo se reserva
# para coordinación con el mantenedor mientras no exista parche)
def construir_sig_header():
coders_info = [coder_con_flag_0x10(num_in=10, num_out=1), ...]
coders_data = serializar(coders_info) # 24 bytes exactos
pack_streams = bytes_controlados(10) # 0a 09 .. 01
return empaquetar_7z(coders_data, pack_streams)
$ python3 gen_poc.py clean # baseline: archivo válido equivalente
$ python3 gen_poc.py desync # variante disparadora del OOB
Conservar un baseline limpio junto al disparador fue decisión del sistema: permite demostrar que la única diferencia relevante es el patrón del flag 0x10, eliminando cualquier duda de factores externos (compresión, diccionarios, alineación).
19. Evidencia de validación
La validación siguió la regla de las dos herramientas. Instrumento primario: build instrumentada con ASAN que reporta el desbordamiento con símbolos. Validador independiente: valgrind Memcheck sobre el binario de producción sin instrumentar, confirmando que el bug no es un artefacto del sanitizer.
Evidencia A — ASAN: heap-buffer-overflow READ of size 1 en IsFolderEncrypted (7zHandler.cpp:308), «located 0 bytes after 24-byte region»: el allocation de CodersData queda exactamente atrás. Captura regenerada a partir del log original de validación con rutas de build redactadas.
bash# Tool B — valgrind sobre binario de producción (sin instrumentar)
$ valgrind ~/builds/7zz-prod l -slt ~/poc/poc_desync.7z
==…== Invalid read of size 1
==…== at … NArchive::N7z::CHandler::IsFolderEncrypted(…) 7zHandler.cpp:308
==…== Address … is 0 bytes after a block of size 24 alloc'd
# Tool C — gdb: breakpoint en la línea culpable, inspección de idSize
(gdb) break 7zHandler.cpp:308
(gdb) run l -slt poc_desync.7z
(gdb) p idSize
$1 = 10 # el nibble bajo del "coder fantasma"
Ambas herramientas coinciden en los tres datos que importan: tamaño de lectura (1 byte), línea exacta (308) y distancia al borde del allocation (0 bytes después de 24) . Reproducido en builds x86 y x64.
20. Acotación del OOB: por qué es LOW
El alcance del desborde está gobernado por el nibble bajo del mainByte falso, y ese byte lo elige el atacante dentro de la zona de packStreams:
Variante Configuración idSize resultante Bytes OOB
PoC estándar numIn=10, packStream[0]=0x0A10 1 byte (ASAN corta en el primero)
PoC ampliado numIn=15, packStream[5]=0x0F15 hasta 6 bytes
Tres razones lo mantienen en LOW. Primera: es solo lectura — no hay escritura ni corrupción de metadatos del allocator. Segunda: el dato leído se acumula en id64 y se compara contra la constante k_AES; su único efecto observable es un booleano (¿carpeta cifrada sí/no?), no un leak canalizable a disco o red. Tercera: la distancia máxima (~15 bytes) limita qué objetos vecinos pueden ser alcanzados, y ninguno de los observados altera decisiones de seguridad más allá del booleano citado.
✔
El valor estratégico del primitivo
Bajo en impacto, alto en señal: este read es el primitivo de lectura sobre el que el sistema construyó su siguiente hipótesis — control determinista del layout del heap para colocar datos elegibles justo detrás de CodersData. Esa escalada, documentada internamente como stepping stone hacia MEDIUM, es el tipo de cadena que separa un crash curioso de una vulnerabilidad madura.
21. Impacto realista y mitigación
¿Quién está expuesto? Cualquier flujo automatizado que liste o extraiga .7z de origen no confiable: gateways de correo que descomprimen adjuntos para escanear, servidores de archivos con previsualización, pipelines que validan artefactos, y usuarios finales abriendo archivos descargados. En todos esos casos el disparador es pasivo: basta listar el contenido.
Para mantenedores, el fix es quirúrgico: procesar el flag 0x10 en el re-parse — consumir numInStreams/numOutStreams exactamente como hace GetFolderInfo — o, mejor aún, eliminar el re-parse crudo y reutilizar la representación estructurada ya calculada en la apertura. Para operadores, hasta que exista parche: tratar los .7z de origen desconocido como entradas hostiles (aislar el proceso extractor, aplicar timeouts, preferir servicios de descompresión sandboxeados).
⚠
Estado de divulgación
Al publicar este análisis no existe patch upstream ni CVE asignado. Por eso el generador completo no se distribuye aquí: el mecanismo está documentado con precisión suficiente para reproducirlo y corregirlo, pero no se sirve listo para disparar. Si eres mantenedor y necesitas el material completo, contacta por los canales habituales.
22. Conclusión
El caso resume lo que hace valioso un LOW bien documentado: una causa raíz clara (dos lectores con reglas divergentes sobre el mismo formato), un mecanismo auditable byte a byte, evidencia triple y una frontera de impacto honestamente acotada. Los bugs de re-parse divergente son una familia — donde hay uno, suele haber más —, y esta firma concreta (flag consumido en un parser, ignorado en otro) ahora forma parte del repertorio que el sistema busca activamente en otros parsers de archivos.
La lección para quien construye parsers: cada vez que una estructura se interpreta dos veces, las dos implementaciones deben compartir la misma gramática — o mejor, la misma función. El formato no cambia; cambia quién lo lee y con cuántas ganas de leer todo.
Continuidad. Tres demultiplexadores donde la entrada manda sobre la aritmetica.
// En 30 segundos
Tres DoS en demuxers de FFmpeg hallados por fuzzing autónomo: SIGFPE en VPK (división por cero, CWE-369, parcheada en 8.1.2 ), memory leak escalable en VQF (CWE-401, sin parche ) y bucle de 13,5M frames en Smacker (CWE-400, sin parche ).
PoCs ridículamente pequeños: 21 B · 13 B · 105 B . Patrón común: el header del contenedor se parsea antes de validarse.
VPK: last_block_size / nb_channels con nb_channels=0 → IDIV → crash determinista. VQF: av_malloc(tag_len) sin av_free en error path, fuga medida hasta ~677 MB/archivo. Smacker: check frames < 0xFFFFFF que no contrasta contra el tamaño del archivo.
Verificación doble herramienta + descarte de 8 hipótesis alternativas. Divulgación graduada en los dos casos abiertos.
23. Resumen Técnico
# Demuxer Bug CWE PoC Efecto Estado fix
A libavformat/vpk.c
División por cero en vpk_read_packet (vpk.c:89)
CWE-369 21 B
SIGFPE → crash determinista del proceso (repro 3/3)
Parcheada en 8.1.2
B libavformat/vqf.c
av_malloc(tag_len) sin av_free en error path (vqf.c:64)
CWE-401 13 B
Leak que escala con tag_len: medido hasta ~677 MB por archivo
Sin parche en 8.1.2
C libavformat/smacker.c
Bucle for(i<frames) sin validar contra tamaño del archivo (smacker.c:225)
CWE-400 105 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.
24. 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.
25. 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 .
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.
26. 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:
c // 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:
hex# poc_vpk_fpe.bin (21 bytes)
204b 5056 f872 b900 01ff 72f8 5650 4b29 2a20 5320 53
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.
bash# 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
27. 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 :
c// 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.
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.
28. 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 .
text// 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.
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).
29. 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:
Caso Hipótesis alternativa Prueba de descarte
VPK Falso positivo del fuzzer Repro manual 3/3 + stack en gdb
Comportamiento by design Un VPK válido declara nb_channels ≥ 1; el header malformado lo fuerza a 0
Artefacto de build instrumentada SIGFPE es señal de hardware (IDIV), no del sanitizer
VQF Ruido de LeakSanitizer Valgrind detectó el mismo malloc sin free
Solo ocurre con inputs raros del corpus El error path se alcanza con cualquier tag_len > tamaño disponible
Leak acotado e irrelevante Medición directa: 285–677 MB de fuga por archivo con tag_len grande
Smacker El check de frames protege 13.5M < 16M: el umbral existente no cubre este rango
El loop termina pronto por EOF Ninguna salida anticipada: timeout sostenido 3/3 con CPU saturada
30. 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.
Mantenedores : replicar el fix de VPK (nb_channels <= 0 → invalid data) como patrón; añadir liberación en todos los return intermedios de add_metadata; validar contadores de Smacker contra el tamaño real del stream, no contra constantes mágicas.
Operadores : aislar el proceso de demuxing (un proceso por archivo, límites de memoria y tiempo duros, cgroups); los tres fallos mueren solos si el sandbox mata al worker sin contagio.
Usuarios finales : riesgo bajo en reproductores de escritorio — el crash cuesta reiniciar el reproductor — pero relevante en cualquier servidor que acepte archivos de terceros.
31. 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.
Continuidad. Un guardia que un camino aplica y el otro no.
// En 30 segundos
34 bytes bastan para que un servidor de imágenes pase segundos — o minutos — decodificando una foto que no existe: DoS por área de imagen no validada en el decoder lossless VP8L de libwebp (CWE-400).
El path lossy (VP8) comprueba width × height contra MAX_IMAGE_AREA; el lossless acepta hasta 16384×16384 sin mirar el área total. Dos caminos gemelos, dos estándares.
ReadImageInfo (vp8l_dec.c:1764) no consulta el guardia → coste de decode ∝ ~256M píxeles declarados → timeout reproducible 3/3.
Validación doble: libFuzzer (timeout 3 s) + control timeout(1) sin instrumentación (rc=124). Sin parche conocido al cierre.
32. Resumen Técnico
Objetivo libwebp 1.4.0 — decoder lossless VP8L (vp8l_dec.c)
Vulnerabilidad Ausencia de validación de área de imagen (width × height) antes de asignar buffers y procesar filas
CWE CWE-400 (Uncontrolled Resource Consumption) · VRT: recurso/CPU
Severidad LOW — consumo de CPU, sin corrupción de memoria
Disparador WebP lossless de 34 bytes declarando dimensiones gigantes (≤16384×16384)
Efecto Decode con coste ∝ área declarada → timeout reproducible 3/3 con límite de 3 s
Causa raíz ReadImageInfo (VP8LDecodeImage, vp8l_dec.c:1764) no consulta MAX_IMAGE_AREA; el path lossy sí lo hace
Validación Doble herramienta: libFuzzer (timeout instrumentado) + timeout de shell como control independiente (exit 124)
Estado fix upstream Sin parche conocido al cierre — divulgación graduada: mecanismo completo, receta byte a byte reservada
33. 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 — CVEs que resonaron 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 .
34. 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.
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.
35. 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:
bash # 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 guardia
→ DecodeImageData (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 —:
c// 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 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.
36. 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.
text# 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.
37. 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.
Evidencia A — libFuzzer: «ERROR: libFuzzer: timeout after 3 seconds» sobre el PoC de 34 B. Reproducido 3/3 ejecuciones.
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.
bash# 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
38. Acotación: por qué es LOW
Sin corrupción de memoria : los buffers se asignan correctamente según las dimensiones; el problema es cuánto tarda el trabajo declarado, no que escriba donde no debe.
Límite estructural del formato : 16384×16384 acota el peor caso (~268M píxeles); no hay crecimiento indefinido como en un bucle infinito puro.
Requiere contexto de servicio : un reproductor local pierde unos segundos; el daño serio exige un servicio que decode imágenes de terceros sin límites propios.
✔
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.
39. 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.
Mantenedores : replicar el guardia de MAX_IMAGE_AREA del path lossy en ReadImageInfo, con multiplicación segura en 64 bits.
Operadores : imponer límites en la capa de servicio — dimensiones máximas verificadas sobre el header antes de llamar a la librería, presupuesto de CPU por archivo, workers aislados con reinicio automático.
Usuarios finales : riesgo prácticamente nulo en navegadores modernos, que ya limitan dimensiones antes de invocar al decoder.
40. 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.
Continuidad. El bug no esta en ejecutar el patron, sino en compilarlo.
// En 30 segundos
Un patrón regex de ~693 bytes con una backreference recursiva (?0) dentro de un negative lookahead (?!…) cuelga a pcre2_jit_compile para siempre en get_framesize (CWE-400).
El mismo patrón en modo interpretado: compila en 0 ms y el match termina limpio con rc=-1. El fallo es exclusivo del backend JIT — no es ReDoS.
Causa: la pasada de dimensionado de frames recorre la recursividad total sin memoizar, get_framesize (pcre2_jit_compile.c:2224) nunca converge.
Superficie real: servicios que compilan regex de terceros (WAFs, motores de búsqueda, filtros de logs). Sin parche confirmado al cierre.
41. Resumen Técnico
Objetivo PCRE2 10.48 — fase de compilación JIT (pcre2_jit_compile.c)
Vulnerabilidad Recursión interna sin terminación en get_framesize (pcre2_jit_compile.c:2224) ante una backreference recursiva (?0) anidada en un negative lookahead (?!…)
CWE CWE-400 (Uncontrolled Resource Consumption)
Severidad LOW — consumo indefinido de CPU durante la compilación; sin corrupción de memoria
Disparador Patrón regex de ~693 bytes; la compilación JIT no termina (timeout reproducible 3/3)
Dato clave El mismo patrón en modo interpretado: compile OK + match rc=-1 en 0.000 s → el fallo es específico del compilador JIT
Validación Doble herramienta: harness JIT instrumentado (timeout) + binario de control sin JIT (éxito inmediato)
Estado fix upstream Sin confirmación de parche al cierre — divulgación graduada: clase de construcción documentada, patrón completo reservado
42. Contexto: quién compila regex ajenas
PCRE2 es la biblioteca de expresiones regulares que respiran medio internet: PHP, Apache, nginx vía módulos, miles de productos embebidos. Su modo JIT traduce los patrones a código máquina nativo para acelerar el matching — una victoria de rendimiento que pagan especialmente los servicios donde cada microsegundo cuenta.
Y ahí está el matiz que hace interesante este hallazgo: hay una clase entera de sistemas que compilan patrones escritos por usuarios o derivados de ellos . Un WAF que traduce reglas de filtrado, un motor de búsqueda que acepta queries avanzadas, un firewall de aplicaciones que recompila firmas dinámicas, un SaaS de logs con filtros personalizables. En todos ellos, la fase de compilación es superficie de ataque de primera categoría: recibe texto semiestructurado del exterior y lo procesa con lógica compleja. La mayoría de auditorías miran el matching (backtracking catastrófico, ReDoS); este bug demuestra que el compilador también puede ser el punto de estrangulamiento.
43. La construcción venenosa: (?0) dentro de (?!…)
Dos piezas del lenguaje regex se combinan aquí. La primera, (?0), es una backreference recursiva: «todo el patrón completo», una referencia a sí mismo. La segunda, (?!…), es un negative lookahead: coincide solo si lo que sigue no encaja con su interior. Por separado, ambas están perfectamente definidas y el intérprete las maneja sin despeinarse.
El problema aparece cuando la recursividad total del patrón vive dentro del lookahead negativo. Para generar código nativo, el compilador JIT necesita saber cuánto espacio de frames necesita cada subexpresión — y calcularlo exige recorrer la estructura de la recursión. Con (?0) anidada en (?!…), ese recorrido entra en un ciclo que no alcanza condición de parada: la función get_framesize vuelve sobre el mismo subárbol una y otra vez, y la compilación nunca termina.
text # Forma de la construcción (esqueleto conceptual — el patrón real,
# de ~693 B, se reserva hasta disponer de parche upstream)
^(?! # lookahead negativo...
...anclajes... (?0) ...anclajes... # ...que contiene la recursiva total (?0)
)
...resto del patrón...
# Comportamiento observado:
pcre2_compile → OK # árbol sintáctico válido, sin quejas
match (intérprete) → rc=-1 # termina limpio en 0.000 s: no hay match, como corresponde
pcre2_jit_compile → HANG # get_framesize no regresa jamás
Fíjense en el dato más incómodo del caso: el árbol sintáctico es legítimo . No hay trampa sintáctica ni cuantificador absurdo; es una combinación semánticamente válida cuya traducción a máquina es lo que no acaba. Eso explica por qué pasa los checks normales de entrada y por qué el fallo se esconde tan bien.
44. El experimento diferencial: intérprete vs JIT
Cuando un fuzzer reporta un timeout contra un target JIT-enabled, la primera sospecha natural es backtracking catastrófico: el clásico ReDoS donde el matching explota exponencialmente. Pero un buen diagnóstico exige aislar la variable. El sistema montó el experimento controlado que separa ambos mundos:
Figura 1 — Diagnóstico diferencial: la única variable que cambia entre ambas columnas es el backend. Si el hang fuera del matching, la rama izquierda también se colgaría. No lo hace: el bug vive exclusivamente en la traducción a código nativo.
45. Anatomía del hang: get_framesize
El compilador JIT de PCRE2 trabaja en varias pasadas; una de ellas dimensiona los frames de ejecución — cuánta memoria local necesitará cada rama del patrón — recorriendo recursivamente la estructura compilada. Esa función es get_framesize, y el stack capturado durante el hang la señala inequívocamente:
bash# Tool A — harness JIT instrumentado (timeout reproducible 3/3)
$ ./pcre2_fuzzer_jit -runs=1 -timeout=3 poc_jit_hang.bin
...
==…== ERROR: libFuzzer: timeout after 3 seconds
# stack (gdb attach):
#0 get_framesize pcre2_jit_compile.c:2224 # ← bucle caliente
#1 ... # marcos de la pasada de dimensionado
#…
#N LLVMFuzzerTestOneInput
La lectura técnica del síntoma: get_framesize visita el nodo de la recursiva total (?0) desde dentro del contexto del lookahead, y el cálculo de tamaño vuelve a requerir dimensionar el patrón completo — incluido, de nuevo, el lookahead. Cada nivel de análisis genera otro; ninguna tabla marca el subárbol como «ya medido». Es una recursión estructuralmente infinita: no crece el stack hasta desbordar porque cada ciclo reutiliza marcos equivalentes, simplemente nunca converge .
Evidencia A — Harness JIT: timeout tras 3 segundos, reproducible 3/3, con el stack caliente en get_framesize (pcre2_jit_compile.c:2224).
46. Descarte de hipótesis alternativas
La fortaleza del hallazgo está en lo que quedó fuera. Tres explicaciones rivales, tres pruebas de descarte:
Hipótesis alternativa Prueba Resultado del descarte
Backtracking catastrófico en runtime (ReDoS clásico) Ejecutar el match sin JIT con el mismo patrón rc=-1 en 0.000 s: el matching no es el problema; el hang ocurre antes, en compile
Límite de tamaño legítimo (patrón «demasiado grande») Oss-fuzz reduce cuantificadores grandes en sus corpus El trigger es la estructura recursiva, no el volumen: patrones cortos equivalentes reproducen el ciclo
Falso positivo del harness Repro manual 3/3 + stack consistente en gdb Bug real, determinista y localizado en una función concreta
Evidencia B — Control experimental: build sin JIT con el mismo patrón. Compila limpio y el match termina en 0.000 s (rc=-1). Esta imagen es la mitad que convierte un crash report en un diagnóstico.
47. Acotación e impacto realista
Sin corrupción de memoria : el proceso no crashea ni escribe fuera de límites; simplemente no avanza. La severidad depende de qué tan caro sea recuperar el worker.
Requiere compilar regex externas : el escenario de explotación exige que la aplicación pase patrones controlados por terceros a pcre2_jit_compile. Quien solo usa patrones propios internos no está expuesto.
Determinista y acotado : la construcción disparadora es precisa y verificable; no es un ataque probabilístico ni requiere volumen.
El perfil de víctima ideal: servicios multiusuario donde el patrón llega desde fuera — WAFs con reglas dinámicas, motores de búsqueda con sintaxis avanzada, plataformas de logs con filtros personalizados, IDEs web que validan regex de usuario en vivo. En todos ellos, un patrón de setecientos bytes convierte un thread de trabajo en un zombie de CPU permanente: no crashea, así que los supervisores de procesos no lo reinician; simplemente deja de responder mientras consume su núcleo asignado.
48. Mitigación
Mantenedores : memoizar el cálculo de frames por subárbol ya visitado (tabla de subárboles medidos), y tratar la recursiva total dentro de aserciones de ancho cero como caso especial con límite de profundidad.
Operadores : envolver toda compilación JIT de patrones externos en un presupuesto temporal duro (thread aparte + deadline + abort); rechazar de entrada patrones que combinen recursivas totales con lookarounds si el caso de uso no los exige.
Desarrolladores de aplicaciones : auditar dónde llegan las regex de usuario. Si la respuesta es «al JIT», esa frontera necesita sandboxing tanto como cualquier parser de archivos.
49. Conclusión
Este hallazgo vale menos por el impacto individual y más por el método: un experimento diferencial bien montado transforma una observación ambigua en un diagnóstico quirúrgico . Sin la rama de control — el mismo patrón, sin JIT, terminando en cero milisegundos — el bug habría quedado archivado como «timeout extraño» en algún issue tracker. Con ella, apunta exactamente a una función, una línea y una causa estructural.
La lección transferible a cualquier equipo de seguridad: cuando algo se cuelga, pregúntense siempre en qué fase se cuelga. Parseo, compilación, optimización, ejecución: cada fase tiene dueño distinto, superficies distintas y fixes distintos. El backtracking catastrófico acapara los titulares de ReDoS, pero los compiladores de regex — esa capa invisible entre el texto y la máquina — merecen el mismo escrutinio.
Continuidad. Patch-diffing: dos compilaciones del mismo codigo, una sola con el fallo.
// En 30 segundos
Mismo archivo CAB, dos binarios: el snapshot de bsdtar 3.8.9 extrae leyendo un history guard del decoder LZX que nadie inicializó (CWE-457); el build con el fix upstream lo rechaza con LZX_CORRUPT.
Método: patch-diffing como radar — un commit upstream del 08-08 endureció el path CAB/LZX, y la revisión periódica del snapshot reveló que el fix no estaba en él.
Validación A/B: mismo input, única variable = el parche. Vulnerable rc=0 (extrae bajo ASAN use-of-uninitialized-value); parcheado rc=1 (-25, LZX_CORRUPT).
Clasificación honesta: DUP-RACE — el fix ya existía una semana antes de la detección. El activo duradero es el método, no el CVE.
50. Resumen Técnico
Objetivo libarchive / bsdtar 3.8.9 — path de extracción CAB con compresión LZX
Vulnerabilidad Uninitialized heap read del history guard del decoder LZX (lzx_read_blocks) durante la extracción
CWE CWE-457 (Use of Uninitialized Variable) · VRT: MCR (read)
Severidad LOW — solo lectura de valores no controlados directamente; sin escalada a escritura
Detección Divergencia post-snapshot: fix upstream del 2026-08-08 ausente en el snapshot del target
Método Patch-diffing + comparación A/B con el mismo input CAB sobre dos builds de bsdtar
Resultado A/B Build vulnerable: rc=0, extrae (con lectura no inicializada). Build parcheado: rc=1 (-25, LZX_CORRUPT)
Validación ASAN (use-of-uninitialized-value) + valgrind --track-origins=yes
Clasificación DUP-RACE — el fix llegó por otra vía dentro de la ventana de análisis; se documenta como caso metodológico
51. Contexto: snapshots que envejecen mal
Analizar un target serio exige construirlo: compilar la versión concreta, instrumentarla, fijar su entorno. Esa construcción es un snapshot — y desde el momento en que existe, empieza a envejecer. Upstream sigue vivo: commits, hardening, fixes. Cada día que pasa, tu snapshot fiel deja de serlo un poco más.
Esta historia nace de esa tensión. El sistema Sherlock mantiene un snapshot de libarchive 3.8.9 para sus campañas de fuzzing sobre formatos de archivo. Durante la revisión periódica contra upstream apareció un commit del 2026-08-08 que tocaba justo el camino CAB/LZX que el snapshot ejercita: un hardening frente a lectura de memoria no inicializada en el history guard del decoder LZX. Pregunta inmediata: ¿el snapshot contiene ese fix? Respuesta corta: no. Pregunta larga: ¿qué significa eso en la práctica? El resto del artículo es la respuesta.
52. El método: patch-diffing como radar
El patch-diffing suele asociarse a martes de parches de grandes vendors: comparar binarios antes/después para localizar la vulnerabilidad corregida. Pero tiene un uso inverso igual de potente para quien caza con targets propios: usar los commits upstream como radar de zonas calientes . Si el proyecto acaba de blindar una ruta de código, esa ruta era frágil ayer — y cualquier versión viva sin el parche lo sigue siendo hoy.
Figura 1 — Cronología del hallazgo: el commit upstream del 08-08 funciona como aviso; la revisión semanal del snapshot lo convierte en hipótesis; el A/B la transforma en hecho medible.
53. Anatomía: el history guard sin inicializar
El algoritmo LZX — el que comprimían los instaladores de Windows y los ficheros CAB antiguos — mantiene un historial de ventana: los últimos kilobytes de salida descomprimida, que las instrucciones de match reutilizan como diccionario. Para acelerar la decodificación, la implementación de libarchive lleva controles auxiliares sobre ese historial; uno de ellos es el history guard , que decide cuándo el estado del historial es válido.
El problema: en ciertas secuencias de bloques CAB malformadas, el guard se consulta antes de haber sido escrito . El heap entrega lo que había en esas direcciones — basura de usos anteriores — y el decoder toma decisiones de decodificación basadas en ella. Formalmente es CWE-457: comportamiento dependiente de valores no inicializados. En la práctica observada, la consecuencia visible era que archivos que deberían ser rechazados como corruptos se «decodificaban» hasta el final, consumiendo la basura como si fuera estado legítimo.
c # Forma del defecto (semántica del fix upstream del 08-08)
# ANTES (snapshot 3.8.9):
if (guard && ...) # guard puede llegar aquí SIN inicializar
...procesar bloque... # decisión tomada sobre basura del heap
# DESPUÉS (upstream parcheado):
if (!history_valid) # estado explícito + inicialización garantizada
return -25; # LZX_CORRUPT: rechazo limpio del stream
Los sanitarios lo confirman con precisión quirúrgica: AddressSanitizer reporta use-of-uninitialized-value en el camino del guard durante lzx_read_blocks, y valgrind con --track-origins=yes remonta el valor hasta su origen: una asignación de heap cuyo contenido nunca se escribió antes de leerse.
54. El experimento A/B
La validación mínima de una divergencia es comparar los dos mundos con todo lo demás constante: mismo input CAB que recorre el path del history guard, misma invocación de extracción, única variable = el parche.
Evidencia A/B — El mismo CAB ante dos builds: el snapshot vulnerable extrae silenciosamente (con lectura no inicializada bajo ASAN/valgrind); el parcheado corta con «corrupt history guard». Captura regenerada a partir del log original de validación con rutas de entorno redactadas.
bash# Resumen del protocolo A/B (2026-08-15)
# A — snapshot vulnerable (bsdtar ASAN 3.8.9)
$ bsdtar-asan-3.8.9 -xvf poc_cab_lzx_history.cab ; echo rc=$?
poc_lzx.bin extraído
rc=0 # éxito aparente...
# stderr ASAN: use-of-uninitialized-value (history guard)
# B — build parcheado (fix upstream 08-08)
$ bsdtar-patched -xvf poc_cab_lzx_history.cab ; echo rc=$?
bsdtar: LZX decoder error: corrupt history guard
rc=1 # (-25, LZX_CORRUPT): rechazo correcto
# Origen confirmado de forma independiente:
$ valgrind --track-origins=yes bsdtar-asan-3.8.9 -xf poc_cab_lzx_history.cab
==…== Conditional jump or move depends on uninitialised value(s)
==…== Uninitialised value was created by a heap allocation
La asimetría de veredictos es el resultado completo: un archivo que el código correcto considera corrupto atraviesa el código vulnerable sin levantar sospechas . Ese es exactamente el tipo de silencio que un read no inicializado produce — y el tipo de señal que un A/B bien montado vuelve audible.
55. La clasificación honesta: DUP-RACE
Aquí el sistema aplicó una regla que vale tanto para humanos como para agentes: verificar la fecha del fix antes de reclamar prioridad . El hardening upstream databa del 08-08 — una semana antes de la detección —, lo que sitúa el descubrimiento dentro de una carrera perdida: alguien reportó el problema primero y upstream ya lo había cerrado. El hallazgo se archiva como DUP-RACE : real, reproducible, pero duplicado respecto a la línea temporal oficial.
⚠
Por qué publicarlo aun siendo dup-race
Tres razones. Primera: el valor metodológico — el patch-diffing como radar — sobrevive al destino del bug concreto. Segunda: millones de sistemas siguen ejecutando builds sin el fix; saber que el path existe ayuda a operadores a priorizar actualizaciones de libarchive. Tercera: la honestidad taxonómica es parte del contrato de esta serie; un dup-race documentado enseña más que un hallazgo inflado.
56. Acotación: por qué es LOW
Solo lectura : el defecto consume valores no inicializados para decidir flujo interno; no hay primitiva de escritura ni corrupción de estructuras adyacentes.
Valores no controlados directamente : el atacante condiciona que se lea basura, pero no qué basura — el contenido depende del estado previo del heap, no de bytes elegidos del input.
Efecto observable limitado : aceptación de streams corruptos y decisiones internas contaminadas; sin fuga directa de datos al output en el escenario observado. Escalarlo a info-leak exigiría encadenarlo con superficies adicionales que este análisis no persiguió.
57. Lecciones metodológicas
Más allá del CWE, este caso dejó tres reglas operativas al sistema:
La fecha del fix importa tanto como el fix : una divergencia detectada días después del commit upstream casi siempre es carrera perdida; reconocerlo rápido ahorra semanas de maduración inútil y protege la credibilidad del programa.
El A/B con build ASAN es la validación mínima : una divergencia solo afirmada por diff de código es una hipótesis; con dos binarios y un input, es un experimento. La diferencia entre ambos estados es la diferencia entre creer y saber.
Los commits upstream son inteligencia gratuita : cada hardening señala una debilidad histórica y delimita las versiones vivas que aún la padecen. Revisarlos periódicamente convierte el repositorio ajeno en sensor propio.
58. Conclusión
No todos los hallazgos terminan en CVE propio, y está bien que sea así. Este caso demuestra que un sistema de caza maduro también sabe perder carreras con elegancia : detectar la divergencia, medirla, clasificarla honestamente y extraer de ella el activo duradero — el método. El history guard de LZX ya está protegido upstream; el patch-diffing como radar, en cambio, apenas empieza a dar sus primeros resultados.
Para operadores, el recordatorio práctico: si vuestra distribución todavía sirve builds de libarchive sin el hardening de agosto, los CAB malformados pueden atravesar vuestro extractor con éxito aparente. Actualizar no es burocracia; es cerrar puertas que otros ya saben dónde están.