Reconstrucción de structs en binarios: lo que el compilador se comió

Cuando el compilador termina su trabajo, del código fuente no queda casi nada legible para un humano: los nombres desaparecen, los tipos se disuelven y la herencia se aplana en simple aritmética de memoria. Esa es la premisa de la reconstrucción de structs en binarios, en What the compiler ate, la charla de Josué Rojas en la DragonJAR Security Conference 2026: el compilador destruye los metadatos —nombres, tipos, herencia: todo desaparece— y el trabajo del reverser es devolvérselos al binario. La reconstrucción de structs en binarios que presenta no es una colección de trucos: es una metodología completa que combina un lenguaje intermedio agnóstico de arquitectura, árboles de prefijos, union-find, retículos y SSA, y que converge en restruct, una herramienta opensource para reversers que automatiza la reconstrucción sobre Binary Ninja.

Índice
  1. Qué se come el compilador cuando compila
  2. Base, índice, escala y offset: el patrón que delata una estructura
  3. Un lenguaje intermedio para hablar con todas las arquitecturas
  4. El alfabeto de los accesos a memoria
  5. Del prefijo al trie: el árbol donde vive la estructura
  6. Congruencia y union-find: cuándo dos accesos son el mismo objeto
  7. Lattice o retículos: resolver conflictos de tipos sin árboles espurios
  8. SSA: la propiedad que elimina los falsos positivos del stack
  9. Las raíces: globales, funciones, allocs y stack
  10. Demo: restruct, la reconstrucción de structs en binarios automatizada
  11. Lecciones para reversers
  12. Preguntas frecuentes
    1. ¿Qué es la reconstrucción de structs en binarios?
    2. ¿Por qué el compilador dificulta tanto recuperar los tipos?
    3. ¿Qué aporta SSA al análisis de binarios?
    4. ¿Qué hace restruct que Binary Ninja no hace?
  13. Conclusión: devolverle al binario lo que el compilador se comió
  14. Mira la Charla Completa Sobre reconstrucción de structs en binarios

Qué se come el compilador cuando compila

La charla parte de una confesión: cuando Josué hacía este tipo de reversing a mano —"que ya no se hace", aclara—, lo primero era especificar el tipo de datos de lo que estaba leyendo, porque en esos tiempos las herramientas fallaban mucho al reconocerlos. Hoy hay que hacer menos correcciones, pero el problema de fondo sigue intacto: en el binario no hay tipos, solo accesos a memoria.

reconstrucción de structs en binarios

Para quienes trabajan en reconstrucción de structs en binarios, el segundo mordisco del compilador son las optimizaciones. Compilar con -O0 le quita las optimizaciones al binario; con -O3 las lleva al máximo, y una de sus consecuencias es que un mismo slot de memoria se reutiliza para cosas totalmente distintas. Para quien intenta reconstruir estructuras, ese reúso fabrica conflictos de tipos que aparecen como ruido. De ahí su lamento: cuántos dolores de cabeza se habría ahorrado a los 15 años, cuando hacía unpacking y rompía protecciones de software, de haber leído un libro de compiladores.

Base, índice, escala y offset: el patrón que delata una estructura

El primer patrón que Josué aprendió al leer su primer paper de reversing es también el primero que explica: un move es solo un mnemotécnico, pero lo que representa se descompone en una base (una dirección de memoria que funciona como variable), un índice, una escala (el peso del tipo de dato) y un offset. Cuando veías esa forma, la conclusión era inmediata: "esto es o un array o es un struct".

¿Por qué? Porque una estructura, al final, "es una región de memoria con campos adyacentes": todos viven en el mismo trozo de memoria. Cada tipo de dato tiene un peso entero en bytes, y ese peso aparece codificado en la escala del acceso. Incluso puedes tener un array de structs, con base, índice, escala y offset en la misma expresión.

El detalle está en leerlo a bajo nivel. Un move con el valor 2.5 delata un float; un shift de 3 posiciones equivale a multiplicar por 2 elevado a 3, y para hallar ciertos offsets el compilador usa rotaciones y shifts en lugar de multiplicaciones. Lo que en el código fuente era objeto.campo termina siendo una suma de direcciones disfrazada.

