Analisando códigos com mutação - VM Protect e Alcatraz
Author: João Vitor (@Keowu) - Security Researcher
Introdução
Este artigo visa apenas ilustrar algumas das técnicas utilizadas em códigos que sofreram mutação por algum protetor, seja ele comercial (como no caso do VM Protect) ou Open Source (como no caso do Alcatraz). Além dos recursos oferecidos pela mutação de código do VM Protect e Alcatraz, incluí meu próprio código ofuscado, composto por stubs de assembly formando um fluxo de diversas funções que separam o código através de estruturas de decisão. Portanto, além das técnicas já oferecidas pelos protetores comerciais, vamos explorar nossas próprias técnicas empregadas.
Sumário
- Introdução
- Uma pequena mensagem antes de prosseguir
- Preparando nossos binários
- Analisando técnicas e mutação do VM Protect
- Analisando técnicas e mutação do Alcatraz
- Sobre o uso de frameworks de execução symbolica
- Desofuscando o VM Protect
- Desofuscando o Alcatraz
- Conclusão
- Referências
Uma pequena mensagem antes de prosseguir
Após um longo período ocupado, retornei com a sanidade mental aparentemente intacta, ao que tudo indica.
Bom, assim eu espero. Finalmente, consegui um tempo para escrever um artigo (eu, de fato, gosto muito de escrever). Espero que você aprecie a leitura, principalmente os amigos do Discord que sempre estavam à minha procura e esperando por novos posts. Um abraço à galera do Discord. Nos vemos daqui a alguns meses novamente (ou não, quem sabe eu suma e vá viajar o mundo e aplaudir o sol ao som de….Forfun-O Viajante, edit: ouve depois de ler)).
Preparando nossos binários
Vamos preparar dois binários diferentes a partir de um binário principal para nossos testes.
Nesse binário, eu me concentrei apenas em usar MASM em sua criação para facilitar nosso trabalho e aprendizado. Então, não contaremos, por exemplo, com uma função CRT (comumente gerada em linguagens C/C++ ou outras). Portanto, diretamente, nosso “Main” será nosso próprio ponto de entrada neste caso. Mas não cometa o erro, como iniciante em engenharia reversa, de achar que ambos são iguais, porque, de fato, não são. Uma dica que dou é que você compile vários binários em linguagens diferentes e tente entender como funciona o fluxo do ponto de entrada até a função main. Após essa explicação, escrevi um stub muito simples a priori:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
includelib ucrt.lib
includelib legacy_stdio_definitions.lib
extern printf: proc
.data
msg db "Try to deobfuscate-me 4 complete !", 0
.code
printSomething proc
sub rsp, 28h
lea rcx, msg
call printf
add rsp, 28h
ret
printSomething endp
main proc
call printSomething
ret
main endp
end
O código é bastante simples e não requer muito detalhamento; apenas saiba que temos uma main
(que, nesse caso, será nosso ponto de entrada) e uma função que imprime uma string simples no console: “Try to deobfuscate-me 4 complete!”.
Agora, vou complementar a brincadeira utilizando um script Python simples para gerar 10 mil procedimentos que tomam decisões diferentes com base em uma lógica simples. Ao final de todas as condições geradas, ocorre um salto para o endereço de impressão. Confira o código fonte abaixo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def common_code():
return " xor rax, rax\n" \
" xor rbx, rbx\n" \
" mov rax, 1\n"
def keovmtype1(idx):
assembly_code = f"KeoVM{idx} proc\n"
assembly_code += common_code()
assembly_code += " mov rbx, 1\n"
assembly_code += " cmp rax, rbx\n"
assembly_code += f" je KeoVM{idx+1}\n"
assembly_code += " nop\n" * 3
assembly_code += f"KeoVM{idx} endp\n\n"
return assembly_code
def keovmtype2(idx):
assembly_code = f"KeoVM{idx} proc\n"
assembly_code += common_code()
assembly_code += " mov rbx, 2\n"
assembly_code += " cmp rax, rbx\n"
assembly_code += f" jl KeoVM{idx+1}\n"
assembly_code += " nop\n" * 3
assembly_code += f"KeoVM{idx} endp\n\n"
return assembly_code
def keovmtypeend(idx):
return f"KeoVM{idx} proc\n" \
" nop\n" * 7 \
" jmp printSomething\n" \
f"KeoVM{idx} endp\n\n"
MAX_VMS = 10_000
OUT_SCRIPT = ""
for i in range(MAX_VMS):
if i == MAX_VMS-1:
OUT_SCRIPT += keovmtypeend(i)
elif i % 2 == 0:
OUT_SCRIPT += keovmtype1(i)
else:
OUT_SCRIPT += keovmtype2(i)
print(OUT_SCRIPT)
Basicamente, o código vai gerar 10 mil stubs e, ao final de todas elas, ou seja, na 9999ª stub, um salto para a função de print será efetuado. Um algoritmo bem simples de ofuscação de código, porém muito funcional para confundir e tirar a paciência de quem estiver analisando o código assembly. Claramente, isso se tornará muito mais difícil e problemático quando adicionarmos a mutação de código oferecida pelo VM Protect e Alcatraz.
Após adicionarmos as stubs e compilarmos, obteremos o seguinte resultado ao abrir no IDA (e carregar o PDB para facilitar a visualização, apenas desta vez, ok):
Como podemos ver, tudo parece bem, e claro, como mencionado, nosso main (apenas nesse caso específico) terá o mesmo endereço do Entry Point:
Com tudo preparado, vamos então proteger nosso binário usando a mutação e recursos oferecidos pelo VM Protect e, em seguida, a mutação e recursos oferecidos pelo Alcatraz.
Começando pelo VM Protect, vou configurá-lo para aplicar a mutação em todas as funções da minha aplicação. Como temos cerca de 10 mil procedimentos e não estamos, desta vez, seguindo nenhum alinhamento padrão que delimite o início e o fim deles, o VMProtect por si próprio possui a capacidade de determinar todas as funções e aplicar a mutação em ambas:
Podemos confirmar que o VMProtect determinou corretamente expandindo e observando a guia code:
Após desativar outras proteções (neste caso e neste tutorial não tão relevantes), ao final de tudo teremos o binário pronto para trabalharmos (KeowuFuscator.exe.vmp).
Vamos agora fazer o mesmo usando o Alcatraz, vamos proteger nosso binário PE e aplicar a mutação e proteções fornecidas por ele, para isso vamos simplesmente abrir nosso binário de testes usando o “Alcatraz-gui.exe”:
Por padrão, o Alcatraz já aplica todas as opções de mutação disponíveis; basta apenas clicarmos na opção para selecionar todas as funções e simplesmente gerar nosso binário protegido (KeowuFuscator.obf.exe).
Analisando técnicas e mutação do VM Protect
Vamos agora analisar cada técnica aplicada pelo VmProtect. A primeiro momento vamos apenas elencar qual foi a mutação adicionada e vamos tentar identificar algumas lógicas para entender como são feitas para logo a diante começarmos a escrever um desofuscador para cada uma delas.
Vamos olhar para nosso binário que está utilizando nossa obfuscação control flow simples gerando um flow graph de sua execução usando o próprio IDA(via scripting):
Esse é o fluxo completo da execução que geramos usando o script. Calma, espere.
Espere, você só enxerga um traço preto ?! Eu também, hehe.
Vamos aproximar esse flow graph e fará mais sentido no que estamos visualizando aqui:
Agora sim, muito melhor. Conseguimos visualizar claramente cada rotina e a sequência que elas seguem no fluxo original. Basicamente, esses são os procedimentos executados em cada um dos 10 mil procedimentos que incluímos em nosso ofuscador manual de fluxo de controle. Em verde, temos o “EntryPoint”, então temos um fluxo completo de execução a partir dele.
Bem, após nosso pequeno choque com o tamanho, vamos iniciar olhando as técnicas de mutação do VmProtect; de antemão, elas são bem simples de se compreender.
A mutação do VmProtect tem inúmeros truques, desde junk code (que é o mais aplicado por ser, em teoria, o mais estável para ser utilizado pelo VmProtect) até CFF (Control Flow Flattening), mas é bem limitado, por isso utilizamos o nosso, já que o do VmProtect é bem semelhante ao apresentado, e desalinhamentos (em sua maioria aplicados antes dos dispatchers de código virtualizado).
Quando pegamos para analisar as instruções geradas pelo VmProtect em nosso binário protegido, a primeira coisa que conseguimos observar é a quantidade de junk code adicionada nele. O VmProtect usa instruções não comumente utilizadas e, em sua maioria, de manipulação de bits, bem como registradores não utilizados pelo código original. A “heurística de análise” do VmProtect é inteligente o suficiente para não quebrar o fluxo original e ainda adicionar uma nova lógica de confusão.
Vamos pegar uma rotina do nosso programa original e comparar com o resultado gerado pelo VmProtect:
Como podemos ver, nosso assembly original foi de fato deturpado; no entanto, a lógica central continua a mesma. Ainda conseguimos obter o mesmo resultado ao executar. Vamos fazer uma limpeza simples nas instruções do vmprotect e identificar quais podem ser removidas e quais devem ser mantidas sem alterar o fluxo de execução. Você entenderá o porquê disso ser importante mais adiante.
Analisando, cheguei à seguinte conclusão:
Em vermelho, temos mnemônicos ou registradores que não fazem parte do fluxo de controle original do procedimento e podemos removê-los com base nos mnemônicos ou no registrador utilizado.
Em verde, são procedimentos que não fazem diferença estarem ou não presentes, ou que a própria engine de análise do IDA vai ignorar, tornando-se irrelevantes no contexto da análise (graças à eliminação de código morto).
Se aplicarmos as mudanças, conseguimos o seguinte resultado:
xor rax, rax
sub rbx, rbx
mov rax, 1
or bx, bp
mov rbx, 2
cmp rax, rbx
jl next_code_block
A lógica é praticamente a mesma, e conseguimos entender como a mutação se comporta, como aconteceu e como eliminá-la manualmente. Automatizaremos isso utilizando o script mais adiante neste artigo. Adiantei um pouco a análise de mais alguns procedimentos e identifiquei pontos onde eles se repetiam. Cheguei à seguinte conclusão para remover e limpar o fluxo original do código:
A mutação aplicada neste binário utiliza os seguintes mnemônicos (que fazem parte do escopo de mnemônicos comumente utilizados pela mutação do VmProtect):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bt
bts
btc
movzx
sar
cmovs
or
stc
clc
cdqe
cmovp
shr
shl
sbb
rcr
sal
rcl
cmc
and
movsx
bsf
movsxd
cbw
Os seguintes registradores não fazem parte do fluxo original e podem ser removidos:
1
2
3
4
5
6
7
8
9
10
DIL
SIL
SPL
BP
R9
R10
R11
R13
R14
R15
Após identificar essas informações, já temos dados suficientes para escrever um desofuscador automatizado mais tarde.
Podemos observar alguns passos para enfrentar a mutação do VmProtect e agir contra o junk code inserido por ele:
Encontrar os registradores que foram utilizados e não fazem diferença no fluxo de controle.
Encontrar os mnemônicos inúteis utilizados (esses mnemônicos são padronizados), ou seja, o VmProtect sempre vai repetir o uso deles em diversas funções e sempre estão relacionados à manipulação de bits e flags.
Escrever um script para interpretar e remover tudo isso, limpando e deixando o código mais “fácil” de se compreender, e permitindo que a heurística de remoção de “dead code” do IDA dê conta do resto.
Analisar o código conforme desejado.
Por fim, vamos observar como o pseudo-código está após a ofuscação aplicada (faremos uma nova comparação ao final da desmutação/deofuscação completa do binário):
Analisando técnicas e mutação do Alcatraz
Vamos agora analisar cada técnica aplicada pelo Alcatraz nos binários protegidos por ele. Vamos elencar padrões e lógicas para que possamos escrever um script para desofuscá-lo completamente.
Neste caso, não gerarei um gráfico de fluxo de controle. Vamos direto analisar as mudanças que conseguimos observar logo no EntryPoint stub do nosso binário:
Podemos observar que nossa stub está realmente diferente, nada igual à antiga instrução “jmp KeoVM0” que tínhamos para ir ao início da nossa cadeia de controle de fluxo de obfuscação.
Essa rotina é responsável por descriptografar o nosso entrypoint original e só então iniciar a execução do código original com a obfuscação, em resumo, onde nosso “jmp KeoVM0” ocorrerá. Analisei o código e recuperei a lógica para isso:
A rotina obtém a imagem base a partir da PEB e então analisa os cabeçalhos IMAGE_DOS_HEADER e IMAGE_NT_HEADERS e faz uma chamada para IMAGE_FIRST_SECTION utilizando a referência de IMAGE_NT_HEADERS para obter o endereço da primeira seção, para então procurar pela seção “.0Dev”, seção adicionada pelo próprio ofuscador para armazenar as stubs que não puderam ser armazenadas na própria seção “.text”. A ideia é bem criativa. Utilizar o valor do campo “VirtualAddress” para fazer parte do algoritmo de descriptografia do entrypoint original. Isso funciona incrivelmente bem, dado que o ofuscador pode controlar o endereço como desejado, desde que, é claro, ele não seja utilizado por outra seção.
Dessa forma, sabemos exatamente o que acontece e como podemos automatizar o processo para recuperar o entrypoint utilizando nosso script. De tudo falado, a parte mais importante é o seguinte algoritmo que usaremos no desofuscador:
1
2
3
4
5
return ((__int64 (__fastcall *)(_QWORD, signed __int64, signed __int64, PIMAGE_SECTION_HEADER))((char *)pImgDos + (unsigned int)__ROR4__(LODWORD(pImgNt->OptionalHeader.SizeOfStackCommit) ^ *(_DWORD *)((char *)&pImgDos->e_magic + pSectionFindedResultRef->VirtualAddress), pImgNt->FileHeader.TimeDateStamp)))(
arg1,
arg2,
arg3,
pImageSectionHeader);
A lógica final para descriptografar utiliza o valor de SizeOfStackCommit efetuando uma operação XOR com o valor do endereço virtual da seção “.0Dev” e em seguida efetuando um ROR4 com o número do timestamp do header do PE. O valor então é interpretado como um endereço e a execução é redirecionada junto com os argumentos originais (então a nossa rotina original inicia).
Vamos agora analisar as outras técnicas adicionadas pelo Alcatraz, como junkcode e Control Flow Flattening. Após recuperar o entrypoint original de forma manual para conseguir corrigir o IDA, eu gerei um flow graph da execução do binário e obtive o seguinte resultado:
Bom, acredito que comparado ao VmProtect e ao nosso fluxo original de execução, tivemos muitas mudanças.
Porém, acalme-se, nem tudo está perdido, vamos entender o que está acontecendo aqui. O que o Alcatraz fez no nosso binário. Talvez exista uma lógica por trás que possamos utilizar para obter nosso binário original de volta.
Separei um stub simples para olharmos os padrões adicionados. Primeiro vamos observar o junk code, depois o CFF:
Ao observarmos o novo código adicionado pelo Alcatraz, conseguimos facilmente identificar o padrão que ele adicionou e que se repete pelo menos duas vezes por stub:
1
2
3
4
5
6
pushf ; Store the flags
not eax
add eax, SOME_IMM
xor eax, SOME_IMM
rol eax, SOME_IMM
popf ; Restore the flags
Avançando um pouco mais, é possível perceber que o padrão se repete em outros procedimentos, seguindo o mesmo fluxo:
Se procurarmos pelas nossas rotinas originais, podemos encontrá-las logo após a branch, sempre no padrão “true” e “false”, com um salto de +5 bytes do endereço atual, de maneira indireta:
Adiantando a análise, esse mesmo padrão se repete em todos os procedimentos, ou seja, a mesma lógica apenas alterando os valores utilizados nos immediate values dos mnemônicos.
Além desse Control Flow Flattening aplicado pelo Alcatraz seguindo um padrão, outra técnica também é aplicada no binário, sendo a ofuscação de constantes.
O procedimento “alcatrazConstantsWrap” é responsável por “descriptografar” o endereço da constante a ser utilizado pela função printf abaixo. Vamos examinar sua lógica:
Um mnemônico “lea” recupera o endereço da constante e, em seguida, subtrai um valor de rax para obter o endereço correto da constante string para só então utilizar no “printf”.
Vamos visualizar como nosso pseudo-código está atualmente sem desofuscá-lo:
Bom, acho que é isso. Conseguimos identificar todas as técnicas aplicadas pelas duas ferramentas em nosso binário, e agora vamos começar a desofuscar nossos binários.
Sobre o uso de frameworks de execução symbolica
O intuito desse artigo é explicar como podemos desofuscar binários manualmente, especialmente para quem tiver curiosidade de aprender sobre o assunto. Eu não utilizarei, pelo menos nesse artigo, nenhum framework de execução simbólica. Faremos tudo manualmente. Mas isso não significa que eu não trarei novos artigos com ofuscadores piores e lógicas muito piores para encontrar padrões, utilizando a execução simbólica para resolvê-los.
Desofuscando o VM Protect
Vamos iniciar nossas aventuras pelo VmProtect. Eu vou colocar algumas metas aqui que deveremos cumprir até o final deste tópico:
- Desofuscar os nossos próprios Control Flow Flattening aos quais adicionamos.
- Remover toda a mutação adicionada pelo VmProtect (com base na lógica que identificamos).
- Vamos analisar opcodes iguais em cada stub em uma lista, identificando stubs iguais e removendo código inútil e repetido adicionado por nós e pelo VmProtect (reduzindo muito o tamanho dos opcodes na nova seção).
- Adicionaremos uma nova seção no binário para receber o código fonte desofuscado de maneira que teremos um código corrigido, funcional e analisável.
Primeiramente, vamos iniciar obtendo os saltos que sempre ocorrem no nosso fluxo gráfico, do nosso padrão de saltos inseridos pelo nosso gerador de stubs. Eu gerei uma lista constante com cada uma das possibilidades de sequência que nossa análise pode ter:
1
2
#Common branchs used by KeoVM
branchs = [ idaapi.NN_jmp, idaapi.NN_jz, idaapi.NN_jl ]
Quando nosso interpretador encontrar algum desses mnemônicos, vamos automaticamente seguir o endereço associado a eles para acompanhar o fluxo do programa.
Em seguida, vamos criar uma segunda constante para armazenar todas as instruções de junkcode da mutação do vmprotect, além dos registradores que não fazem parte da lógica original do programa:
1
2
3
4
5
#Common menemonics used by the vmprotect mutation
vmprotect_operands = [ idaapi.NN_neg, idaapi.NN_btr, idaapi.NN_cmovbe, idaapi.NN_bt, idaapi.NN_bts, idaapi.NN_btc, idaapi.NN_movzx, idaapi.NN_sar, idaapi.NN_cmovs, idaapi.NN_or, idaapi.NN_stc, idaapi.NN_clc, idaapi.NN_cdqe, idaapi.NN_cmovp, idaapi.NN_shr, idaapi.NN_shl, idaapi.NN_sbb, idaapi.NN_rcr, idaapi.NN_sal, idaapi.NN_rcl, idaapi.NN_cmc, idaapi.NN_and, idaapi.NN_movsx, idaapi.NN_bsf, idaapi.NN_movsxd, idaapi.NN_cbw ]
#Common regs that not make part of the original program logic
vmprotect_mutation_regs = [ 24, 27, 26, 5, 9, 10, 11, 13, 14, 15 ] # 24 = R_spl, 27 = R_dil, 26 = R_sil, 5 = R_bp, 9 = R_r9, 10 = R_r10, 11 = R_r11, 13 = R_r13, 14 = R_r14, 15 = R_r15
Além dessas listas constantes, também iniciarei um bytearray para armazenar nosso novo opcode corrigido, para que possamos copiá-lo para outra seção que criarmos, além de declarar uma lista onde serão armazenados cada stub e seus respectivos opcodes:
1
2
inst_code = bytearray()
inst_code_block = []
Vamos começar a escrever nosso interpretador. Vou obter como ponto de partida inicial o primeiro “jmp keovm1” que marca o início das nossas stubs mutadas. E para cada padrão identificado, vamos trocar o opcode por 0x90 (Mnemônico NOP). Tudo isso repetindo para a quantidade de bytes na visualização do disassembly, considerando cada branch e seguindo os endereços em caso “verdadeiro” para cada uma delas até encontrar o grafo final. E, claro, filtraremos os mnemônicos NOP para não irem à nossa lista de opcode stubs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def vmp_mutation_analyser():
bFound = True
temporary_block = bytearray()
ea = idaapi.get_screen_ea()
while True:
inst = idautils.DecodeInstruction(ea)
if not inst:
break
if bFound:
bFound = False
inst_code_block.append(temporary_block)
temporary_block = []
if inst.itype in branchs:
ea = inst[0].addr
bFound = True
continue
if inst.itype == idaapi.NN_retn:
# saving the last block
temporary_block.extend(idc.get_bytes(ea, inst.size))
inst_code_block.append(temporary_block)
break
if inst.itype in vmprotect_operands:
for i in range(inst.size):
idaapi.patch_byte(ea+i, 0x90)
continue
if inst[0].reg in vmprotect_mutation_regs and inst[1].reg in vmprotect_mutation_regs:
for i in range(inst.size):
idaapi.patch_byte(ea+i, 0x90)
continue
# Nop opcode remove from blocks
if inst.itype == idaapi.NN_nop:
ea += inst.size
continue
temporary_block.extend(idc.get_bytes(ea, inst.size))
print(hex(ea) + " " + idc.GetDisasm(ea))
ea += inst.size
Após a execução, teremos corrigido todo o desmontador. E em nossa lista “inst_code_block”, teremos cada stub com seus respectivos opcodes. Agora vamos filtrar e eliminar código igual. O código abaixo é responsável por eliminar o código repetido de nossa lista (para otimização), bem como preparar “inst_code” para receber os novos opcodes já otimizados:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def search_in_list(index, block_to_find):
for i, block in enumerate(inst_code_block):
if block_to_find == block and len(block_to_find) == len(block) and i != index:
return i
return -1
def replace_invalid_opcode_blocks():
removed_count = 0
before_block_sizes = len(inst_code_block)
for i, block in enumerate(inst_code_block):
while True:
index_to_remove = search_in_list(i, block)
if index_to_remove == -1: break # no are more blocks equal to remove
del inst_code_block[index_to_remove]
removed_count += 1
print(f"Removed: {removed_count} blocks equals from mutation | Original: {before_block_sizes} | New: {len(inst_code_block)}")
# Save our new data lmao
for block in inst_code_block:
inst_code.extend(block)
print(f"Final demutaded opcode size in bytes: {len(inst_code)}")
Após tudo, só nos basta adicionarmos uma nova seção ao nosso banco de análises com os opcodes que temos corrigidos no nosso bytearray global “inst_code”. Eu criei o código abaixo para agilizar o processo, deixando tudo pronto para análise, e basicamente ele obtém a última seção do binário e adiciona uma nova, calculando devidamente seu tamanho e, ao final, fornecendo o endereço do nosso entry do código desofuscado:
1
2
3
4
5
6
7
8
9
10
11
12
13
def populate_segment():
seg = idaapi.get_last_seg()
if seg:
if idaapi.add_segm(0x00, seg.end_ea, seg.end_ea+len(inst_code), ".keowu", "CODE", 0):
seg = idaapi.get_last_seg()
idc.set_segm_attr(seg.start_ea, idc.SEGATTR_PERM, idaapi.SEGPERM_MAXVAL)
idc.set_segm_attr(seg.start_ea, idc.SEGATTR_ALIGN, idaapi.saRelPara)
idaapi.set_segm_addressing(seg, 2) # 2 means 64-bits
ida_bytes.patch_bytes(seg.start_ea, bytes(inst_code))
print(f"New section with code is added on 0x{seg.start_ea:x}")
Antes de executar o nosso script e ver nosso resultado, eu lembro você, leitor, sobre a importância de fazer uma configuração simples no seu IDA para que consiga exibir, neste nosso exemplo de artigo, mais do que 64 nós de gráfo no IDA. Por padrão, o IDA limita o número de grafos, porém podemos facilmente reconfigurar isso no arquivo “hexrays.cfg”. Eu particularmente recomendo 4096 como um bom valor padrão.
Ao executar o script, obtemos um trace de todas as “instruções” já corrigidas e, ao final, o endereço da nova seção adicionada, bem como a dica de configuração.
Perceba que em alguns pontos temos alguns resquícios da mutação do VmProtect e até mesmo de nossas branches antigas. Mas não se preocupe. Deixe o próprio IDA fazer o trabalho difícil e limpar tudo isso usando a tecnologia de “dead-code remove”. Ao olharmos novamente e gerarmos uma decompilação completa do código anteriormente mutado, veremos a grande diferença/mágica de nossa aventura.
Nossa reação:
De fato, nosso esforço gerou resultados e obtivemos um código totalmente desmutado. Se compararmos o antes e o depois, a diferença é gigantesca. Vamos relembrar:
Antes da desmutação:
Depois da desmutação:
Aprendemos muito até agora, mas nossa aventura ainda não terminou. Vamos em frente e fazer o mesmo com o Alcatraz.
Desofuscando o Alcatraz
Vamos agora começar nossos estudos de desofuscação com o Alcatraz. De forma similar ao VmProtect, vou separar em passos o que será feito para remover a ofuscação do Alcatraz. De antemão, ela é bem mais complexa, mas acredito que ficaremos bem ao seguir nossos passos.
- Vamos fazer um script para desofuscar e encontrar o ponto de entrada original substituído pela stub do Alcatraz para que possamos encontrar o código real.
- Primeiro, vamos desofuscar nossos próprios fluxos de controle combinados com os fluxos de controle que também foram adicionados pelo Alcatraz, com base no padrão que descobrimos neles.
- Vamos limpar toda a mutação adicionada pelo Alcatraz analisando seus opcodes e limpando-os de maneira que fique claro e o dead code removido do IDA possa lidar com o restante.
- Adicionaremos uma nova seção no binário para receber o novo código que desofuscamos, de maneira a termos um código corrigido funcional e, o mais importante, analisável.
- Vamos desofuscar strings e constantes e corrigir os deslocamentos com base na análise preliminar do ofuscador.
A princípio, vamos criar nosso script para desofuscar o entrypoint original do programa, replicando o funcionamento da stub de descriptografia do entrypoint do Alcatraz.
Como explicamos acima, toda a lógica central para resolver o endereço correto do entry point se resume apenas a este trecho:
1
2
3
4
5
return ((__int64 (__fastcall *)(_QWORD, signed __int64, signed __int64, PIMAGE_SECTION_HEADER))((char *)pImgDos + (unsigned int)__ROR4__(LODWORD(pImgNt->OptionalHeader.SizeOfStackCommit) ^ *(_DWORD *)((char *)&pImgDos->e_magic + pSectionFindedResultRef->VirtualAddress), pImgNt->FileHeader.TimeDateStamp)))(
arg1,
arg2,
arg3,
pImageSectionHeader);
A única coisa que devemos fazer é encontrar a seção “.0Dev” para recuperar o campo “VirtualAddress” e utilizá-lo no algoritmo para descriptografar o endereço. Sendo assim, reescrevi e obtive o seguinte resultado como primeira parte de nosso desofuscador:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def __ROL__(value, count):
nbits = 8 * value.bit_length()
if count > 0:
count %= nbits
high = value >> (nbits - count)
if value < 0: # Valor assinado
high &= ~((1 << count) - 1)
value = (value << count) & (2 ** nbits - 1) # Para ter certeza do wrapper equivalente
value |= high
else:
count = -count % nbits
low = value << (nbits - count)
value >>= count
value |= low
return value
def __ROR4__(value, count):
return (__ROL__(value & 0xFFFFFFFF, -count) & 0xFFFF000000000000000000000000) >> 96
def decrypt_original_entry_point():
pe = pefile.PE(idaapi.get_input_file_path())
section_virtual_address = 0
for section in pe.sections:
if ".0Dev" in section.Name.decode('utf-8'):
section_virtual_address = section.VirtualAddress
break
entry_point = int.from_bytes(idaapi.get_bytes(idaapi.get_imagebase() + section_virtual_address, 4), 'little')
entry_point ^= pe.OPTIONAL_HEADER.SizeOfStackCommit
entry_point = __ROR4__(entry_point, pe.FILE_HEADER.TimeDateStamp)
entry_point += idaapi.get_imagebase()
print(f"Decrypted EntryPoint: 0x{entry_point:x}")
De maneira geral, replicamos a mesma lógica utilizada pelo Alcatraz para desofuscar o entrypoint. Encontramos a seção, recuperamos o campo do VirtualAddress e então replicamos o algoritmo de descriptografia. Ao final, imprimimos o endereço verdadeiro do entrypoint. Vamos ver isso em funcionamento:
Estamos evoluindo! Vamos em frente na nossa missão de desofuscar completamente, dando continuidade aos nossos passos.
Vamos agora resolver o nosso próprio controle de fluxo, combinado com o Alcatraz. Na nossa etapa de análise do ofuscador, identificamos que havia uma lógica no fluxo sempre verdadeiro, e poderíamos simplesmente simular o fluxo de maneira a reproduzir a execução original do código. É exatamente isso que vamos fazer. Claro, vamos limpar tudo que estiver pelo caminho, extrair os opcodes e corrigi-los para que possamos adicioná-los em uma nova seção posteriormente, para análise adicional.
Vamos iniciar. A priori, adicionei uma lista global para armazenar nossos mnemônicos para as branches usadas pelo Alcatraz, além, é claro, da nossa própria ofuscação de controle de fluxo.
1
branch_menemonics = [ idaapi.NN_jmp, idaapi.NN_jnz, idaapi.NN_jl, idaapi.NN_jz ]
Também criei um bytearray global para armazenar nossos novos opcodes corrigidos:
1
inst_code = bytearray()
Vamos então aplicar a lógica para resolver os control flow até encontrar o último bloco, aplicando patches nos padrões usados pelo Alcatraz para adicionar código inútil a ele. Cheguei ao seguinte resultado, que explicarei em seguida:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def alcatraz_flow_obfuscation_analyser():
ea = idaapi.get_screen_ea()
remove_junk_code_begin = False
addr_begin_junk_code = 0
while True:
inst = idautils.DecodeInstruction(ea)
if not inst: break
if inst.itype in branch_menemonics:
ea = inst[0].addr
continue
if inst.itype == idaapi.NN_pushf:
if not remove_junk_code_begin:
remove_junk_code_begin = True
addr_begin_junk_code = ea
ea += inst.size
continue
if inst.itype == idaapi.NN_popf:
remove_junk_code_begin = False
size = (ea - addr_begin_junk_code) + inst.size
ida_bytes.patch_bytes(addr_begin_junk_code, bytes(bytearray(b"\x90" * size)))
ea += inst.size
continue
if inst.itype == idaapi.NN_retn:
break
ida_bytes.del_items(ea)
inst_code.extend(idc.get_bytes(ea, inst.size))
print(hex(ea) + " " + idc.GetDisasm(ea))
ea += inst.size
O script resolve cada salto individualmente avançando para os próximos blocos enquanto encontra o junk code padrão adicionado pelo Alcatraz e os remove. Além disso, é claro, adicionando o novo opcode com o fluxo original unificado e sem o junk code, de uma maneira que o IDA e sua análise de dead code consigam lidar e gerar um código muito mais fácil e limpo para análise. Após isso, gravamos o conteúdo de nossos novos opcodes presentes em inst_code em uma nova seção do banco de análise:
1
2
3
4
5
6
7
8
9
10
11
def populate_segment():
seg = idaapi.get_last_seg()
if seg:
if idaapi.add_segm(0x00, seg.end_ea, seg.end_ea+len(inst_code), ".keowu", "CODE", 0):
seg = idaapi.get_last_seg()
idc.set_segm_attr(seg.start_ea, idc.SEGATTR_PERM, idaapi.SEGPERM_MAXVAL)
idc.set_segm_attr(seg.start_ea, idc.SEGATTR_ALIGN, idaapi.saRelPara)
idaapi.set_segm_addressing(seg, 2) # 2 mens 64-bits
ida_bytes.patch_bytes(seg.start_ea, bytes(inst_code))
print(f"New section with code is added on 0x{seg.start_ea:x}")
Vamos descriptografar o endereço do entrypoint, posicionar nosso cursor no endereço correto, que é o início de todos os blocos do flow graph, e por fim, executar nosso script para ver o resultado que conseguimos obter com nossa desofuscação:
Como uma rápida explicação: percebemos que em algumas partes o código assembly não veio, código este que não foi analisado pelo IDA por conta da quantidade massiva de control flow block. Porém, nosso script foi capaz de analisar e resolver esse problema. “:: address -> address | bytes: x” representa o junk code identificado e a quantidade de bytes que foram removidos até chegarmos no bloco final. E por fim, obtemos nosso endereço com nosso novo código analisável e corrigido, vamos até ele para vermos nosso novo resultado. |
Perceba que ainda temos um pouco de dead code, mas não se preocupe, eliminamos a grande maioria massiva. Além disso, removemos todos os invalid control flow. O IDA vai cuidar dessa pequena parte de junk code restante com sua tecnologia de análise. Mas enfim, vamos gerar o nosso pseudocódigo para vermos como ficou nosso binário sem Alcatraz:
Nossa reação com esse avanço:
Mas espere, ainda temos uma última técnica para resolver, a técnica de obfuscação de constantes do Alcatraz, a qual também já abordamos anteriormente no tópico que explicamos como funcionava a proteção do Alcatraz.
O Alcatraz adiciona procedimentos wrappers no código para recuperar o endereço de uma constante ou recuperar o seu valor:
Podemos recuperar facilmente a informação do wrapper observando o retorno em rax:
A resolução no script ficou assim:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def unwrap_constants():
ea = idaapi.get_screen_ea()
possible_reference = []
while True:
inst = idautils.DecodeInstruction(ea)
if not inst: break
if inst.itype == idaapi.NN_call:
ea = inst[0].addr
continue
"""
Finding the pattern:
lea rax, cs:SOMEADDRESS
pushf
sub rax, imm
popf
ret
"""
if inst.itype == idaapi.NN_lea:
if inst[0].reg == 0: #R_ax
possible_wrapped_value = inst[1].addr
ea += inst.size
inst = idautils.DecodeInstruction(ea)
if inst.itype == idaapi.NN_pushf:
ea += inst.size
inst = idautils.DecodeInstruction(ea)
if inst.itype == idaapi.NN_sub:
possible_wrapped_value -= inst[1].value
print(f"Possible return value unwraped: 0x{possible_wrapped_value:x}")
possible_reference.append(possible_wrapped_value)
if inst.itype == idaapi.NN_retn:
idc.set_cmt(idaapi.get_screen_ea(), f"RAX = 0x{possible_reference[0]:x}", False)
break
ea += inst.size
print(f"Ended. All list with all possible return address unwraped: {possible_reference}")
Conseguimos recuperar os valores originais fazendo o unwrap da ofuscação adicionada:
Por fim, vamos comparar o antes e o depois de nossa desofuscação do Alcatraz.
Antes da desmutação:
Depois da desmutação:
Por fim, podemos declarar que cumprimos nossa missão com o Alcatraz e conseguimos atingir o objetivo esperado.
Conclusão
Nesse artigo, exploramos diversas técnicas usadas por ofuscadores comerciais e de código aberto, além, é claro, de criarmos o nosso próprio ofuscador de controle de fluxo e entender um pouco sobre o compilador CRT. Além disso, lutamos contra os ofuscadores com o intuito de desofuscá-los e obter um código analisável, aprendendo muito pelo caminho. Espero que este artigo tenha ficado interessante. Nos vemos nos próximos, se eu me animar a continuar escrevendo para a galera do Discord, que são os únicos que lêem meus artigos.
Referências
VM Protect. [S. l.], 20 jan. 2024. Disponível em: https://vmpsoft.com. Acesso em: 20 jan. 2024.
ALCATRAZ. [S. l.], 14 jul. 2023. Disponível em: https://github.com/weak1337/Alcatraz. Acesso em: 20 jan. 2024.
QUICK look around VMP 3.x - Part 2 : Code Mutation. [S. l.], 26 jan. 2021. Disponível em: https://whereisr0da.github.io/blog/posts/2021-01-26-vmp-2/. Acesso em: 20 jan. 2024.