
Presentado en Recon Montreal 2018
o parseo avanzado a escopetazos para necios y rebeldes
Érase una vez, querido lector, en una noche oscura y tormentosa, su fiel autor se topó con una situación desconcertante y misteriosa: un sistema donde lo único que impedía el análisis de memoria por chip-off y la ingeniería inversa era un sistema de archivos propietario fragmentado y desconocido, junto con la compresión debida al uso de Java embebido, lo que reducía la eficacia de las herramientas existentes para el file carving.
En su momento se podría haber improvisado una solución ad hoc concatenando manualmente fragmentos que parecían encajar, con algo de horrible trabajo de consola en Python y feo scripting bash ad hoc. Era Suficientemente Bueno, pero llevaba mucho tiempo.
Si bien extraer datos planos sin comprimir en un análisis chip-off es un trabajo bastante corriente en el día a día de un hacker de hardware, la compresión plantea problemas importantes cuando sus piezas están desperdigadas por todas partes de un modo irracional y desagradable, incluso cuando no hay otras protecciones reales contra la extracción.
Hay algunas cosas interesantes sobre los archivos Zip en particular (que es el formato básico usado para los archivos JAR). Como estudiante entregado desde hace bastante tiempo de la International Journal of PoC||GTFO y siguiendo especialmente el trabajo sobre trucos con formatos de archivo de Ange Albertini, pensé que podría haber suficientes datos dentro de un archivo zip sobre el propio archivo zip como para hacer un trabajo decente de volver a coserlo todo.
Así que, dejando las referencias a un lado, entremos en los detalles técnicos.
Primero lo primero: puede que no conozcamos los detalles del sistema de archivos (y, desde la perspectiva de mi investigación, independientemente del sistema en el que había encontrado el problema, decidí que sencillamente era mejor no preocuparme por ellos). Pero sabemos una cosa o dos sobre cómo se implementan la mayoría de los sistemas de archivos. En particular, sabemos que tienden a escribirse en fragmentos. Los fragmentos tienen un tamaño mínimo de algún tipo, conocido como páginas, y podemos identificar ese tamaño de página examinando el volcado y determinando el tamaño mínimo de bloque que se escribe.
Algunos de estos pueden ser contiguos y otros no, sin un patrón claro sobre cuándo los bloques son contiguos.
Con todo esto quiero decir que el problema que tenemos es cómo reordenar las páginas de datos de manera que nos proporcionen imágenes válidas (o casi válidas) de los archivos que queremos extraer.
Los archivos Zip están escritos de forma que implementan una especie de jerarquía inversa. Primero los datos del archivo comprimido (envueltos en cabeceras de archivo local que los describen). Después un directorio central (que enumera los desplazamientos de las cabeceras de archivo local) y luego un registro de fin de directorio central (que, entre otras cosas, describe el número de archivos almacenados en el zip, el desplazamiento donde comienza el directorio central y el tamaño del directorio central).
Pongámoslo al revés, ahondando un poco más en el detalle:
El fin del directorio central nos dice:
Cada registro del directorio central nos dice:
Cada registro de archivo local nos dice:
Todo lo anterior nos lleva a haber reconstruido la gran mayoría del archivo.
En primer lugar, necesitamos un paso para distinguir los datos de distintos firmwares. La razón es que todos los desplazamientos solo son relevantes dentro de sus respectivos archivos zip: cualquier conflicto provocará flujos zip desajustados y corrupción, y queremos a toda costa sacar la mayor cantidad posible de datos sin corromper. También queremos tener buenas garantías de que, p. ej., cualquier vulnerabilidad que diagnosticuemos en el firmware objetivo afecta al que normalmente vemos ejecutándose y no a algún otro archivo que simplemente se ha quedado tirado por ahí.
La solución que se necesita aquí es el algoritmo kmeans (también conocido como "Algoritmo de Lloyd"). Hay un gran vídeo aquí que explica cómo funciona. SciPy tenía una buena versión lista para usar, pero tuve que identificar/ parchear la única crate de clustering/análisis que implementaba el algoritmo para que funcionara en la implementación de Rust. Por suerte no tuve que escribirlo desde cero.
Después de eso, el trabajo está hecho.
Podemos usar varias características para ello. Los campos Flags, Method y Version varían según la pila Zip usada para comprimir el archivo. Además, las cabeceras tienen marcas de tiempo y, en general, es poco probable que todos los firmwares se compilaran y comprimieran exactamente al mismo tiempo.
Como inciso, vale la pena señalar que los archivos Zip usan marcas de tiempo en formato MS-DOS, que son enteros cortos empaquetados en bits que representan año-mes-día y hora-minutos-dos-segundos. Si no se convirtieran a un valor escalar absoluto antes de usarlos como datos de clasificación, podrías dar tanto peso a la diferencia de un año como a la de un segundo, ¡y eso no sirve de nada!
Los convertimos en un Vector Euclidiano (que es una palabra elegante para un array n-dimensional de valores en ℝ, o coordenadas flotantes, pero el vídeo enlazado arriba es probablemente la explicación más sencilla) y el algoritmo de agrupamiento hace prácticamente todo lo demás por nosotros, reuniendo todas las cabeceras parseadas en el número de grupos que esperamos.
Aunque he escrito esto como un script de Python bastante chapucero para prototipar el método, al alcanzar aproximadamente un 70-80% de tasa de recuperación de contenido JAR con el PoC, decidí parar ahí y pasar a implementar una versión rápida en Rust.
Rust tiene una crate llamada nom que es absolutamente fantástica para escribir
verificadores de parseo. Esta fue una de las principales razones para
reescribirlo en Rust, por si sirve de algo. La capacidad de escribir parsers
claros y extremadamente estrictos hace que esto sea mucho más fácil en cierto
modo que intentar manejarlo todo en Python (que tiende a ser mucho más tolerante,
hasta el punto de que a veces es un reto estar seguro de que no estás pasando por
alto fallos erróneos al intentar cazar un caso límite).