
Desofuscação via otimização com uso de LLVM IR e análise de assembly.
Mergen é uma ferramenta projetada para converter código Assembly em Representação Intermediária (IR) do LLVM. Esta ferramenta foi criada para:
Para construir e executar o projeto, consulte docs/BUILDING.md.
O trabalho de reescrita deve manter o portão de regressão da base verde. O portão compila amostras PE focadas, executa lifter e verifica as saídas IR geradas.
scripts\\rewrite\\run.cmdNós executamos simbolicamente (ou elevamos simbolicamente) o alvo, a ideia aqui não é elevar instruções individuais, mas sim elevar uma função inteira. Não esperamos que uma única instrução ou um único bloco básico se comporte da mesma forma toda vez, em vez disso, os tratamos como se pudessem ser e são para propósitos diferentes a cada vez. Tentamos manter o IR gerado o mais simples e otimizável possível. Também temos necessidades diferentes de um compilador comum. Usamos análise para avaliar o fluxo de controle. Não podemos depender do LLVM para toda a nossa análise, porque eles são criados para objetivos diferentes e podem não ser ideais para o nosso caso de uso.

Este é o exemplo prático para ilustrar como o Mergen resolve contra programas virtualizados.
Este é o nosso programa alvo
struct test {
int a;
int b;
int c;
};
int maths(test a, int b, int c) {
return a.a + b - c;
}


Configurações do VMProtect, tudo está desligado, virtualizamos a função na configuração ultra. (Versões testadas 3.4.0-3.6.0 3.8.1)


Aqui, executamos o mergen. O primeiro argumento é o nome do arquivo e o segundo argumento é o endereço da função. Veja como é simples de executar. E podemos compilar a saída para explorá-la usando nosso decompilador 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) }
Após compilar:


Agora você pode notar que os registradores estão um pouco diferentes. Isso ocorre porque não seguimos as convenções de chamada; se seguíssemos as convenções de chamada, a assinatura da função seria assim:
define i64 @main(i64 %rcx, i64 %rdx, i64 %rdx, i64 %r8, i64 %r9 ...)
Portanto, apenas ajustamos a assinatura da função para parecer normal. Se você tiver mais dúvidas sobre esta parte, sugiro pesquisar sobre convenções de chamada e ABI.
Então, vamos supor que temos este código. As VMs pegarão o código abaixo e o transformarão em um salto indireto, o que é um pouco mais incômodo para o engenheiro reverso.
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;
Nós sempre tentamos analisar valores e mantê-los rastreados. Isso nos permite entender o fluxo de controle. Para branches do tipo jumptable A saída otimizada seria um simples
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
}
Saída não otimizada. (DCE aplicado para legibilidade)
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-
}
Observe esta parte
%realand-5368713229- = and i64 %creatingrflag4, 128
%shr-lshr-5368713233- = lshr i64 %realand-5368713229-, 7
Obtemos os flags, depois obtemos o 7º bit, que é o Flag de Sinal (Sign Flag), e usamos o Flag de Sinal para calcular um endereço. Através da análise, determinamos que o endereço pode ser um de dois valores, 5368713257 ou 5368713264, e então transformamos isso em uma comparação. Se o endereço for 5368713257, pegue um ramo, se for o outro, pegue o outro. Ao fazer isso, também é importante marcar a condição com o valor apropriado, porque mais tarde, podemos precisar calcular outro salto com o mesmo valor exato.
Mesmo que resolvamos os saltos indiretos, saltos com mais de 2 locais possíveis não são suportados. Isso ocorre porque a análise para eles ainda não foi implementada. Isso nos permite resolver branches do tipo VM, mas temos problemas com jumptables reais.
Nosso programa alvo:

Configurações do Themida (só nos importamos com VMs no momento):



Após a VM:

Executando o Mergen:

Código de saída: clique aqui Então, por que nosso resultado não é tão bem-sucedido quanto elevar um binário protegido por VMP?
O Themida escreve ativamente na seção .themida. Ao contrário da pilha, não podemos desconsiderar essas escritas, porque esses valores podem ser lidos por outras coisas posteriormente.
Mas temos uma solução temporária para isso. Remover todos os stores na seção .themida. Como nosso programa não escreve na memória, apenas comentei todos os stores. Agora ficamos com isto:
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) }
Junte-se ao nosso Servidor Discord Mergen para trocar ideias ou apenas conversar em geral.