
Desofuscación mediante optimización con uso de LLVM IR y análisis de ensamblador.
Mergen es una herramienta diseñada para convertir código ensamblador en Representación Intermedia (IR) de LLVM. Esta herramienta está pensada para:
Para compilar y ejecutar el proyecto, consulta docs/BUILDING.md.
El trabajo de reescritura debe mantener el gate de regresión base en verde. El gate compila muestras PE específicas, ejecuta lifter y verifica las salidas de IR elevado.
scripts\\rewrite\\run.cmdEjecutamos simbólicamente (o elevamos simbólicamente) el objetivo. La idea aquí no es elevar instrucciones individuales, sino elevar una función completa. No esperamos que una instrucción ni un bloque básico se comporten igual cada vez; en cambio, los tratamos como si pudieran ser y de hecho son utilizados para diferentes propósitos en cada ocasión. Intentamos mantener el IR generado lo más simple y optimizable posible. También tenemos necesidades diferentes a las de un compilador habitual. Usamos análisis para evaluar el flujo de control. No podemos depender de LLVM para todo nuestro análisis, porque están creados para objetivos distintos y podrían no ser óptimos para nuestro caso de uso.

Este es el ejemplo práctico que ilustra cómo Mergen resuelve contra programas virtualizados.
Este es nuestro programa objetivo
struct test {
int a;
int b;
int c;
};
int maths(test a, int b, int c) {
return a.a + b - c;
}


Configuración de VMProtect, todo está desactivado; virtualizamos la función en configuración ultra. (Versiones probadas 3.4.0-3.6.0 3.8.1)


Aquí ejecutamos mergen. El primer argumento es el nombre del archivo y el segundo argumento es la dirección de la función. Mira lo sencillo que es ejecutarlo. Y podemos compilar la salida para explorarla con nuestro descompilador favorito.

; ModuleID = 'my_lifting_module'
source_filename = "my_lifting_module"
; Function Attrs: mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: read)
define i64 @main(i64 %rax, i64 %rcx, i64 %rdx, i64 %rbx, i64 %0, i64 %rbp, i64 %rsi, i64 %rdi, i64 %r8, i64 %r9, i64 %r10, i64 %r11, i64 %r12, i64 %r13, i64 %r14, i64 %r15, ptr nocapture readonly %memory) local_unnamed_addr #0 {
entry:
%stackmemory = alloca i128, i128 13758960, align 8
%1 = trunc i64 %r8 to i32
%2 = trunc i64 %rdx to i32
%GEPLoadxd-5369456437- = getelementptr i8, ptr %memory, i64 %rcx
%3 = load i32, ptr %GEPLoadxd-5369456437-, align 4
%adc-temp-5370242400- = sub i32 %2, %1
%realnot-5369532059- = add i32 %adc-temp-5370242400-, %3
%stackmemory10243.sroa.55.1375304.insert.ext10255 = zext i32 %realnot-5369532059- to i64
ret i64 %stackmemory10243.sroa.55.1375304.insert.ext10255
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: read) }
Después de compilar:


