Engenharia reversa
O que é engenharia reversa? Usos, limites e base legal
Entenda o que a compilação descarta, a diferença entre desassembladores e descompiladores e os limites legais antes de analisar um binário.
Compile uma verificação de licença de brinquedo em C de duas formas e use o Ghidra para achar a string, seguir referências e comparar com seu código.
Engenharia reversa é mais fácil de aprender com código que você mesmo escreveu. Você sabe o que o programa deveria fazer, então consegue checar cada afirmação que a ferramenta faz sobre ele. Este artigo segue essa ideia. Você vai escrever um programa pequeno em C com uma verificação de licença de brinquedo, compilá-lo de duas formas e abrir as duas versões no Ghidra. Depois, vai comparar o que o decompilador diz com o código-fonte que você digitou.
A principal lição não está nos botões do Ghidra. Quando você coloca a saída do decompilador ao lado do seu próprio código, aprende rápido o que a ferramenta acerta, o que ela chuta e o que perde quando o compilador otimiza.
Se a ideia de ler código de máquina é nova para você, leia primeiro O que é engenharia reversa?. Você não precisa de experiência prévia com assembly. Precisa de um terminal e de cerca de uma hora.
O Ghidra é um framework de engenharia reversa de software desenvolvido pela Agência de Segurança Nacional dos Estados Unidos (NSA) e publicado no GitHub, no repositório NationalSecurityAgency/ghidra. Ele roda em Windows, macOS e Linux. Para um programa compilado, faz quatro coisas que importam aqui:
O Ghidra não recupera o código-fonte original. Ele produz uma aproximação. O pseudocódigo costuma ter forma parecida com a do seu código, mas nomes, tipos e parte do fluxo de controle podem ser diferentes do que você escreveu. Essa diferença é o que este laboratório foi desenhado para mostrar.
Você poderia baixar um binário de exemplo de um fórum e começar a olhar. Não faça isso. Binários desconhecidos podem ser malware, e você não teria como conferir a saída da ferramenta, porque não sabe a resposta certa.
Um programa que você compila sozinho dá três vantagens:
check() compara o argumento com "LAB-1234".ok ou no. Não faz chamadas de rede nem grava arquivos.Tudo neste laboratório fica na sua máquina. Você está analisando um arquivo seu, que é o único escopo necessário para este exercício.
gcc pelo gerenciador de pacotes. No macOS, o clang das ferramentas de linha de comando do Xcode serve para este laboratório, e as flags são as mesmas. No Windows, use o WSL ou um contêiner com GCC.sha256sum. O Git Bash no Windows já inclui o comando.Instale o JDK que a versão do Ghidra pede, e não confie num guia antigo para esse número. Quando conferi o README do repositório e o guia na data em que escrevi, o README e o guia de primeiros passos pediam o JDK 25 de 64 bits, e o README apontava para as builds Temurin. Leia o README ou as notas da versão que você baixar e use a versão indicada ali. Versão de Java incompatível é um motivo comum para o Ghidra não abrir.
Baixe o Ghidra apenas nas releases oficiais, em github.com/NationalSecurityAgency/ghidra. O repositório aponta para os arquivos de cada release. Descompacte em uma pasta nova. O guia de primeiros passos diz para não extrair o Ghidra por cima de uma instalação existente. Inicie com ghidraRun no Linux e no macOS, ou ghidraRun.bat no Windows.
Antes de descompactar qualquer coisa, compare o arquivo com o valor SHA-256 publicado. Execute isto na pasta onde você salvou a release:
sha256sum ghidra_*.zipCompare o hash impresso com o valor SHA-256 que o GitHub mostra ao lado do arquivo na página da release, ou que o projeto publica. Ele precisa ser idêntico. Se você não achar um SHA-256 publicado para aquele arquivo, anote isso nas suas notas do laboratório. Não trate como checksum do publicador um hash copiado de um espelho qualquer.
Conferir o hash só prova que o arquivo é igual ao que o publicador listou. Não prova que o publicador é confiável. Use a fonte oficial e guarde nome do arquivo e hash nas suas notas.
Crie uma pasta vazia para o laboratório. Mantenha o código-fonte e os binários juntos, e guarde os projetos do Ghidra fora dela, para não bagunçar a pasta.
mkdir -p ghidra-lab && cd ghidra-lab
cat > toy.c <<'EOF'
#include <stdio.h>
#include <string.h>
int check(const char *k){ return strcmp(k, "LAB-1234") == 0; }
int main(int c, char **v){ if(c>1 && check(v[1])) puts("ok"); else puts("no"); return 0; }
EOFO programa lê um argumento da linha de comando. Se o argumento for igual a LAB-1234, imprime ok. Caso contrário, imprime no. Essa verificação não tem valor de segurança. É uma string fixa dentro de um arquivo que qualquer pessoa pode ler, e esse é o ponto. Você vai encontrar a string no binário em poucos minutos.
Registre o hash do seu código-fonte para conseguir provar depois qual arquivo analisou:
sha256sum toy.cNa máquina que usei, o comando imprimiu:
d9746d8fb118fa954a46b4d501ddf9b0ed86e986e7c99adb05e4a1a60ca0071d toy.cO seu hash vai ser diferente se o arquivo tiver outros espaços ou outras quebras de linha.
Compile o mesmo código-fonte em dois binários com configurações diferentes:
gcc -O0 -g -o toy_debug toy.c
gcc -O2 -s -o toy_stripped toy.cAs flags fazem coisas diferentes. Ambas vêm do manual do GCC, que vale ler uma vez:
-O0 desliga a maior parte das otimizações. É o padrão, e o manual descreve essa configuração como a que mantém a depuração previsível. O código segue de perto o fonte.-O2 ativa a maior parte das otimizações que não trocam tamanho por velocidade. O manual diz que essa opção também faz inlining de funções pequenas, mesmo as que não foram declaradas como inline. O código pode parecer bem diferente do fonte.-g adiciona informações de depuração no formato nativo do sistema operacional. O Ghidra lê parte delas, e depuradores usam tudo.-s remove a tabela de símbolos do arquivo final, no momento da linkagem. Os nomes check e main deixam de ficar guardados no arquivo.Dois binários com o mesmo código e flags diferentes é o que torna o laboratório útil. Guarde os dois.
Execute os binários antes de abrir o Ghidra. Um programa que você não rodou é um programa que você ainda não entende.
./toy_debug LAB-1234
./toy_debug wrong
./toy_debug
./toy_stripped LAB-1234
./toy_stripped wrongEu executei dentro de um contêiner Debian com GCC 14.2.0, em um host x86-64. A saída foi:
ok
no
no
ok
noAs duas versões se comportam igual. Remover símbolos e otimizar não mudou a lógica, que é o esperado. Se a sua saída for diferente, pare e descubra o motivo antes de seguir.
Antes do Ghidra, veja o que está guardado em cada arquivo. Duas ferramentas bastam: nm lista símbolos, e strings lista textos legíveis.
nm toy_debug | grep -E " (check|main)$"
nm toy_stripped
strings toy_stripped | grep LAB
ls -l toy_debug toy_strippedNa máquina que usei, a saída foi:
0000000000001149 T check
0000000000001175 T main
nm: toy_stripped: no symbols
LAB-1234
-rwxr-xr-x 1 root root 17384 Nov 8 09:17 toy_debug
-rwxr-xr-x 1 root root 14480 Nov 8 09:17 toy_strippedLeia essas linhas com calma:
check e main. Esses nomes vieram do seu código-fonte.nm não encontra nenhum símbolo.LAB-1234 sobrevive nos dois arquivos. Uma string é dado, e remover símbolos não tira dados que o programa precisa em tempo de execução. Ela fica no arquivo como constante, e é isso que torna o laboratório fácil de começar.Agora desmonte check no binário de depuração:
objdump -d -M intel toy_debug | grep -A14 "<check>:"A saída ficou assim:
0000000000001149 <check>:
1149: 55 push rbp
114a: 48 89 e5 mov rbp,rsp
114d: 48 83 ec 10 sub rsp,0x10
1151: 48 89 7d f8 mov QWORD PTR [rbp-0x8],rdi
1155: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
1159: 48 8d 15 a4 0e 00 00 lea rdx,[rip+0xea4] # 2004 <_IO_stdin_used+0x4>
1160: 48 89 d6 mov rsi,rdx
1163: 48 89 c7 mov rdi,rax
1166: e8 d5 fe ff ff call 1040 <strcmp@plt>
116b: 85 c0 test eax,eax
116d: 0f 94 c0 sete al
1170: 0f b6 c0 movzx eax,al
1173: c9 leave
1174: c3 retVocê reconhece o seu código nessas instruções. Uma chamada a strcmp, um teste sobre o valor de retorno e um sete que transforma "igual a zero" em 0 ou 1. Guarde isso na cabeça quando for ler o decompilador.
Agora olhe o build otimizado. Desmontei o toy_stripped do mesmo jeito. O compilador colocou a comparação direto em main, que fica em 0x1060 no meu build: um cmp edi,0x1 para o teste c>1, depois mov rdi,[rsi+0x8] para carregar v[1], depois call strcmp@plt. Ele ainda manteve uma cópia separada de check em 0x1190, porque a função não é static e outro arquivo poderia chamá-la. Os endereços mudam conforme o seu toolchain, então use isso como um mapa do que procurar, e não como números para igualar.
Os nomes exatos dos menus mudam conforme a versão do Ghidra, e eu não rodei a interface gráfica na máquina onde este artigo foi conferido. Os passos abaixo seguem a documentação oficial e o que a interface costuma mostrar. Confira cada nome com a sua versão. Tire uma captura da sua própria tela ao final de cada passo, e não reaproveite capturas de outro tutorial.
ghidraRun (ou ghidraRun.bat).toy-lab. Salve em uma pasta sob seu controle.toy_debug. Aceite o formato detectado, se bater com o seu sistema. Um arquivo gerado em Linux x86-64 deve aparecer como ELF com processador x86.toy_stripped. Agora o projeto tem duas entradas.Espere a análise terminar. O Ghidra mostra o progresso na barra inferior. Não comece a renomear nada antes disso.
Comece pelo toy_stripped, que é o caso mais difícil. Depois compare com o toy_debug.
LAB-1234 na lista. Dê duplo clique para ir ao endereço dela na listagem.No build de depuração, o Ghidra também deve mostrar o nome check da tabela de símbolos. No build sem símbolos, ele vai mostrar um nome gerado, como FUN_00101190. O número depende do build, então use o nome que aparecer na sua tela.
Faça o mesmo com as strings "ok" e "no". Cada uma é usada em main. Seguir essas referências a partir de main mostra o mesmo quadro pelo outro lado.
Abra o painel do decompilador na função que compara a string. Deixe o toy.c aberto em outra janela, ao lado.
O resultado que você deve procurar é deste tipo, descrito como ilustração de como ler, e não como saída de uma versão específica do Ghidra:
strcmp, ou um símbolo que a ferramenta rotula como strcmp.Agora liste as diferenças que você vê. As mais comuns são:
char * como char *, ou usar um tipo mais vago, como undefined8. Ele pode não saber que a entrada é const.k aparece como param_1 ou um marcador parecido.strcmp(...) == 0 pode aparecer como uma comparação seguida de um retorno condicional. O sentido é o mesmo, mas o formato não.main. A sua checagem c > 1 vira uma comparação sobre a contagem de argumentos. Preste atenção no deslocamento de um: argv[0] é o nome do programa, e v[1] é o primeiro argumento de verdade.O objetivo não é dar nota ao decompilador. O objetivo é notar onde a história dele e a sua batem, e onde divergem. Quando divergirem, volte à desmontagem e ao código-fonte e decida qual está certo.
Agora faça a parte que torna um binário sem símbolos legível.
L. Dê o nome input.check_input.main, renomeie o vetor de argumentos para argv e a contagem para argc.O Ghidra guarda esses nomes no seu projeto. O binário no disco não muda. Isso importa: você está documentando a sua leitura, e não editando o arquivo que compilou.
Repare em quanto o código fica mais fácil depois de renomear. Na primeira vez, os nomes das variáveis eram chutes. Agora batem com o seu código-fonte. É esse processo que engenheiros reversos usam no dia a dia, e é também por isso que nomes em um programa decompilado devem ser tratados como hipóteses até você conferir.
Coloque a decompilação de depuração e a sem símbolos lado a lado. Responda estas perguntas nas suas notas:
-O2 fez inlining de check dentro de main, ou manteve uma função separada? Procure, na listagem, um ponto de chamada separado para strcmp e um ret separado.Escreva uma frase para cada resposta. Se não conseguir responder alguma, isso também é dado útil. Volte à listagem e encontre a instrução que deixa aquilo confuso.
Pular a etapa de execução. Se você abre o binário no Ghidra antes de rodá-lo, não sabe qual é o comportamento correto. Rode primeiro e registre as saídas.
Confiar nos nomes do decompilador. Um nome como FUN_00101190 ou local_18 é um marcador. Renomear é uma afirmação sua, e precisa de evidência.
Trocar os builds. Os binários de depuração e sem símbolos têm endereços e nomes diferentes. Anote o nome de cada arquivo e confira em qual entrada do projeto você está antes de renomear qualquer coisa.
Usar um exemplo de fonte não confiável. O seu próprio arquivo compilado é o ponto de partida seguro. Quando estiver confortável com ele, o próximo passo ainda é código seu ou um exemplo de fonte que você verificou antes. O artigo sobre hashes de arquivo e ética do VirusTotal explica por que um hash sozinho não resolve essa questão.
Tratar o laboratório como teste de segurança. A checagem de string aqui é trivialmente quebrável. Encontrá-la não prova nada sobre software real. Código real de licenciamento e proteção é mais difícil, e analisá-lo precisa ficar dentro do escopo que você tem permissão para trabalhar.
Esquecer a versão do JDK. Se o Ghidra não abrir, confira a versão do Java com as notas da release antes de mexer em qualquer outra coisa.
Depois que o brinquedo funcionar, deixe o exercício mais difícil de forma controlada:
"LAB-1234" por uma string maior, recompile e encontre-a de novo nos dois builds.-O0 -s e compare com -O2 -s. A diferença mostra quanto o otimizador muda a forma do código sem remover a lógica.Se quiser entender melhor as flags do compilador, as seções do manual do GCC sobre opções de otimização e de depuração são as fontes primárias. Leia antes de confiar em uma flag num relatório.
Guarde suas notas. Um bom caderno de laboratório tem o hash do código-fonte, os comandos de compilação, as saídas da execução, a versão do Ghidra, a versão do JDK e os nomes que você atribuiu, cada um com a evidência que o sustenta. Esse registro é o que transforma este exercício em uma habilidade que você reaproveita no próximo programa.
Engenharia reversa
Entenda o que a compilação descarta, a diferença entre desassembladores e descompiladores e os limites legais antes de analisar um binário.