Un lenguaje intermedio para hablar con todas las arquitecturas

El patrón base-índice-escala-offset bastaría si solo existiera una arquitectura. Pero el binario real llega en ARM de 64 bits, x86 de 64 bits y MIPS de 32 bits, y con las optimizaciones del compilador el mismo acceso "a bajo nivel es totalmente distinto". Intentar detectar el patrón leyendo el ensamblador crudo de cada CPU es un juego perdido de antemano.

La solución viene de la investigación en compiladores: el lenguaje intermedio. Su símil, dice Josué, es "como si todos habláramos el mismo idioma". Un lenguaje intermedio es agnóstico a la arquitectura —más precisamente, su resultado lo es, porque por dentro maneja cada particularidad de cada arquitectura— y entrega un solo estándar con mnemotécnicos como LOAD, STORE, MUL, ADD y CALL. Si hablas ese lenguaje, puedes analizar diferentes arquitecturas con las mismas técnicas.

El alfabeto de los accesos a memoria

¿Cómo se pasa del ensamblador a una estructura reconstruible? Igual que en todo lenguaje formal: estableciendo un alfabeto. Josué formaliza cada acceso como una secuencia de símbolos: +K para avanzar K bytes, *N para una lectura de N bytes, IN y OUT para los parámetros de entrada y salida de las funciones (necesarios porque a veces se pasa una estructura a una función, o un campo recibe lo que esa función devuelve) y [i] para el índice de un array de structs.

Un acceso donde a1 avanza más 16 bytes, hace una lectura de 8, avanza 4 y lee 4 bytes más se convierte en una palabra de ese alfabeto: técnicamente, un monoide libre. Y las palabras se construyen para estudiarlas: "queremos saber cuáles se parecen o cuáles son iguales". Si dos palabras resultan iguales, se pueden colapsar en una sola.

Del prefijo al trie: el árbol donde vive la estructura

La comparación entre palabras se apoya en los prefijos, que definen una relación de orden parcial: de menor a mayor, desde la palabra vacía, luego a1, y encadenando cada símbolo del alfabeto. Esa cadena es un flujo de orden prácticamente sin bifurcaciones, con una propiedad clave: "nunca hay dos formas distintas de llegar al mismo prefijo desde una misma palabra".

Y, en el contexto de la reconstrucción de structs en binarios, una estructura así se parece mucho a un trie, que es un árbol. Las palabras que comparten prefijos penden del mismo lugar y luego se van bifurcando; cada nodo del trie es el camino que lleva hasta ese punto del acceso. La propiedad que importa para la reconstrucción de structs en binarios: "todo elemento tiene un único camino de ancestros sin ambigüedad". El resultado es una estructura de datos bien definida que se puede recorrer y consultar, no cadenas de texto sueltas.

Congruencia y union-find: cuándo dos accesos son el mismo objeto

Con el árbol listo llega la pregunta central de un binario grande, lleno de estructuras y tipos: "¿cómo puedo yo saber que este dato se parece a otro dato?". La respuesta técnica es la congruencia, construida sobre una relación de equivalencia: si A es el mismo objeto que B, y B es el mismo objeto que C, evidentemente A es el mismo objeto que C.

La definición operativa: A es congruente con B si y solo si cada paso interno de A —cada stride— es igual a cada stride de B. Para agrupar los objetos congruentes, Josué recurre a una estructura que, insiste, "no la inventé yo, es muy antigua, matemáticas": la disjoint union o union-find. El algoritmo primero hace un find que recorre y verifica si los dos candidatos son iguales; si lo son, hace el union que los junta en un solo conjunto. "Básicamente es un merge": muchos accesos dispersos por todo el ejecutable colapsan en una sola clase, un solo objeto.

Lattice o retículos: resolver conflictos de tipos sin árboles espurios

