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

; Stack frame típico en x64 — prólogo y epílogo

push rbp ; Guardar puntero base del caller

mov rbp, rsp ; Establecer nuevo marco de pila

sub rsp, 0x30 ; Reservar 48 bytes para variables locales

; ... cuerpo de la función ...

add rsp, 0x30 ; Liberar espacio reservado

pop rbp ; Restaurar puntero base del caller

retn ; Retorno de la función
Observaciones sobre __main

__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

; do { a++; } while(a == 10); — traducción directa a ASM

loop_start:

movzx eax, byte [rbp-0x1]

add eax, 0x1

mov byte [rbp-0x1], al

cmp byte [rbp-0x1], 0xa

jne loop_start

; NOTA: las variables no definidas se crean al asignarlas en el stack

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.

Observaciones clave sobre tipos de retorno

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 variableSección PEComportamiento
auto global.dataInicializada en tiempo de compilación
static global.dataInicializada, visibilidad limitada al archivo
const global.rdataSolo lectura
static const global.rdataSolo lectura, visibilidad limitada
auto localStackInicializada en ejecución
static local.dataPersiste entre llamadas
const localStackEl compilador sustituye su valor literal
static const local.rdataPersiste, solo lectura
Constantes: ¿realmente protegidas?

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ónContenidoRelevancia para RE
.textCódigo ejecutableDonde pasás el 90% del tiempo en Ghidra
.dataDatos globales y estáticos inicializadosStrings, variables globales con valor inicial
.rdataDatos inicializados de solo lecturaConstantes, tablas de importación
.bssDatos globales y estáticos no inicializadosVariables declaradas sin valor inicial
.idataDatos de importación (funciones de DLLs)Clave para identificar qué APIs usa el binario
.edataDatos exportadosFunciones que el binario expone a otros
.rsrcRecursos: iconos, menús, binariosPuede contener payloads incrustados
.relocInformación de reubicaciónNecesaria 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:

; int* ptr_arr_end = &arr[3]; — obtener dirección del 4to elemento

lea rax, [rbp-0x1c] ; Dirección base del arreglo en el stack

add rax, 0x3 ; Desplazamiento al 4to elemento (0-indexed)

mov [rbp-0x18], rax ; Guardar dirección calculada en ptr_arr_end

; result = *(--ptr_arr_end) + *(++ptr_arr); — pre-decremento y pre-incremento

sub qword [rbp-0x18], 0x1 ; Decrementar dirección (restar 1 byte)

mov rax, qword [rbp-0x18] ; Cargar dirección decrementada

movzx eax, byte [rax] ; Acceder al valor en esa dirección

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.

Acceso a la pila: dos formas de direccionamiento

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.

; class_0 cl; — instanciación y llamada al constructor

lea rax, [rbp-0x10] ; Dirección en el stack para la instancia

mov rcx, rax ; Pasar this como primer argumento (Windows x64)

call class_0::class_0 ; Llamada al constructor

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

TipoAlineación típica en x64
char1 byte
short2 bytes
int4 bytes
float4 bytes
long long8 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.

Implicaciones para análisis de malware

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ísticaStackHeap
EstructuraLIFO, linealJerárquica, fragmentada
VelocidadAlta (ajuste de puntero)Más lento (búsqueda de bloques)
FragmentaciónNo
VariablesSolo localesAcceso global (punteros)
LímiteDepende del SO (típicamente 1-8 MB)Sin límite específico (memoria virtual)
RedimensionableNo (fijo en compilación)Sí (realloc)
AsignaciónContigua, automáticaAleatoria, manual
DesasignaciónAutomática (al salir del scope)Manual (free/delete)

Malloc, Realloc, Free en ASM

; ptr = (int*)malloc(sizeof(int)); — asignar 4 bytes en el heap

mov ecx, 0x4 ; sizeof(int) = 4 bytes

call malloc ; Retorna dirección del bloque en rax

mov [rbp-0x8], rax ; Guardar puntero en variable local

; ptr = (int*)realloc(ptr, sizeof(*ptr) + (8 * sizeof(int))); — redimensionar

mov rax, [rbp-0x8] ; Bloque original

mov edx, 0x24 ; 4 + (4 * 8) = 36 bytes — nuevo tamaño

mov rcx, rax ; Primer argumento: puntero original

call realloc ; Retorna nueva dirección (puede ser distinta)

mov [rbp-0x8], rax ; Actualizar puntero con nueva dirección

; free(ptr); — liberar bloque

mov rax, [rbp-0x8] ; Cargar puntero

mov rcx, rax ; Pasar como argumento

call free ; Liberar memoria

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:

  1. 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.
  2. 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.
  3. 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.
; push: stack[*top] = e; — implementación con struct

mov rax, [rbp+0x10] ; Cargar puntero a stack_struct

mov eax, [rax] ; Cargar valor actual de top

lea edx, [rax+0x1] ; Incrementar top (nuevo índice)

mov rax, [rbp+0x10] ; Recargar puntero a stack_struct

mov [rax], edx ; Almacenar nuevo valor de top

; Calcular dirección de almacenamiento: base + top * sizeof(elemento)

mov ecx, [rbp+0x18] ; Cargar elemento e a insertar

mov [rdx+rax*4+0x4], ecx ; Almacenar en stack[top] (4 bytes por elemento)

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:

La instrucción TEST — AND sin almacenar resultado

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ísticaListas EnlazadasPilas (LIFO)Colas (FIFO)
EstructuraSecuencia de nodos con dato y punteroLIFO (un extremo)FIFO (dos extremos)
OperacionesInsertar/Eliminar en cualquier posiciónPush/Pop (cima)Enqueue/Dequeue (frente/final)
AccesoSecuencial desde el principioSolo al último (top)Solo al primero (front)
Usos típicosEstructuras dinámicas, árboles, grafosLlamadas a funciones, undo/redoTareas, colas de SO, buffers
// Implementación: Método de inserción al frente (prepend)

NODO_SIMPLE nodo_tail = NODO_SIMPLE(0x42);

NODO_SIMPLE nodo_middle = NODO_SIMPLE(0x51, &nodo_tail);

NODO_SIMPLE nodo_header = NODO_SIMPLE(0x81, &nodo_middle);

// Construir lista enlazada y asignar posiciones

LISTA_ENLAZADA_SIMPLE lista_enlazada = LISTA_ENLAZADA_SIMPLE(&nodo_header);
IDEA: Nodos con punteros a funciones — ofuscación de flujo de control

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

32-bit stdcall — API de Windows

32-bit cdecl: Tratamiento de tipos

64-bit SystemV — Linux/macOS

64-bit Windows — Microsoft x64 calling convention

RECORDATORIO: Aplicación práctica en reversing

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:

18. Próximos Temas en la Serie de Reversing

Al terminar con listas enlazadas y las otras estructuras de datos, tocaremos:

19. Referencias