Assembly x86/x64 para Ingeniería Inversa: Guía de Referencia Rápida
Repaso exhaustivo del lenguaje ensamblador aplicado a reversing: stack frames, variables, clases en ASM, malloc/new/delete, estructuras de datos (pilas LIFO, colas FIFO, listas enlazadas), formato PE, alineamiento, y todas las convenciones de llamada (cdecl, stdcall, SystemV, Windows x64) con ejemplos de código real.
01. Introducción y Herramientas para Reversing
Haremos un repaso rápido del lenguaje ensamblador para interpretar correctamente los binarios que estudiaremos durante sesiones de ingeniería inversa. En primera instancia empezaremos con los compiladores de GCC para C/C++ debido a su naturaleza menos abstracta que el compilador de Microsoft, que por defecto encapsula todo dentro del CRT haciendo la lectura más confusa. Con las flags adecuadas podremos controlar el nivel de optimizaciones para simplificar la salida del ensamblador.
Como mecanismo de estudio paralelo, también haremos uso de FLAT y NASM para escribir código directamente en Assembly. Estas notas complementan el trabajo práctico con herramientas como analystty y los análisis de malware documentados en la Threat Hunter Recollection.
02. Variables Globales, Locales y Stack Frame en x86/x64
Analizaremos dónde se almacenan las variables globales definidas y cómo se prepara la función principal para recibir parámetros. Las variables locales se almacenan en la pila y su acceso se realiza a través del puntero base (rbp), mientras que las variables globales se almacenan en la sección de datos y su dirección se obtiene mediante un desplazamiento relativo a un símbolo (.data).
__main es crucial para la inicialización de un programa en C++. Se asegura de que una determinada variable global en la sección .bss sea inicializada a 1 una sola vez al inicio del programa. Es probable que sea una técnica de sincronización implícita en entornos multihilo: si múltiples hilos intentan acceder simultáneamente, el primero establecerá el valor en 1 y los subsiguientes encontrarán que ya ha sido inicializado, evitando condiciones de carrera.
03. Arreglos, Punteros, Bucles y Condicionales en Ensamblador
Estudiaremos la salida en ensamblador de estructuras de control desde el ámbito local (stack). Analizaremos cómo se traducen los while, do-while, for (con incremento/decremento prefijo y postfijo) y las estructuras if-else. Entender estos patrones es fundamental para seguir el flujo de ejecución durante el análisis de malware.
Estructura do-while y variables sin definir
for con incremento prefijo vs postfijo
No hay diferencia significativa entre i++ y ++i a nivel de instrucciones generadas cuando el resultado no se asigna en la misma expresión. El compilador optimiza ambos casos de manera equivalente. Esta es una de esas micro-optimizaciones que no sobreviven al proceso de compilación.
04. Funciones: Tipos de Retorno y Parámetros en ASM
Estudiaremos las variaciones de funciones y los cambios en el código ensamblador generado según el tipo de retorno: void, char, bool, unsigned char, short, unsigned short, unsigned int, long long. Identificar el tipo de retorno por el registro utilizado es una skill básica de reversing.
- void: reserva 0x10 en la pila (sin argumentos).
- bool: no reserva espacio en pila; devuelve 0 o 1 en eax.
- short (2 bytes): usa el mnemónico word. Alineamiento de 2 en 2 en la pila.
- int (4 bytes): usa el mnemónico dword.
- long long (8 bytes): deja de usar registros de 32 bits por los de 64 bits completos. La asignación es menos directa: primero carga el literal en rax y luego lo deposita en la pila con qword.
05. Valores con Signo y Tipos de Almacenamiento en el Formato PE
Analizamos cómo se traducen los valores negativos a nivel de código. Por ejemplo, -25 se representa como 0xe7 (231 en decimal, fuera del rango típico de 256 sin signo). Es necesario estudiar los rangos para ambos casos según el tipo de dato correspondiente.
| Tipo de variable | Sección PE | Comportamiento |
|---|---|---|
| auto global | .data | Inicializada en tiempo de compilación |
| static global | .data | Inicializada, visibilidad limitada al archivo |
| const global | .rdata | Solo lectura |
| static const global | .rdata | Solo lectura, visibilidad limitada |
| auto local | Stack | Inicializada en ejecución |
| static local | .data | Persiste entre llamadas |
| const local | Stack | El compilador sustituye su valor literal |
| static const local | .rdata | Persiste, solo lectura |
Las constantes no se almacenan y en su lugar son sustituidas en tiempo de compilación por sus valores literales. Sin embargo, mediante punteros es posible alterar los valores de una constante en algunos casos. EXPERIMENTAR para verificar. Esto tiene implicaciones directas en la integridad de secciones como .rdata durante el análisis de malware.
06. Structs, Enums y Secciones del Formato PE para Reversing
Estructuras (struct) en ensamblador
Los structs se apilan desde el último elemento. Las variables sin inicializar se encuentran en .bss con valor 0x0, mientras que las estructuras globales inicializadas residen en .data. Entender el layout de memoria de los structs es esencial para reconstruir estructuras de datos durante el reversing con Ghidra o IDA.
Enumeraciones (enum) — no existen en runtime
Los enum no existen en tiempo de ejecución. Son simplemente una sustitución de valores literales. La única ventaja que ofrecen es legibilidad al código fuente. En el binario compilado, solo verás los valores numéricos.
Secciones del formato PE
| Sección | Contenido | Relevancia para RE |
|---|---|---|
| .text | Código ejecutable | Donde pasás el 90% del tiempo en Ghidra |
| .data | Datos globales y estáticos inicializados | Strings, variables globales con valor inicial |
| .rdata | Datos inicializados de solo lectura | Constantes, tablas de importación |
| .bss | Datos globales y estáticos no inicializados | Variables declaradas sin valor inicial |
| .idata | Datos de importación (funciones de DLLs) | Clave para identificar qué APIs usa el binario |
| .edata | Datos exportados | Funciones que el binario expone a otros |
| .rsrc | Recursos: iconos, menús, binarios | Puede contener payloads incrustados |
| .reloc | Información de reubicación | Necesaria para ASLR |
07. Punteros en x64: Operaciones y Aritmética de Direcciones
En sistemas de 64 bits, los punteros tienen un tamaño de 8 bytes (qword). Los incrementos/decrementos se hacen alineados a 8 bytes para sistemas de 64 bits y de 4 bytes para sistemas de 32 bits. Operadores unitarios:
- Operador de dirección (&): permite acceder a la dirección de memoria de una variable. Se traduce a lea.
- Operador de indirección (*): además de declarar el tipo puntero, permite acceder al valor alojado en la dirección de memoria asignada. Se traduce a mov con corchetes.
08. Funciones Inline, Macros y Templates en Ensamblador
Funciones inline — el atributo always_inline
La palabra clave inline solo recomienda al compilador incrustar el código. Para forzarlo, se usa el atributo __attribute__((always_inline)) de GCC. Con esto se evita generar símbolos independientes para la función, eliminando las instrucciones call y ret asociadas.
Templates — expansión en tiempo de compilación
Las funciones template se "expanden" en donde son utilizadas, pero añaden la seguridad de comprobación de tipos de datos. Sin el atributo always_inline, el compilador genera dos funciones distintas para tratar con tipos de datos diferentes. Con el atributo, se realiza una sustitución directa de instrucciones sin generar símbolos independientes.
- rbp + desplazamiento: generalmente para acceder a argumentos de funciones o datos almacenados después del marco de pila actual (parámetros pasados por el caller).
- rbp - desplazamiento: usado para variables locales o datos almacenados antes del marco de pila actual (espacio reservado con sub rsp, N).
09. Clases en Ensamblador: Constructores, Métodos y Destructores
Instanciación y constructor — el puntero this en rcx
Al instanciar un objeto, se obtiene la dirección de memoria en el stack donde se alojará la instancia. Esta dirección se pasa como argumento implícito al constructor vía rcx (convención Windows x64) o rdi (convención SystemV). Es el famoso puntero this.
Acceso a atributos — desplazamientos desde this
Se calcula la dirección de memoria tomando como referencia la dirección donde está instanciado el objeto. Los atributos privados y públicos se diferencian solo por el desplazamiento desde la base del objeto: [rax] para var_1, [rax+0x4] para var_2, [rax+0x8] para var_3, [rax+0xc] para var_4. A nivel de ASM, no hay diferencia entre acceso público y privado: ambos son offsets desde la dirección base.
Destructores y operator delete
El destructor se llama implícitamente al finalizar la función. operator delete recibe la dirección de memoria (rcx) y el tamaño del objeto alineado. La función importada operator delete(void*, uint64_t) se encuentra en la sección .idata y referencia a libstdc++-6.
10. Alineamiento de Datos, Padding y #pragma pack
| Tipo | Alineación típica en x64 |
|---|---|
| char | 1 byte |
| short | 2 bytes |
| int | 4 bytes |
| float | 4 bytes |
| long long | 8 bytes |
| pointer (64-bit) | 8 bytes |
En estructuras o clases se suele buscar la máxima alineación de sus miembros para organizarlos a todos con el mismo tamaño. El padding (relleno) consiste en insertar bytes para asegurar que cada miembro comience en una dirección múltiplo de su alineación natural. Con el atributo aligned(N) se puede forzar una alineación específica. Con #pragma pack se puede controlar el tamaño de las estructuras asignando un nuevo alineamiento máximo.
Al reducir el tamaño de las estructuras con #pragma pack, los autores de malware pueden hacer más difícil el análisis estático y alterar la firma por secuencia de bytes detectada por los AVs. Esta es una de las técnicas documentadas en el desarrollo del C2 Builder, donde la ofuscación de estructuras complica la detección basada en firmas.
11. Clases vs Structs: Diferencias a Nivel de ASM
Las clases crean referencias externas fuera del stack: el constructor y los métodos reciben implícitamente la dirección de memoria de la instancia (puntero this). Las estructuras generan una copia completa de la estructura en el stack por cada instancia, lo que puede ocasionar problemas de memoria con múltiples instancias. Los "métodos" en structs son callbacks: se asigna una dirección de función a un miembro puntero. Esta distinción es clave al reconstruir tipos en Ghidra.
12. Asignación Dinámica de Memoria: malloc, realloc, free, new y delete
Stack vs Heap: diferencias fundamentales
| Característica | Stack | Heap |
|---|---|---|
| Estructura | LIFO, lineal | Jerárquica, fragmentada |
| Velocidad | Alta (ajuste de puntero) | Más lento (búsqueda de bloques) |
| Fragmentación | No | Sí |
| Variables | Solo locales | Acceso global (punteros) |
| Límite | Depende del SO (típicamente 1-8 MB) | Sin límite específico (memoria virtual) |
| Redimensionable | No (fijo en compilación) | Sí (realloc) |
| Asignación | Contigua, automática | Aleatoria, manual |
| Desasignación | Automática (al salir del scope) | Manual (free/delete) |
Malloc, Realloc, Free en ASM
New y Delete (C++) — diferencias con malloc/free
new calcula el tamaño mediante el compilador y permite llamar al constructor apropiado para instanciar objetos. delete y delete[] evitan tediosos procesos de desasignación elemento por elemento. En caso de error, malloc retorna NULL y new lanza bad_alloc. A nivel de ASM, new se traduce a una llamada a operator new seguida de la llamada al constructor.
13. Estructuras de Datos: Pilas (Stack LIFO) en Ensamblador
Se presentan tres implementaciones progresivas de una pila (LIFO), desde la más simple hasta la completamente dinámica:
- Implementación básica: arreglo de tamaño fijo con variable top. Funciones push y pop con comprobaciones de overflow/underflow. Al desapilar se sobreescribe con 0.
- Implementación con struct: estructura con top y stack[]. El acceso es [rdx+rax*4+0x4], donde rdx apunta a la estructura y rax es el índice.
- Implementación con clase y memoria dinámica: el constructor asigna el tamaño predefinido vía malloc. Al alcanzar el máximo, se realiza realloc para incrementar la capacidad. Al vaciar la pila, se libera la memoria con free. El destructor libera el bloque automáticamente.
14. Estructuras de Datos: Colas (Queue FIFO) en Ensamblador
Una cola es una lista FIFO (First In, First Out). Se implementa con un arreglo y dos punteros: begin_cola (frente) y next_cola (siguiente lugar disponible). Operaciones: enqueue (insertar al final), dequeue (extraer del frente), isEmpty, isFull, peek.
Se presentan dos implementaciones:
- Cola estática: tamaño fijo. Si está llena, los elementos adicionales se descartan.
- Cola dinámica: al llenarse, realloc incrementa el tamaño en 1. Al vaciarse completamente, free libera la memoria. Si se vuelve a encolar, se crea un nuevo bloque con malloc.
TEST realiza una conjunción lógica (AND) entre dos operandos sin almacenar el resultado. Se usa para verificar si bits están establecidos, comparar valores (usando las banderas como ZF) y crear condiciones para saltos. Es más eficiente que CMP cuando solo necesitás verificar si un valor es cero.
15. Listas Enlazadas: Implementación y Recorrido en ASM
Las listas enlazadas permiten almacenar y manipular información de forma dinámica. Cada elemento es un nodo que contiene un valor y una referencia al siguiente nodo. Son útiles cuando se desconoce el tamaño total de los datos, ya que pueden crecer o reducirse según sea necesario.
| Característica | Listas Enlazadas | Pilas (LIFO) | Colas (FIFO) |
|---|---|---|---|
| Estructura | Secuencia de nodos con dato y puntero | LIFO (un extremo) | FIFO (dos extremos) |
| Operaciones | Insertar/Eliminar en cualquier posición | Push/Pop (cima) | Enqueue/Dequeue (frente/final) |
| Acceso | Secuencial desde el principio | Solo al último (top) | Solo al primero (front) |
| Usos típicos | Estructuras dinámicas, árboles, grafos | Llamadas a funciones, undo/redo | Tareas, colas de SO, buffers |
Estos nodos podrían estar compuestos por punteros a funciones y, mediante el enlace entre ellos, controlar el flujo de ejecución. Esto podría ser directamente la dirección de una función o, en su lugar, almacenar la instrucción que será ejecutada. Esta técnica tiene aplicaciones en ofuscación de flujo de control y polimorfismo en tiempo de ejecución, conceptos explorados en el desarrollo del C2 Builder.
16. Convenciones de Llamada: cdecl, stdcall, SystemV y Windows x64
Es importante conocer las convenciones de llamada para identificar la estructura de las funciones con las que estamos trabajando durante el reversing. La elección de una convención u otra determina dónde se colocan los parámetros, quién limpia la pila y qué registros se preservan.
32-bit cdecl — convención por defecto en C
- Convención predeterminada en sistemas POSIX (ABI SystemV i386) y Windows 32-bit.
- Parámetros: en la pila, primer argumento en la dirección más baja (empujado en último lugar).
- Retorno escalar: en EAX o EDX:EAX (64 bits). Flotantes en st(0) (x87).
- Estructuras: por referencia, con puntero pasado como primer parámetro implícito (se devuelve en EAX).
- Registros guardados: EBX, EDI, ESI, EBP, ESP. EAX, ECX, EDX pueden modificarse libremente.
- Limpieza de pila: el caller (llamador) limpia los parámetros después de la llamada.
32-bit stdcall — API de Windows
- Usado para llamadas API de Windows 32-bit (Win32 API).
- Parámetros: en la pila, el destinatario (callee) los extrae antes de devolver con ret N.
- Retorno: valores escalares en EAX.
- Ventaja: código más compacto que cdecl porque el caller no necesita limpiar la pila después de cada llamada.
32-bit cdecl: Tratamiento de tipos
- Enteros 8/16/32 bits: siempre en la pila como valores de 32 bits de ancho completo.
- Enteros 64 bits: dos pushes (32 bits superiores primero, luego inferiores). Retorno en EDX:EAX.
- Flotantes 32 bits: en la pila.
- Doubles 64 bits: dos pushes (32 bits superiores primero).
- Long doubles 80 bits: ocupan 12 bytes (tres pushes de 32 bits).
- Estructuras por valor: se copian completamente en la pila respetando el diseño de memoria original.
- Estructura trivial (un miembro ≤ 32 bits): se devuelve en EAX (GCC/Linux) o EDX:EAX si tiene dos miembros (MSVC/Win32).
64-bit SystemV — Linux/macOS
- Predeterminada en sistemas POSIX de 64 bits (Linux, macOS, BSD).
- Parámetros: primeros 6 en RDI, RSI, RDX, RCX, R8, R9. El resto en pila.
- Retorno escalar: en RAX. Estructuras grandes: puntero implícito en RDI al inicio de la lista de parámetros.
- Registros guardados: RBP, RBX, R12-R15. Todos los demás pueden modificarse libremente.
- Shadow space: no requerido (a diferencia de Windows x64).
64-bit Windows — Microsoft x64 calling convention
- Parámetros: primeros 4 en RCX, RDX, R8, R9. Flotantes en XMM0-XMM3. Resto en pila.
- El caller siempre reserva espacio para 4 parámetros QWORD en la pila (32 bytes de shadow space), incluso si la función usa menos.
- Parámetros > 64 bits se pasan por dirección.
- Retorno: escalar en RAX. Estructuras > 64 bits: puntero en RAX.
- Registros guardados: RBP, RBX, RDI, RSI, R12-R15, XMM6-XMM15.
Las convenciones de llamada son necesarias para implementar correctamente patrones de búsqueda en scripts de reversing, como los utilizados en analystty para la detección automática de TTPs. Es importante tomar en cuenta las distinciones entre compiladores en base a su sistema objetivo y la arquitectura. Un binario compilado para Windows x64 tendrá una estructura de llamadas radicalmente diferente a uno compilado para Linux x64, incluso si el código fuente es idéntico.
17. Heurísticas para Identificación de Funciones en Reversing
Para obtener etiquetas durante el proceso de desensamblado cuando no hay información de debugging, se utilizan las siguientes técnicas:
- Tabla de direcciones virtuales: no siempre presente, sobre todo si se borra la información de debugging (stripped binaries).
- Análisis de flujo: identificar bloques simples y saltos para saber cuándo inicia una función. Los saltos hacia atrás (jmp a una dirección menor) suelen indicar bucles.
- Patrones de llamada: buscar por convenciones de llamada identificando instrucciones de llamada y valores devueltos. Analizar la pila durante las llamadas para inferir parámetros y retorno.
- Heurística de prólogo/epílogo: reconocer ajustes de pila (push rbp; mov rbp, rsp; sub rsp, N), guardado de registros y retorno (add rsp, N; pop rbp; ret). Esta es la técnica más fiable y la que implementa analystty en su módulo de detección de funciones.
18. Próximos Temas en la Serie de Reversing
Al terminar con listas enlazadas y las otras estructuras de datos, tocaremos:
- Herencia, plantillas (templates) y polimorfismo — cómo se traducen las v-tables a ASM
- Sobrecarga de operadores y funciones — name mangling y resolución de símbolos
- Bibliotecas y el enlazador (linker) — cómo se resuelven las dependencias en tiempo de compilación
19. Referencias
- analystty — Automatización de Triage de Malware PE con detección de funciones por heurística de prólogo/epílogo
- De la Shellcode Artesanal al C2 Builder — Técnicas de ofuscación y alineamiento
- GCC — Function Attributes
- StackOverflow — always_inline attribute
- GCC — Structure-Packing Pragmas
- cppreference — operator delete
- C Language — Puntos de secuencia