Faltaría lo más difícil: los unions. Un mismo campo puede usarse como int en un sitio y como float en otro —"según cómo se usa es un int y según cómo se usa es un float"— porque un union dentro de un struct permite manejar dos tipos de datos en un solo campo. Si el análisis no contempla eso, los conflictos "generan hasta árboles espurios".

Para manejarlos se usan los lattice o retículos, con tres estados: el inferior, donde "no sabes nada"; un escalón más arriba, donde ya tienes la información detectada; y TOP, el estado contradictorio. Un observer recorre el binario marcando con qué tipos se usa cada campo y, cuando al momento del merge un campo llegó con dos tipos diferentes, el análisis sube a TOP, baja un nivel y se queda con ambos tipos generando un union. El conflicto no se ignora: se representa.

SSA: la propiedad que elimina los falsos positivos del stack

En la reconstrucción de structs en binarios, SSA —Static Single Assignment— "es una investigación ya de los 80", con una versión extendida en el 91, y su idea es simple: cada variable se asigna una sola vez en su periodo de vida. Si haces a igual a 1, "a siempre va a ser a" y nunca cambia durante toda la ejecución.

¿Por qué importa aquí? Porque las optimizaciones que reusan slots de memoria rompen la identidad de las variables: el mismo slot se usa un rato para un int, luego para un float, después para otra operación. Con SSA cada uso tiene su propia versión, y eso "puede dar identidad a las structs construidas en el stack": cuando se reserva un marco de pila y el compilador reusa sus slots, la asignación única elimina los falsos positivos —o, como los llama Josué, los falsos tops—, porque queda claro que ese acceso no pertenece a la estructura. De paso, hacer el merge del union-find es más seguro y, en los bucles, "es claro dónde se define y es claro dónde se usa".

La contra de SSA son los nodos phi: cuando un if bifurca y la misma variable llega de cada rama con un uso distinto, se crea un nodo phi que une las versiones y hay que manejarlo.

Las raíces: globales, funciones, allocs y stack

El último componente del pipeline son las raíces, la parte inicial del árbol. Josué usa cuatro: las globales, porque una estructura declarada fuera del scope de una función en C probablemente vive en una región de memoria global (guarda la dirección); las funciones (guarda la dirección de la función); los allocs, porque en C++ una estructura reservada con memoria dinámica escribe sus datos en ese chunk (guarda el sitio del alloc); y el stack, donde guarda la función, la variable —el slot u offset relativo al marco de pila—, marcada como local, más una versión (sub1, sub2, subn) para desambiguar cuando el mismo slot se reutiliza. Con árbol, congruencia, retículo, SSA y raíces, están todos los insumos: "prácticamente ya estamos listos para la reconstrucción de structs en binarios".

Demo: restruct, la reconstrucción de structs en binarios automatizada

La demo usa un caso borde deliberado: estructuras inicializadas con listInit, que se usan mucho y cuyo body cambia según lo que se quiera hacer con ellas —una estructura con un header fijo y un cuerpo variable—, incluyendo listHit y un patrón container_of con struct task y link. Josué trabaja sobre Binary Ninja, elegido precisamente "porque Binary Ninja te da SSA", y su investigación se basó en dos papers: Retype y Circle. La diferencia: "mi forma de hacer merge es distinta a la de ellos y nosotros usamos SSA y ellos no usan SSA".

Sobre esas bases corre restruct —"mi framework", "mi plugin. La llamé restruct"—, que reconstruye las estructuras del binario de la demo (Hit, Task, Socket, Page) y hace dos cosas que Binary Ninja no hace. Primera: detecta autoreferencias. Donde Binary Ninja muestra "un puntero pero a nada" y campos vacíos, restruct reconoce que la estructura tiene un puntero hacia sí misma —"este es struct2 y tengo un puntero struct2, o sea, se referencia a sí mismo"—, justo lo que delata una lista enlazada. Segunda: reconoce los argumentos de las funciones, como los de listInit y listAdd.

Honestidad de autor: "está bastante beta esto porque tengo bastantes cosas que arreglar todavía", y la integración con la inferencia de tipos de Binary Ninja todavía no está hecha. El cierre lo dice todo: "a mí me divertí un montón haciendo esto, aprendí un montón de estructura de datos, de compiladores. Uno es más feliz haciendo cosas que le gustan".

