7-Zip: Heap Out-of-Bounds Read en IsFolderEncrypted por Re-parse Divergente de CodersData

Un archivo .7z de 24 bytes manipulados basta para que el binario más instalado del mundo lea memoria que no le pertenece. Este artículo deconstruye un heap out-of-bounds read confirmado en 7-Zip 26.00, 26.01 y 26.02: la función IsFolderEncrypted re-parsea la estructura CodersData con reglas distintas a las que usó el parser original, se desincroniza byte a byte y acaba comparando contra k_AES bytes leídos más allá del final del buffer. Hallazgo del sistema autónomo Sherlock, validado con ASAN + valgrind + gdb en x86 y x64.

/índice_del_análisis +

00. Resumen Técnico

Objetivo7-Zip / 7-Zip ZS — binario CLI 7zz, versiones 26.00 · 26.01 · 26.02
VulnerabilidadHeap buffer over-read en NArchive::N7z::CHandler::IsFolderEncrypted (7zHandler.cpp:308)
CWECWE-125 (Out-of-bounds Read) · VRT: MCR (Memory Corruption — Read)
SeveridadLOW — solo lectura, 1–6 bytes controlables, sin camino directo a escritura
Disparador7zz l -slt archivo.7z y 7zz x archivo.7z (consultan kpidEncrypted)
NovedadSin 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ónASAN heap-buffer-overflow + valgrind invalid read sobre builds gemelas (instrumentada y producción), x86 y x64
Estado fix upstreamSin parche al cierre de este análisis — divulgación graduada: mecanismo sí, generador completo no

01. 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.

02. 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.

ComandoConsulta 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.

03. 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.

// 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), 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.

04. 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:

CodersData — 24 bytes en el heap 02numCoders 31coder1 flg 00id[0] 0anumIn=10 01numOut=1 05propsSize 5D 00×4props (LZMA2) 00coder2 00 00bond 0a 09 … 01packStreams (10 bytes) ✓ ReadFolder (7zIn.cpp:521) — parseo de apertura consume flag 0x10 → salta numIn+numOut → propsSize=05 en su sitio → termina limpio en el byte 24 ✗ IsFolderEncrypted (7zHandler.cpp:308) — re-parse para kpidEncrypted ignora 0x10 · lee mainByte=0x31 → idSize=1, id=00 · ve bit 0x20 → ReadNum() traga 0x0a como "propsSize" skip 10 bytes → aterriza en pos 14 (zona de packStreams) creyendo que ahí empieza un segundo coder 0a mainByte falso = packStream[0] idSize = 0x0a & 0x0F = 10 longID[j] lee 10 bytes desde pos 15 → el byte 10 sale del final del buffer id64 se construye con memoria adyacente del heap y se compara contra k_AES
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í:

# 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

05. 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.

# Flujo del generador (esqueleto conceptual — el script completo se reserva # para coordinación con el mantenedor mientras no exista parche) 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).

06. 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.

Terminal mostrando el reporte de AddressSanitizer: heap-buffer-overflow READ of size 1 en NArchive::N7z::CHandler::IsFolderEncrypted 7zHandler.cpp:308, localizado justo después de una región de 24 bytes
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.
# 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.

07. 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:

VarianteConfiguraciónidSize resultanteBytes OOB
PoC estándarnumIn=10, packStream[0]=0x0A101 byte (ASAN corta en el primero)
PoC ampliadonumIn=15, packStream[5]=0x0F15hasta 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.

08. 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.

09. 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.

/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.

10. Referencias