Ahora te darás cuenta de que los registros están un poco descolocados. Esto se debe a que no seguimos las convenciones de llamada; si las siguiéramos, la firma de la función se vería así:
define i64 @main(i64 %rcx, i64 %rdx, i64 %rdx, i64 %r8, i64 %r9 ...)
Así que simplemente ajustamos la firma de la función para que se vea normal. Si tienes más preguntas sobre esta parte, te sugiero investigar las convenciones de llamada y el ABI.
Entonces, digamos que tenemos este código. Las VM tomarán el siguiente código y lo convertirán en un salto indirecto, lo que es un poco más incómodo para el reverser.
int maths(int a, int b, int c) {
if (a > b)
return a + b + c;
else
return a - b - c;
}
next_handler = xxx;
if ( a-b > 0 )
next_handler = yyy;
jump next_handler;
Siempre intentamos analizar los valores y hacerles seguimiento. Esto nos permite comprender el flujo de control. Para ramas tipo tabla de salto La salida optimizada sería una simple
define i64 @main(i64 %rax, i64 %rcx, i64 %rdx, i64 %rbx, i64 %rsp, i64 %rbp, i64 %rsi, i64 %rdi, i64 %r8, i64 %r9, i64 %r10, i64 %r11, i64 %r12, i64 %r13, i64 %r14, i64 %r15, ptr nocapture readnone %TEB, ptr nocapture readnone %memory) local_unnamed_addr #0 {
fake_ret:
%0 = lshr i64 %rcx, 62
%common.ret.op = and i64 %0, 2
ret i64 %common.ret.op
}
Salida no optimizada. (DCE para legibilidad)
source_filename = "my_lifting_module"
define i64 @main(i64 %rax, i64 %rcx, i64 %rdx, i64 %rbx, i64 %rsp, i64 %rbp, i64 %rsi, i64 %rdi, i64 %r8, i64 %r9, i64 %r10, i64 %r11, i64 %r12, i64 %r13, i64 %r14, i64 %r15, ptr %TEB, ptr %memory) {
%lsb = and i64 %rcx, 255
%pf1 = mul i64 %lsb, 72340172838076673
%pf2 = and i64 %pf1, -9205322385119247871
%pf3 = urem i64 %pf2, 511
%pf4 = and i64 %pf3, 1
%pf5 = icmp eq i64 0, %pf4
%0 = zext i1 %pf5 to i64
%createrflag2 = shl i64 %0, 2
%creatingrflag = or i64 2, %createrflag2
%zeroflag = icmp eq i64 %rcx, 0
%1 = zext i1 %zeroflag to i64
%createrflag21 = shl i64 %1, 6
%creatingrflag2 = or i64 %creatingrflag, %createrflag21
%signflag = icmp slt i64 %rcx, 0
%2 = zext i1 %signflag to i64
%createrflag23 = shl i64 %2, 7
%creatingrflag4 = or i64 %creatingrflag2, %createrflag23
%GEPSTORE-5368713221- = getelementptr i8, ptr %memory, i64 1376032
store i64 %creatingrflag4, ptr %GEPSTORE-5368713221-, align 4
%realand-5368713229- = and i64 %creatingrflag4, 128
%shr-lshr-5368713233- = lshr i64 %realand-5368713229-, 7
%3 = mul i64 %shr-lshr-5368713233-, 4
%bvalue_indexvalue = add i64 5368713249, %3
%4 = icmp eq i64 %bvalue_indexvalue, 5368713253
%lolb- = select i1 %4, i64 5368713264, i64 5368713257
%GEPSTORE-5368713248- = getelementptr i8, ptr %memory, i64 1376032
store i64 %lolb-, ptr %GEPSTORE-5368713248-, align 4
br i1 %4, label %real_ret, label %real_ret41
real_ret: ; preds = %fake_ret
%inc-5368713273- = add i64 %shr-lshr-5368713233-, 1
ret i64 %inc-5368713273-
real_ret41: ; preds = %fake_ret
ret i64 %shr-lshr-5368713233-
}
Observa esta parte
%realand-5368713229- = and i64 %creatingrflag4, 128
%shr-lshr-5368713233- = lshr i64 %realand-5368713229-, 7
Obtenemos las banderas, luego obtenemos el 7º bit, que es la bandera de signo (Sign Flag), y usamos la bandera de signo para calcular una dirección. Mediante el análisis, determinamos que la dirección podría ser uno de dos valores, 5368713257 o 5368713264, y luego lo convertimos en una comparación. Si la dirección es 5368713257, tomamos una rama; si es la otra, tomamos la otra. Al hacer esto, también es importante marcar la condición con el valor apropiado porque más adelante podríamos necesitar calcular otro salto con el mismo valor exacto.
Aunque resolvemos los saltos indirectos, los saltos con más de 2 ubicaciones posibles no son compatibles. Esto se debe a que el análisis para ellos aún no está implementado. Esto nos permite resolver las ramas estilo VM, pero tenemos problemas con las tablas de salto de la vida real.
Nuestro programa objetivo:

Configuración de Themida (solo nos importan las VM por ahora):



Después de la VM:

Ejecutando Mergen:

Código de salida: haz clic aquí Entonces, ¿por qué nuestro resultado no tiene tanto éxito como al elevar un binario protegido con VMP?
Themida escribe activamente en la sección .themida. A diferencia de la pila, no podemos ignorar estas escrituras porque esos valores podrían ser leídos más tarde por otras cosas.
Pero tenemos una solución temporal para eso. Eliminar todos los stores en la sección .themida. Como nuestro programa no escribe en memoria, solo comenté todos los stores. Ahora nos queda esto:
source_filename = "my_lifting_module"
define i64 @main(i64 %rax, i64 %rcx, i64 %rdx, i64 %rbx, i64 %rsp, i64 %rbp, i64 %rsi, i64 %rdi, i64 %r8, i64 %r9, i64 %r10, i64 %r11, i64 %r12, i64 %r13, i64 %r14, i64 %r15, ptr writeonly %memory) local_unnamed_addr #0 {
%trunc = trunc i64 %r8 to i32
%trunc1 = trunc i64 %rdx to i32
%trunc2 = trunc i64 %rcx to i32
%realadd-5369771371- = add i32 %trunc1, %trunc2
%realadd-5369582686- = add i32 %realadd-5369771371-, %trunc
%trunc457139 = zext i32 %realadd-5369582686- to i64
ret i64 %trunc457139
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: write) }
Únete a nuestro Servidor de Discord de Mergen para intercambiar ideas o simplemente charlar.