Lecciones para reversers

  • El compilador no borra la información: la reorganiza. Nombres y tipos desaparecen, pero cada acceso conserva base, índice, escala y offset, y con eso se puede reconstruir.
  • No analices ensamblador crudo si puedes analizar lenguaje intermedio. LOAD, STORE, MUL, ADD y CALL dan un solo estándar para ARM, x86 y MIPS.
  • Formaliza antes de comparar. Un alfabeto (+K, *N, IN, OUT, [i]) convierte accesos en palabras comparables, y el trie las ordena sin ambigüedad.
  • La congruencia es la pregunta correcta. Dos accesos son el mismo objeto si cada stride coincide; union-find los agrupa en una sola clase.
  • Los conflictos de tipos son información, no ruido. Un retículo convierte un top contradictorio en un union explícito en lugar de un árbol espurio.
  • SSA primero. Sin asignación única, el reúso de slots del stack fabrica falsos positivos que envenenan todo el merge posterior.

Preguntas frecuentes

¿Qué es la reconstrucción de structs en binarios?

Es el proceso de recuperar, a partir de un binario compilado, las estructuras de datos que existían en el código fuente: sus campos, sus tipos y sus relaciones. Se apoya en patrones de acceso a memoria (base, índice, escala, offset), un lenguaje intermedio agnóstico de arquitectura y estructuras como tries, union-find y retículos, y puede automatizarse con herramientas como el plugin restruct sobre Binary Ninja.

¿Por qué el compilador dificulta tanto recuperar los tipos?

La reconstrucción de structs en binarios es difícil porque al compilar se pierden los metadatos (nombres, tipos, herencia) y porque las optimizaciones (-O3) reusan los mismos slots de memoria para variables distintas. Eso genera conflictos de tipos en el análisis y hasta árboles espurios si no se manejan con retículos y SSA.

¿Qué aporta SSA al análisis de binarios?

Static Single Assignment garantiza que cada variable se asigna una sola vez. Eso da identidad a las structs construidas en el stack, elimina los falsos positivos causados por el reúso de slots y hace más seguros los merges de union-find. Su contrapartida son los nodos phi en las bifurcaciones.

¿Qué hace restruct que Binary Ninja no hace?

En la reconstrucción de structs en binarios, restruct aporta dos cosas que Binary Ninja no hace: además de reconstruir las estructuras, detecta punteros de una estructura hacia sí misma (autoreferencias, como en las listas enlazadas) y reconoce los argumentos de las funciones, dos cosas que Binary Ninja no hace de forma nativa. El plugin está en fase beta, según su propio autor.

Conclusión: devolverle al binario lo que el compilador se comió

La charla de Josué Rojas demuestra que la reconstrucción de structs en binarios no depende de adivinar, sino de formalizar: un alfabeto para los accesos, un trie para ordenarlos, una relación de congruencia para agruparlos, un retículo para sus conflictos, SSA para no confundir variables y raíces para anclar el árbol. Todo eso, ensamblado en un plugin que convierte horas de trabajo manual en un comando. La ingeniería inversa sigue siendo un arte, pero cada vez con más ciencia de compiladores debajo.

Para seguir aprendiendo sobre seguridad ofensiva a partir de casos reales, regístrate en https://www.dragonjarcon.org. Allí podrás acceder a charlas como la que acabas de leer y conocer más sobre DragonJARCON, el congreso anual que reúne en Colombia a profesionales e investigadores de la seguridad informática.

Y si tu organización necesita este mismo nivel de análisis y escrutinio, conoce los servicios de seguridad ofensiva de DragonJAR en https://www.dragonjar.org/servicios-de-seguridad-informatica.

Mira la Charla Completa Sobre reconstrucción de structs en binarios

Toda la charla de Josué Rojas sobre reconstrucción de structs en binarios —del move que delata un float al plugin restruct detectando listas enlazadas en Binary Ninja— está disponible en YouTube:

Subir