Flujo de Control: Loops, Condicionales y Switch en Ensamblador
Como los compiladores traducen if/else, for, while, do-while y switch/case a ensamblador x86/x64. Patrones de reconocimiento, jump tables, y como el malware ofusca el flujo de control para dificultar el analisis.
Reconocer estructuras de alto nivel en ensamblador
Cuando un compilador traduce codigo C o C++ a ensamblador, las estructuras de control (if, for, while, switch) se convierten en secuencias de comparaciones y saltos. Reconocer estos patrones te permite reconstruir mentalmente la logica del programa original mientras lees ensamblador en Ghidra o IDA.
Los decompiladores automatizan este proceso, pero no siempre aciertan. El malware ofuscado o el codigo optimizado agresivamente puede confundir al decompilador. Conocer los patrones a nivel de ensamblador te da la capacidad de verificar y corregir la salida del decompilador.
If / else
Patron basico
Un if simple se traduce a una comparacion seguida de un salto condicional. El compilador invierte la condicion: si el codigo fuente dice "if (x == 5)", el ensamblador salta cuando x NO es 5 (para saltar por encima del bloque if).
Codigo fuente equivalente:
if (valor == 5)
accion_a();
Ensamblador generado:
cmp dword [ebp-0x04], 5 ; compara variable local con 5
jne despues_if ; si NO es igual, salta el bloque if
call accion_a ; solo se ejecuta si valor == 5
despues_if:
If / else completo
if (resultado != 0)
exito();
else
error();
test eax, eax ; resultado de la funcion anterior
jz bloque_else ; si es cero, salta al else
call exito ; bloque if: resultado no era cero
jmp despues_ifelse ; salta el else
bloque_else:
call error ; bloque else: resultado era cero
despues_ifelse:
El patron clave es: salto condicional al else, codigo del if, JMP incondicional despues del else, codigo del else. Este patron de dos saltos (condicional + incondicional) es la marca de un if/else.
If con condiciones compuestas
if (a > 10 && b < 20)
accion();
El AND logico se traduce a dos comparaciones con cortocircuito:
cmp dword [ebp-0x04], 10 ; a > 10?
jle despues_if ; si a <= 10, no evaluar b (cortocircuito)
cmp dword [ebp-0x08], 20 ; b < 20?
jge despues_if ; si b >= 20, no ejecutar accion
call accion
despues_if:
Con OR logico, la estructura se invierte:
if (a == 1 || a == 2)
accion();
cmp dword [ebp-0x04], 1
je ejecutar_accion ; si a == 1, ejecutar directamente
cmp dword [ebp-0x04], 2
jne despues_if ; si tampoco es 2, saltar
ejecutar_accion:
call accion
despues_if:
If anidados
Los if anidados producen cascadas de saltos condicionales. En Ghidra, el grafo de control flow muestra esto como una cadena de diamantes (cada condicion bifurca el flujo). Si ves muchos saltos condicionales consecutivos sin JMP incondicionales entre ellos, probablemente son condiciones anidadas o encadenadas.
Loops: while, do-while, for
while loop
while (contador > 0) {
procesar(buffer[i]);
contador--;
}
El compilador puede generar dos variantes:
Variante 1: comparacion al inicio (pre-test loop)
jmp test_condicion ; salta a la condicion primero
cuerpo_loop:
mov eax, [esi] ; procesar(buffer[i])
push eax
call procesar
add esp, 4
dec dword [ebp-0x04] ; contador--
test_condicion:
cmp dword [ebp-0x04], 0 ; contador > 0?
jg cuerpo_loop ; si es mayor, volver al cuerpo
Variante 2: duplicacion de la condicion
cmp dword [ebp-0x04], 0 ; verificar antes de entrar
jle fin_loop ; si ya es 0 o negativo, no entrar
cuerpo_loop:
; ... cuerpo del loop ...
dec dword [ebp-0x04]
cmp dword [ebp-0x04], 0
jg cuerpo_loop ; verificar al final
fin_loop:
do-while loop
do {
byte = leer_byte(stream);
} while (byte != 0);
cuerpo_loop:
push dword [ebp-0x08] ; stream
call leer_byte
add esp, 4
mov [ebp-0x04], al ; byte = resultado
test al, al ; byte != 0?
jnz cuerpo_loop ; si no es cero, repetir
El do-while es el loop mas simple en ensamblador: el cuerpo, una comparacion y un salto hacia atras. No hay salto de entrada ni verificacion previa. En malware, los loops de descifrado suelen ser do-while porque el programador sabe que el buffer tiene al menos un byte.
for loop
for (int i = 0; i < longitud; i++) {
buffer[i] ^= clave;
}
xor ecx, ecx ; i = 0
jmp test_for ; salta a la condicion
cuerpo_for:
mov al, [esi+ecx] ; buffer[i]
xor al, [ebp-0x04] ; ^= clave
mov [esi+ecx], al ; guarda resultado
inc ecx ; i++
test_for:
cmp ecx, [ebp-0x08] ; i < longitud?
jl cuerpo_for ; si es menor, continuar
El for y el while generan ensamblador practicamente identico. La unica pista para distinguirlos es que en el for, la inicializacion (XOR ECX, ECX) y el incremento (INC ECX) estan mas agrupados con la comparacion. Pero esto no es fiable: el decompilador elige for o while segun heuristicas internas.
Loops con instruccion LOOP
La instruccion LOOP es una instruccion legada que decrementa ECX y salta si ECX no es cero:
mov ecx, 256 ; 256 iteraciones
bucle:
xor byte [esi], 0x5A ; operacion del loop
inc esi
loop bucle ; ECX--; si ECX != 0, saltar a bucle
LOOP es mas lenta que DEC ECX + JNZ en procesadores modernos, por lo que los compiladores actuales no la generan. Pero aparece en shellcode escrito a mano y en malware antiguo. Si ves LOOP en un binario moderno, es probable que esa seccion fue escrita manualmente en ensamblador.
Switch / case
Los compiladores traducen switch/case de tres formas segun el numero de cases y la densidad de los valores.
Pocos cases: cadena if-else
Con 3-4 cases, el compilador genera comparaciones secuenciales:
switch (tipo) {
case 1: handler_tcp(); break;
case 2: handler_udp(); break;
case 3: handler_icmp(); break;
default: handler_unknown();
}
cmp eax, 1
je case_tcp
cmp eax, 2
je case_udp
cmp eax, 3
je case_icmp
jmp case_default
case_tcp:
call handler_tcp
jmp fin_switch
case_udp:
call handler_udp
jmp fin_switch
case_icmp:
call handler_icmp
jmp fin_switch
case_default:
call handler_unknown
fin_switch:
Muchos cases densos: jump table
Cuando hay muchos cases con valores consecutivos o casi consecutivos, el compilador genera una jump table (tabla de saltos):
switch (comando) {
case 0: cmd_ping(); break;
case 1: cmd_shell(); break;
case 2: cmd_download(); break;
case 3: cmd_upload(); break;
case 4: cmd_screenshot(); break;
case 5: cmd_keylog(); break;
}
cmp eax, 5 ; verificar rango valido
ja case_default ; si es mayor que 5, default
jmp dword [tabla_saltos + eax*4] ; salta via la tabla
; Tabla de saltos (en .rdata o .text)
tabla_saltos:
dd offset case_0_ping ; comando 0
dd offset case_1_shell ; comando 1
dd offset case_2_download ; comando 2
dd offset case_3_upload ; comando 3
dd offset case_4_screenshot ; comando 4
dd offset case_5_keylog ; comando 5
Las jump tables son particularmente interesantes en malware porque revelan la tabla de comandos del C2. Si encuentras una jump table con 10-20 entradas, cada entrada es un handler de un comando diferente. Los RATs tipicamente usan jump tables para despachar los comandos recibidos del servidor C2.
Para reconocer una jump table en IDA o Ghidra:
- Busca un CMP seguido de JA (verificacion de rango)
- Busca un JMP con direccionamiento
[base + reg*4] - En la seccion .rdata, busca un array de dwords que apuntan a la seccion .text
Cases dispersos: arbol binario
Si los valores de los cases son muy dispersos (por ejemplo 1, 100, 500, 9999), el compilador puede generar un arbol de busqueda binaria con comparaciones anidadas, o una tabla de lookup con indices.
Patrones del compilador que debes reconocer
Operador ternario
resultado = (condicion) ? valor_a : valor_b;
test eax, eax
cmovnz ecx, edx ; si EAX != 0, ECX = EDX (sin salto)
La instruccion CMOVcc (Conditional Move) evita un salto condicional. Es mas eficiente porque no rompe la prediccion de ramas del procesador. Si ves CMOVcc, generalmente es un operador ternario o un min/max.
Min y max
menor = (a < b) ? a : b;
cmp eax, ecx ; compara a y b
cmovg eax, ecx ; si a > b, eax = b (se queda con el menor)
Comparacion con cero optimizada
Los compiladores nunca generan cmp eax, 0. Siempre usan test eax, eax que tiene el mismo efecto pero es 1 byte mas corto.
Multiplicacion por constantes
En lugar de IMUL, los compiladores generan combinaciones de LEA, SHL y ADD:
; Multiplicar por 3
lea eax, [ecx+ecx*2] ; eax = ecx * 3
; Multiplicar por 5
lea eax, [ecx+ecx*4] ; eax = ecx * 5
; Multiplicar por 10
lea eax, [ecx+ecx*4] ; eax = ecx * 5
add eax, eax ; eax = eax * 2 = ecx * 10
; Multiplicar por 7
lea eax, [ecx*8] ; eax = ecx * 8
sub eax, ecx ; eax = ecx * 8 - ecx = ecx * 7
Division por constantes
En lugar de DIV (que es lenta), los compiladores usan multiplicacion por el recirproco seguida de un desplazamiento:
; Division de EAX entre 10
mov ecx, 0xCCCCCCCD ; "magic number" reciproco de 10
mul ecx ; EDX:EAX = EAX * 0xCCCCCCCD
shr edx, 3 ; EDX = cociente (EAX / 10)
Si ves un MUL con una constante grande seguido de un SHR de EDX, es una division optimizada. Los decompiladores suelen reconocer este patron y mostrarlo como division.
Ofuscacion de flujo de control en malware
El malware avanzado usa tecnicas para dificultar el analisis del flujo de control:
Opaque predicates
Una condicion que siempre es true (o siempre false) pero parece una condicion real:
mov eax, [ebp-0x04]
imul eax, eax ; eax = x * x
and eax, 1 ; x^2 es par si x es par, impar si x es impar
; Pero x*(x-1) siempre es par:
mov ecx, [ebp-0x04]
dec ecx
imul eax, ecx ; eax = x * (x-1), siempre par
test eax, 1
jnz rama_falsa ; NUNCA se toma (x*(x-1) siempre es par)
; codigo real aqui
Control flow flattening
Todos los bloques basicos se ponen al mismo nivel dentro de un gran switch/dispatch loop:
; Dispatcher
dispatch:
mov eax, [estado]
cmp eax, 1
je bloque_1
cmp eax, 2
je bloque_2
cmp eax, 3
je bloque_3
jmp fin
bloque_1:
; ... codigo ...
mov dword [estado], 3 ; siguiente bloque = 3
jmp dispatch
bloque_2:
; ... codigo ...
mov dword [estado], 1
jmp dispatch
bloque_3:
; ... codigo ...
mov dword [estado], 2
jmp dispatch
El flujo real es 1, 3, 2, pero mirando el ensamblador linealmente es imposible determinarlo sin trazar la variable de estado. El decompilador muestra un gran switch incomprensible. Herramientas como D810 (plugin IDA) y el script de deflattening de Ghidra pueden revertir parcialmente esta ofuscacion.
Saltos indirectos calculados
En lugar de JMP directo a una direccion, el malware calcula la direccion en runtime:
mov eax, 0x401000 ; direccion base
add eax, [ebp-0x04] ; offset variable
xor eax, 0x12345678 ; descifra la direccion
jmp eax ; salta a direccion calculada
Este patron rompe el grafo de control flow porque el desensamblador no puede determinar estaticamente a donde salta el JMP.
Ejercicio de reconocimiento
Al analizar malware, practica identificar estas estructuras:
- Busca pares CMP/TEST + Jcc: cada par es una decision (if, loop, switch)
- Busca saltos hacia atras: cada salto hacia atras es un loop
- Busca JMP incondicional despues de un bloque de codigo: marca el fin de un bloque if o un case del switch
- Busca JMP [reg + reg4] o JMP [tabla + reg4]: jump tables de switch
- Busca secuencias de CMP + JE con el mismo registro: cadena if-else o switch con pocos cases
Con practica, estos patrones se vuelven automaticos y puedes reconstruir la logica de alto nivel mentalmente mientras recorres el desensamblado.
Preguntas frecuentes
Libros recomendados
Artículos relacionados
Este contenido tiene fines exclusivamente educativos y de investigación en ciberseguridad defensiva. No se proporcionan binarios maliciosos ni payloads ejecutables. El uso indebido de esta información es responsabilidad exclusiva del usuario. Leer disclaimer completo.