Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Este livro detalha os assuntos necessários para dar os primeiros passos na engenharia reversa de software em ambientes Windows, com foco em programas de 32-bits compilados para a arquitetura Intel. É pensado para ser estudado interativamente: você precisa seguir os exemplos e comandos apresentados no livro logo que eles aparecem para um melhor aproveitamento. Ao terminar de ler o livro, você será capaz de fazer engenharia reversa em programas simples, mas com a confiança de quem aprendeu de verdade o básico sobre o assunto.
Este livro é destinado a iniciantes em engenharia reversa, mas é esperado que você conheça computação básica, saiba baixar e instalar programas no Windows e no Linux, e tenha conhecimentos básicos de programação.
Se encontrou algum erro, quer fazer um comentário, sugestão ou pergunta, por favor utilize o Fórum em https://menteb.in/forum ou nosso servidor do Discord em https://menteb.in/discord.
Fernando Mercês atua há mais de 12 anos como pesquisador sênior de ameaças na Trend Micro, uma das maiores empresas dedicadas a software de segurança do mundo. Seu trabalho envolve o uso da engenharia reversa para analisar malwares em diversos ambientes e arquiteturas. Já ministrou diversos treinamentos de segurança, hardware, programação, engenharia reversa, análise de malware e tópicos correlatos. É autor de várias ferramentas livres e é fundador e diretor executivo da Mente Binária.
A Mente Binária é uma instituição de ensino e pesquisa sem fins lucrativos que desenvolve programas de capacitação e cursos em computação com foco em programação de baixo nível e segurança. A instituição é uma ONG e sobrevive com doações e venda de cursos e materiais como este, mas também disponibiliza muito de seu conteúdo de forma online e irrestrita.
Este livro está em constante desenvolvimento e a versão online dele está disponível em https://menteb.in/livro para você e outros milhões de falantes da língua portuguesa sem nenhum custo. No entanto, para continuar, precisamos de doações. Se este conteúdo te ajudar, por favor considere nos apoiar seguindo as instruções em https://menteb.in/apoie e aproveite as vantagens e descontos para quem nos apoia. Seu apoio também vai ajudar a manter este livro atualizado e torná-lo cada vez mais melhor.
Durante a jornada de concepção deste livro, várias pessoas foram envolvidas. Gostaria de agradecer à Gabriela Vicari pelo grande esforço que ela fez para que tivéssemos uma versão impressa deste livro, ao Leandro Fróes por seu valioso retorno sobre os tópicos abordados, ao Carlos Cabral pela revisão inicial e aos usuários do GitHub que contribuíram com correções: morkin1792, tiagorlampert, eremit4, felipensp, xfgusta, pr3y, st3phano, gleysonnnnn, hudsantos e becauro.
Todo escritor quer que sua mensagem seja lida e compreendida, isso não é diferente no meu caso. Então, estabeleci umas regras em meu processo de escrita para facilitar o seu processo de compreensão da disciplina de Engenharia Reversa.
Utilizo do itálico para neologismos, palavras em Inglês ou em outro idioma, como crypter (encriptador). Em geral, prefiro não utilizar termos "aportuguesados" como baite (para byte) ou linkeditor (para linker). Acho que isso confunde o leitor e por isso mantenho os originais em inglês, mas em itálico.
Já o negrito, utilizo para dar destaque ou para me referir a um nome de ferramenta, por exemplo: o comando ipconfig.
Algumas vezes utilizo o termo GNU/Linux ao invés de somente Linux. O projeto GNU é o principal projeto da FSF (Free Software Foundation), a fundação que criou o conceito de software livre. Suas ferramentas são parte essencial de qualquer sistema operacional baseado no kernel Linux e por isso faz bem lembrá-la.
Após a introdução, engenharia reversa passa a ser utilizado como forma curta de engenharia reversa de software.
Nas operações bit-a-bit (bitwise), utilizo os símbolos da programação para representar as operações E, OU, OU EXCLUSIVO, etc. Muitas vezes no texto adoto seus mnemônicos em inglês como em AND, OR e XOR.
Cada frase deste livro, a não ser que expressado diferente, considera a arquitetura Intel x86 (IA-32), visto que esta é documentada o suficiente para começar o estudo de engenharia reversa e moderna o suficiente para criar exemplos funcionais de código e analisar programas atuais.
Este é um guia prático. Sendo assim, é recomendável que você seja capaz de reproduzir o que é sugerido neste livro em seu próprio ambiente. Você vai precisar do seguinte:
Uma máquina (virtual ou real) com Windows 7, 10 ou 11.
Nessa máquina Windows, você deve baixar e instalar os seguintes programas:
Detect It Easy (DIE) - https://horsicq.github.io
HxD - https://mh-nexus.de
Python 3 - https://www.python.org
Visual Studio Community - https://aka.ms/vs
x64dbg - https://x64dbg.com
Este livro é recheado de trechos de código. É recomendável que você pratique escrevendo-os no ambiente específico cada vez que encontrar blocos como os mostrados abaixo.
Exemplos de código em Python como a seguir devem ser digitados no ambiente do Python.
Você também encontrará códigos em linguagem C como este:
Este deve ser compilado em ambiente Windows utilizando o Visual Studio.
Tenha em mente que é necessário para o aprendizado que você escreva estes códigos, os execute e analise seus resultados, certo? É para o seu próprio bem. :)
Enquanto instala os programas necessários, vamos seguir para a introdução ao assunto!
Em termos amplos, engenharia reversa é o processo de entender como algo funciona através da análise de sua estrutura e de seu comportamento. É uma arte que permite conhecer como um sistema foi pensado ou desenhado sem ter contato com o projeto original.
Não restrinjamos, portanto, a engenharia reversa à tecnologia. Qualquer um que se disponha a analisar algo de forma minuciosa com o objetivo de entender seu funcionamento a partir de seus efeitos ou estrutura está fazendo engenharia reversa. Podemos dizer, por exemplo, que Carl G. Jung (conhecido como o pai da psicologia analítica) foi um grande engenheiro reverso ao introduzir conceitos como o de inconsciente coletivo através da análise do comportamento humano.
No campo militar e em época de guerras é muito comum assegurar que inimigos não tenham acesso às armas avançadas: aviões, tanques e outros dispositivos, pois é importante que adversários não desmontem esses equipamentos, não entendam seu funcionamento e, consequentemente, que não criem versões superiores deles ou encontrem falhas que permitam inutilizá-los com mais facilidade. Na prática, é evitar a engenharia reversa.
Vale a pena assistir ao filme Jogo da Imitação (Imitation Game) que conta a história do criptoanalista inglês Alan Turing, conhecido como o pai da ciência de computação teórica, que quebrou a criptografia da máquina nazista Enigma utilizando engenharia reversa.
Este livro foca na engenharia reversa de software, ou seja, no processo de entender como uma ou mais partes de um programa funcionam, sem ter acesso a seu código-fonte. Focaremos inicialmente em programas para a plataforma x86 (de 32-bits), rodando sobre o sistema operacional Windows, da Microsoft, mas vários dos conhecimentos expressos aqui podem ser úteis para engenharia reversa de software em outros sistemas operacionais, como o GNU/Linux e até mesmo em outras plataformas, como ARM.
Assim como o hardware, o software também pode ser desmontado. De fato, existe uma categoria especial de softwares com esta função chamados de disassemblers, ou desmontadores. Para explicar como isso é possível, primeiro é preciso entender como um programa de computador é criado atualmente. Farei um resumo aqui, mas entenderemos mais a fundo em breve.
A parte do computador que de fato executa os programas é o chamado processador. Nos computadores de mesa (desktops) e laptops atuais, normalmente é possível encontrar processadores fabricados pela Intel ou AMD. Para ser compreendido por um processador, um programa precisa falar sua língua: a linguagem (ou código) de máquina.
Os humanos, em teoria, não falam em linguagem de máquina. Bem, alguns falam, mas isso é outra história. Acontece que para facilitar a criação de programas, algumas boas almas começaram a escrever programas onde humanos escreviam código (instruções para o processador) numa linguagem mais próxima da falada por eles (Inglês no caso). Assim nasceram os primeiros compiladores, que podemos entender como programas que "traduzem" códigos em linguagens como Assembly ou C para código de máquina.
Um programa é então uma série de instruções em código de máquina. Quem consegue olhar pra ele desta forma, consegue entender sua lógica, mesmo sem ter acesso ao código-fonte que o gerou. Isso vale para praticamente qualquer tipo de programa, seja ele criado em linguagens onde a compilação ocorre separada da execução, como C, C++, Pascal, Delphi, Visual Basic, D, Go e até mesmo em linguagens onde a compilação ocorre junto à execução, como Python, Ruby, Perl ou PHP. Lembre-se que o processador só entende código de máquina e para ele não importa qual é o código fonte, ou se a linguagem é compilada(análise e execução separadas) ou interpretada(análise e execução juntas). Então é importante notar que, para o processador poder executar, "tudo tem que acabar em linguagem de máquina". Qualquer um que a conheça será capaz de inferir qual lógica o programa possui. Aliado ao conhecimento do ambiente de execução, é possível inclusive descrever exatamente o que um programa faz e nisto está a arte da engenharia reversa de software que você vai aprender neste livro.
Naturalmente, os criadores de programas maliciosos não costumam compartilhar seus códigos-fonte com as empresas de segurança de informação. Sendo assim, analistas que trabalham nessas empresas ou mesmo pesquisadores independentes podem lançar mão da engenharia reversa afim de entender como essas ameaças digitais funcionam e então poder criar suas defesas.
Alguns bugs encontrados em software podem ser exploráveis por outros programas. Por exemplo, uma falha no componente SMB do Windows permitiu que a NSA desenvolvesse um programa que dava acesso a qualquer computador com o componente exposto na Internet. Para encontrar tal vulnerabilidade, especialistas precisam conhecer sobre engenharia reversa, dentre outras áreas.
Às vezes um software tem um problema e por algum motivo você ou sua empresa não possui mais o código-fonte para repará-lo ou o contrato com o fornecedor que desenvolveu a aplicação foi encerrado. Com engenharia reversa, pode ser possível corrigir tal problema.
Mesmo sem ter o código-fonte, é possível também alterar a maneira como um programa se comporta. Por exemplo, um programa que salva suas configurações num diretório específico pode ser instruído a salvá-las num compartilhamento de rede.
Adicionar um recurso é, em geral, trabalhoso, mas possível.
Software proprietário costuma vir protegido contra pirataria. Você já deve ter visto programas que pedem número de série, chave de registro, etc. Com engenharia reversa, os chamados crackers são capazes de quebrar essas proteções. Por outro lado, saber como isso é feito é útil para programadores protegerem melhor seus programas.
Um bom exemplo de uso da engenharia reversa é o caso da equipe que desenvolve o LibreOffice: mesmo sem ter acesso ao código fonte, eles precisam entender como o Microsoft Office funciona, a fim de que os documentos criados nos dois produtos sejam compatíveis. Outros bons exemplos incluem:
o Wine, capaz de rodar programas feitos para Windows no GNU/Linux;
o Samba que permite que o GNU/Linux apareça e interaja em redes Windows;
o Pidgin que conecta numa série de protocolos de mensagem instantânea;
e até um sistema operacional inteiro chamado ReactOS, que lhe permite executar seus aplicativos e drivers favoritos do Windows em um ambiente de código aberto e gratuito.
Todos estes são exemplos de implementações em software livre, que tiveram de ser criadas a partir da engenharia reversa feita em programas e/ou protocolos de rede proprietários.
Se o computador só entende números, como podemos trabalhar com texto então? Bem, não se engane, o computador realmente só entende números. O fato de você apertar uma tecla no teclado que tem o desenho de um símbolo do alfabeto utilizado no seu país não garante que é isto que de fato seja enviado para o computador e, certamente, não é. Ao invés disso, cada tecla possui um código conhecido como scan code ou make code que é enviado, entre outras informações, pelo fio teclado para a placa-mãe do computador e passa por vários estágios até chegar ao kernel, o núcleo do sistema operacional. Se sua intenção for escrever o texto "a" num editor de textos (no Bloco de Notas por exemplo), então uma tabela de conversão entra em jogo. Essa tabela vai armazenar no arquivo de texto que você está criando um ou mais números que são equivalentes ao caractere "a". Ou seja, o texto "a", na prática, não existe.
Assim como na entrada de dados pelo teclado, o tratamento da entrada do mouse ou de qualquer outro dispositivo de entrada também é numérico e, de maneira geral, o computador necessita entender que ao ler um determinado número, precisa tomar alguma ação, como desenhar o que conhecemos por caractere "a" num editor de textos. Para ele é um número, para nós, um símbolo, que é parte de um texto. Veremos nas seções a seguir como essa conversão se dá.
Tudo é número (Pitágoras)
Costumo dizer para meus alunos que um computador é basicamente uma calculadora gigante. Claro que esta é uma afirmação muito simplista, mas a verdade é que a ideia pitagórica de que "tudo é número" cabe muito bem aqui. Não é à toa que em textos sobre a origem da computação você encontra a foto de um ábaco, a primeira máquina de calcular, datando-se aproximadamente de mais de 2000 anos antes de Cristo e que é feita de pedras. De fato, calculus em Latim significa pedrinha (agora você entende a expressão "cálculo renal"!), porque era a maneira que o povo tinha para contar na antiguidade.
Um fato interessante é que a patente número US4812124 do Google descreve um ábaco hexadecimal e é datada de 1988.
Neste capítulo vamos focar nos números. Em breve veremos como o processador trabalha com eles também.
Pois então, o que é um número? De acordo com definição na Wikipédia, um número é um objeto matemático utilizado para contar, medir ou descrever uma quantidade. Na prática também utilizamos números para outros fins, como um número de telefone ou número de série de um equipamento.
O processador de um computador moderno consegue realizar muitos cálculos num intervalo de tempo muito curto. Mas, considerando o computador como dispositivo eletrônico que ele é, já parou para pensar como é que um número "entra" no processador? Para entender isso com precisão, seria necessário falar de eletricidade, física, química e talvez quântica, mas vou resumir: os elétrons que caminham pelos circuitos de um computador e chegam até o processador são interpretados de modo que uma baixa tensão elétrica é interpretada como o número 0 e uma mais alta, como 1. É através de um componente eltrônico chamado transístor que se consegue representar 0 e 1 dentro do processador. Você pode aprender mais sobre isso no apêndice Referências deste livro. Parece pouco, mas nas próximas seções você verá como que, a partir de somente dois números é possível obter-se todos os outros.
Nesta seção faremos alguns cálculos com números binários, considerando cada um de seus dígitos, também chamados de bits. Além de operações aritiméticas clássicas como adição, subtração, multiplicação e divisão, estudaremos também conjunção, disjunção, negação e disjunção exclusiva. Também incluiremos outras operações bit-a-bit que fogem da álgebra tradicional, como deslocamento e rotação de bits. Todas são importantes pois existem no contexto do Assembly, que estudaremos no futuro.
Você pode encontrar mais sobre este assunto pesquisando por álgebra booleana e operações bit-a-bit (bitwise).
Dados dois bits x e y, a conjunção entre eles resulta em 1 se ambos forem iguais a 1. Na programação, o seu símbolo na programação é normalmente o "e comercial" (&). Sendo assim, a chamada tabela verdade desta operação é:
0
0
0
0
1
0
1
0
0
1
1
1
Então suponha que queiramos calcular a conjunção do número 0xa com 12. Sim, estamos misturando dois sistemas de numeração (hexadecimal e decimal) na mesma operação. E por que não? O segredo é converter para binário e fazer o cálculo para cada bit, respeitando a tabela verdade da conjunção. Mãos à obra:
O resultado é 0b1000, ou 8 em decimal. Sendo assim, as linhas abaixo farão o mesmo cálculo, ainda que utilizem sistemas de numeração (bases) diferentes:
Por que utilizei tantas bases diferentes? Quero com isso por na sua cabeça que um número é só um número, independente da base na qual ele está sendo representado.
O resultado da disjunção entre dois bits x e y é 1 se pelo menos um deles for 1. Sendo assim, segue a tabela verdade:
0
0
0
0
1
1
1
0
1
1
1
1
Na programação, o símbolo normalmente é a barra em pé (|). Por exemplo, vamos calcular a disjunção entre 8 e 5:
O resultado é 0b1101, que é 13 em decimal.
Aí você pode questionar:
Opa, então a disjunção é tipo a soma?
Te respondo:
Não.
Veja que o resultado da disjunção entre 9 e 5, também é 13:
Isso porque numa soma entre 1 e 1 o resultado seria 10 (2 em decimal), já na operação OU o resultado é 1.
A disjunção exclusiva entre x e y resulta em 1 se somente um deles for 1. Sendo assim:
0
0
0
0
1
1
1
0
1
1
1
0
Assim como a disjunção é normalmente chamada de "OU", a disjunção exclusiva é chamada de "OU exclusivo", ou simplesmente XOR. O símbolo que representa a disjunção exclusiva em programação é o circunflexo (^).
Algumas propriedades importantes desta operação são:
Você pode aplicá-la em qualquer ordem. Então, x ^ (y ^ z) = (x ^ y) ^ z por exemplo.
O XOR de um número com ele mesmo é sempre zero.
O XOR de um número com zero é sempre ele mesmo.
A operação XOR tem vários usos em computação. Alguns exemplos:
É possível saber se um número é diferente de outro com XOR. Se os números forem diferentes, o resultado é diferente de zero. Por exemplo, tomemos um XOR entre 8 e 5 e outro entre 5 e 5:
Fica claro que é possível zerar variáveis bastando fazer uma operação XOR do valor dela com ele mesmo, independentemente de que valor é este:
O algoritmo conhecido por XOR swap consiste em trocar os valores de duas variáveis somente com operações XOR, sem usar uma terceira variável temporária. Basta fazer, nesta ordem:
XOR entre x e y e armazenar o resultado em x.
XOR entre x e y e armazenar o resultado em y.
XOR entre x e y e armazenar o resultado em x.
Veja:
Analisando em binário:
Dado um número x, é possível calcular o resultado de uma operação XOR com um valor qualquer que chamaremos de chave. Se usarmos a mesma chave num XOR com este resultado, obtemos o número original:
Portanto, para uma cifrabem básica, se você quiser esconder o valor original de um número antes de enviá-lo numa mensagem, basta fazer um XOR dele com uma chave que só você e o receptor da mensagem conheça (0x42 no exemplo). Assim você usa a chave para fazer a operação XOR com ele e instrui o receptor da mensagem (por outro canal) a usar a mesma chave numa operação XOR afim de obter o número original. Claro que esta cifragem é muito simples, e consequentemente muito fraca e fácil de quebrar, mas está aqui em caráter de exemplo.
Em textos matemáticos sobre lógica, o circunflexo ^ representa conjunção ao invés de disjunção exclusiva. Já em softwares matemáticos, pode significar potência, por exemplo: 2^32 é dois elevado à trigésima segunda potência.
Na língua Portuguesa utilizamos a palavra "OU" no sentido de "OU exclusivo". Por exemplo, quando você pede "Pizza de presunto ou pepperoni ou lombo", quer dizer que só quer um dos sabores (exclusividade). Se fosse uma disjunção tradicional "OU", o garçom poderia trazer presunto com pepperoni, ou mesmo todos os três ingredientes e você não poderia reclamar. :)
O deslocamento para a esquerda (shift left) consiste em deslocar todos os bits de um número para a esquerda e completar a posição criada mais à direita com zero. Tomemos por exemplo o número 7 e uma operação SHL com 1 (deslocar uma vez para a esquerda):
Assim podemos perceber que deslocar à esquerda dá no mesmo que multiplicar por 2. Veja:
No exemplo acima deslocamos 1 bit do número 7 (0b111) para a esquerda três vezes, que resultou em 56. Seria o mesmo que deslocar 3 bits de uma só vez:
De forma análoga, o deslocamento para a direita (shift right), ou simplesmente SHR, consiste em deslocar todos os _bits_de um número para a direita e completar a posição criada à esquerda com zero. Tomando o mesmo 7 (0b111):
O resultado é uma divisão inteira (sem considerar o resto) por 2. Assim, 7/2 = 3 (e sobra 1, que é desconsiderado neste cálculo). Esta é de fato uma maneira rápida de dobrar ou calcular a metade de um número.
Assim como no deslocamento, a rotação envolve deslocar os bits de um número para a esquerda (rotate left) ou direita (rotate right) mas o bit mais significativo (mais à esquerda) é posto no final (mais à direita), no lugar de zero. Por isso é necessário considerar o tamanho. Tomemos o número 5 como exemplo:
O bit zero, que está mais à esquerda, "deu a volta" e veio parar ao lado direito do bit 1, mais à direita do número 0b00000101.
Analise com o número 133 agora:
Desta vez o bit 1, que estava mais à esquerda, veio parar ao lado direito do bit mais à direita, e todos os outros bits foram "empurrados" para a esquerda.
Não estamos limitados a fazer operações ROL e ROR somente com 1. O byte 133 ROL 3 por exemplo resulta em 0x2c. Você é capaz de conferir se acertei?
Para negar um bit, basta invertê-lo:
0
1
1
0
No entanto, para inverter o número maior, como por exemplo 0b100, é preciso saber seu tamanho. Analise os exemplos abaixo para tamanhos variados:
1 byte
0b00000100
0b11111011
2 bytes (WORD)
0b0000000000000100
0b1111111111111011
4 bytes (DWORD)
0b00000000000000000000000000000100
0b11111111111111111111111111111011
Fazer essa inversão é o mesmo que calcular o complemento (também chamado de "complemento de um") de um número. Para obter seu simétrico, é preciso ainda somar uma unidade, como vimos anteriormente. Por isso, um NOT bit-a-bit no número 4, por exemplo, resulta em -5. Veja:
Os processadores Intel x86 trabalham com muitas outras operações bitwise, mas detalhá-las foge do escopo deste livro. Conforme você avançar no estudo de engenharia reversa, vai se deparar com elas.
American Standard Code for Information Interchange
Computadores trabalham com números, mas humanos trabalham também com texto. Sendo assim, houve a necessidade de criar um padrão de representação textual - e também de controle, que você entenderá a seguir.
O American Standard Code for Information Interchange, ou em português, Código Padrão Americano para o Intercâmbio de Informação, é uma codificação criada nos EUA (como o nome sugere), já que o berço da computação foi basicamente lá.
Na época em que foi definido, lá pela década de 60, foi logo usado em equipamentos de telecomunicações e também nos computadores. Basicamente é uma tabela que relaciona um número de 7 bits com sinais de controle e caracteres imprimíveis. Por exemplo, o número 97 (0b1100001) representa o caractere 'a', enquanto 98 (0b1100010) é o 'b'. Perceba que tais números não excedem os 7 bits, mas como em computação falamos quase sempre em bytes, então acaba que um caractere ASCII possui 8 bits mas só usa 7. A tabela ASCII vai de 0 a 127 e pode ser encontrada no apêndice Tabela ASCII, que você deve consultar agora.
Há vários testes interessantes que você pode fazer para entender melhor as strings ASCII. Ao saber que o caractere 'a'
é o número 97, você pode usar a função chr()
no Python para conferir:
Viu? Quando você digita 'a', o computador entende o byte 0x61 (97 em decimal). De forma análoga, quando um programa que exibe um texto na tela encontra o byte 0x61, ele exibe o caractere 'a'. Como você pode ver na tabela ASCII, todas as letras do alfabeto americano estão lá, então é razoável concluir que uma frase inteira seja na verdade uma sequência de bytes onde cada um deles está dentro da faixa da tabela ASCII. Podemos usar o Python para rapidamente imprimir os valores ASCII de cada caractere de uma string:
Perceba o b minúsculo antes das aspas do texto. Em Python, isso cria um objeto da classe bytes
ao invés de str
. Essa classe tem um método hex()
para imprimir cada o valor de caractere da string em hexadecimal e aceita um argumento para ser utilizado como separador entre os bytes. No exemplo, usei espaço.
É exatamente assim que um texto ASCII vai parar dentro de um programa ou arquivo.
Agora vá até o Apêndice Tabela ASCII e observe o seguinte:
O primeiro sinal é o NUL, também conhecido como null ou nulo. É o byte 0.
Outro byte importante é 0x0a, conhecido também por \n, line feed, LF ou simplesmente "caractere de nova linha".
O MS-DOS e o Windows utilizam na verdade dois caracteres para delimitar uma nova linha. Antes do 0x0a, temos um 0x0d, conhecido também por \r, carriage return ou CR. Essa dupla é também conhecida por CrLf.
O caractere de espaço é o 0x20.
Os dígitos vão de 0x30 a 0x39.
As letras maiúsculas vão de 0x41 a 0x5a.
As letras minúsculas vão de 0x61 a 0x7a.
Agora, algumas relações:
Se somarmos 0x20 ao número ASCII equivalente de um caractere maiúsculo, obtemos o número equivalente do caractere minúsculo em questão. Da mesma forma, se diminuirmos 0x20 ao valor de um caractere minúsculo, obtemos o seu maiúsculo. Perceba que basta mudar o bit 5 (da direita para a esquerda, com a contagem começando em zero) do valor para alternar entre maiúsculo e minúsculo.
Se diminuirmos 0x30 de um dígito, temos o equivalente numérico do dígito. Por exemplo, o dígito 5 possui o valor 0x35. Então, 0x35 - 0x30 = 5.
Sabe quando no Linux você dá um cat
num arquivo que não é de texto e vários caracteres "doidos" aparecem na tela enquanto você escuta alguns beeps? Esses sons são, na verdade, os bytes 0x07 encontrados no arquivo. Experimente!
Para complementar esta seção, assista ao vídeo Entendendo a tabela ASCII no nosso canal no YouTube. Nele há exemplos no Linux, mas o conceito é o mesmo.
A tabela ASCII padrão de 7 bits é limitada ao idioma inglês no que diz respeito ao texto. Perceba que uma simples letra 'á' (com acento agudo) é impossível nesta tabela. Sendo assim, ela foi estendida e inteligentemente passou-se a utilizar o último bit do byte que cada caractere ocupa, tornando-se assim uma tabela de 8 bits, que vai de 0 a 255 (em decimal).
Essa extensão da tabela ASCII varia de acordo com a codificação utilizada. Isso acontece porque ela foi criada para permitir texto em outros idiomas, mas somente 128 caracteres a mais não são suficientes para representar os caracteres de todos os idiomas existentes. A codificação mais conhecida é a ISO-8859-1, também chamada de Latin-1, que você vê no Apêndice Tabela ISO-8859-1/Latin-1.
Outro nome para ASCII é US-ASCII. Alguns textos referem-se a texto em ASCII como ANSI strings também.
Não há dúvida de que você já se deparou com diversos arquivos, mas será que já pensou numa definição para eles? Defino arquivo como uma sequência de bytes armazenada numa mídia digital somados a uma entrada, um registro, no sistema de arquivos (filesystem) que os referencie. Vou tentar provar minha definição para você. Faça o seguinte teste: abra o Bloco de Notas, escreva "mentebinaria.com.br" (sem aspas) e salve num arquivo chamadoarquivo.txt
.
Se nosso estudo sobre strings estiver correto, este arquivo deve possuir 19 bytes de tamanho.
Agora vamos verificar o conteúdo deste arquivo. Abra-o num editor hexadecimal. O conteúdo deve consistir apenas dos seguintes bytes:
O conteúdo exibido é exatamente a string "mentebinaria.com.br" em ASCII. Conferindo com Python, temos:
Ou seja, se o arquivo tem apenas 19 bytes, que são os codepoints referentes aos caracteres da string, onde ficam armazenados seu nome, extensão, permissões, data e hora de criação, e todos os outros dados que não são o conteúdo, ou seja, os metadados do arquivo? Só pode ser em outro lugar no filesystem né?
De fato, nos sistemas de arquivos modernos, os arquivos só armazenam seu conteúdo. Na prática, as referências a eles é que definem onde começam e onde terminam um arquivo.
A pergunta mais interessante para nós é, no entanto, em relação ao tipo de arquivo. Criamo o arquivo.txt
com a extensão .txt
, mas é bom lembrar que uma extensão de arquivo nada mais é que parte de seu nome e não mantém nenhuma relação com seu tipo real. A única forma de saber um tipo de arquivo é inferindo este tipo através de seu conteúdo. Ao olhar para o arquivo no editor hexadecimal, vimos que todos os bytes do arquivo.txt
pertencem à faixa de codepoints da tabela ASCII, por isso podemos inferir que este é um arquivo de texto ASCII.
Claro que há maneiras mais práticas de se identificar o tipo de arquivo do que inspecionando seus bytes um a um. No Windows, podemos utilizar softwares como o DIE. Ele possui uma base de assinaturas para reconhecer os bytes de um arquivo e inferir seu tipo. Outros exemplos incluem o TrID (Windows) e o file/libmagic (GNU/Linux).
Veremos agora como trabalhar com arquivos mais complexos que os arquivos de texto.
Como explicado no capítulo anterior, a maioria dos tipos de arquivo que trabalhamos possuem uma especificação. Com os arquivos executáveis no Windows não é diferente: eles seguem a especificação do formato PE (Portable Executable) que conheceremos agora.
O formato PE é o formato de arquivo executável atualmente utilizado para os programas no Windows, isso inclui os famosos arquivos EXE mas também DLL, OCX, CPL e SYS. Seu nome deriva do fato de que o formato não está preso a uma arquitetura de hardware específica.
Os programas que criam estes programas, chamados compiladores precisam respeitar tal formato e o programa que os interpreta, carrega e inicia sua execução (chamado de loader) precisa entendê-lo também.
A documentação completa do formato PE é mantida pela própria Microsoft e está disponível em .
Assim como o formato GIF e outras especificações de formato de arquivo, o formato PE possui cabeçalhos, que possuem campos e valores possíveis. Outro conceito importante é o de seções.
A estrutura geral de um arquivo PE é apresentada na imagem abaixo:
Conheceremos agora os cabeçalhos mais importantes para este primeiro contato com a engenharia reversa e, em seguida, as seções de um arquivo PE.
Os arquivos de texto, independentemente da codificação (ASCII, ISO-8859-1, UTF-8, etc) são tipos de arquivos bem simples. Começaremos agora a avaliar tipos de arquivos mais complexos. Acontece que para um programa salvar ou abrir um determinado tipo de arquivo, é essencial que este conheça seu formato. Tomemos por exemplo o formato GIF. Para inspecionar seu conteúdo, utilizaremos, no Windows, um editor hexadecimal gráfico chamado HxD.
Analise o seguinte arquivo GIF aberto no HxD:
O HxD é um editor hexadecimal. Para entender estes editores funcionam, vamos começar pelas colunas onde os bytes estão organizados.
Também chamado de offset no arquivo ou offset em disco, esta coluna exibe a posição do conteúdo no arquivo, em bytes. Na imagem, o primeiro byte (no offset 0) é o 0x47. O segundo é o 0x49 e assim por diante. Já o byte no offset 0x10 é o 0x06.
Identifique na imagem as afirmações a seguir:
O byte no offset 0x53 é o 0xc8.
O byte no offset 0x7c é 0xc0.
O byte no offset 0x90 é 0x6c.
A próxima coluna exibe os bytes em si. Sem segredos. Por serem editores hexadecimais, programas como o HxD exibem o conteúdo do arquivo em bytes hexadecimais separados por espaços, mas é importante lembrar que o conteúdo de um arquivo é uma sequência de bits em disco ou em outro dispositivo de armazenamento que, quando aberto num editor, tem seu conteúdo copiado para a memória. A maneira como estes bytes serão visualizados fica a cargo do programa que o abre. Por exemplo, se o editor exibisse os bytes em decimal, os primeiros dois bytes (0x47 e 0x49) seriam 71 e 73. Se fosse em binário, seria 1000111 e 1001001.
A esta altura o leitor já deve ter percebido que um número pode ser expresso de várias maneiras, no entanto, o sistema hexadecimal é bem otimizado para fins de engenheiria reversa.
Na terceira coluna o HxD nos apresenta a interpretação textual em ASCII de cada byte exibido na segunda coluna, ou seja, se o byte em questão estiver dentro da faixa de caracteres ASCII imprimíveis (de 0x20 à 0x7e), sua representação é exibida. Para valores fora desta faixa, o HxD imprime um ponto.
Há dezenas de outros editores hexadecimais disponíveis, inclusive alguns visualizadores de linha de comando. Vale consultar o apêndice Ferramentas e testar alguns. Se você tiver curiosidade de saber como funciona um visualizador hexadecimal, recomendo olhar o código do hdump, disponível em https://github.com/merces/hdump, um visualizador para linha de comando que implementei em C para funcionar em Windows, Linux e macOS.
De volta ao formato, é importante ressaltar que tanto quem programa o software que salva um determinado tipo de arquivo quanto quem programa o software que visualiza tal formato precisa conhecê-lo bem. Vejamos então como o formato GIF é definido.
Em geral, os formatos são definidos por campos (faixas de bytes) de tamanho fixo ou variável, que podem assumir determinados valores. Para entendê-los, precisamos da documentação deste formato (no caso, do GIF) que está disponível livremente na internet. Conforme sua especificação, o formato GIF segue, dentre outras, as seguintes regras:
Seguindo esta tabela fornecida por quem desenhou o formato GIF e olhando o conteúdo do arquivo de exemplo na imagem anterior, podemos verificar que o primeiro campo, de 6 bytes, casa exatamente com o que está definido no padrão. Os bytes são a sequência 0x47, 0x49, 0x46, 0x38, 0x39 e 0x61 que representam a sequência em ASCII GIF89a. É bem comum ao definir formatos de arquivo que o primeiro campo, normalmente chamado de cabeçalho (header) ou número mágico (magic number) admita como valor uma representação ASCII que dê alguma indicação de que tipo de arquivo se trata. Por exemplo, os tipos de arquivo ZIP possuem o magic number equivalente ao texto PK. Já o tipo de arquivo RAR começa com os bytes equivalentes ao texto Rar!. Não é uma regra, mas é comum.
No exemplo do formato GIF o tamanho do primeiro campo é de 6 bytes, mas nem todo magic number possui este tamanho. Na verdade, não há regra.
Logo após o primeiro campo, temos o segundo campo, que define a largura em pixels da imagem GIF, segundo sua documentação. Este campo possui 2 bytes e, na imagem de exemplo, são os bytes 0x30 e 0x00. Aqui cabe explicar um conceito valioso que é o endianess. Acontece que na arquitetura Intel os bytes de um número inteiro são armazenados de trás para frente (chamado de little endian). Sendo assim a leitura correta da largura em pixels deste GIF é 0x0030, ou simplesmente 0x30 (já que zero à esquerda não conta), que é 48 em decimal.
O próximo campo, também de 2 bytes, diz respeito a altura em pixels da imagem GIF e também possui o valor 0x30 (já lendo os bytes de trás para frente conforme explicado). É correto dizer então que esta é uma imagem de 48 x 48 pixels.
É por isso que alguns sistemas operacionais, com o GNU/Linux, não consideram a extensão de arquivo como sendo algo importante para definir seu tipo. Na verdade, o conteúdo do arquivo o define.
Não seguiremos com toda a interpretação do formato GIF pois este foge ao escopo de estudo de engenharia reversa, mas vamos seguir a mesma lógica para entender o formato de arquivos executáveis do sistema Windows, objeto de estudo do próximo capítulo.
Cabeçalhos, como o nome sugere, são áreas de dados no início de um arquivo. Basicamente são definidos por conjuntos de diferentes campos, que admitem valores.
Cada campo possui um tipo que também já define seu tamanho. Por exemplo, se dissermos que o primeiro campo de um primeiro cabeçalho é do tipo WORD, estamos afirmando que este tem 2 bytes de tamanho, conforme a tabela a seguir:
Há também os campos que possuem o que chamamos de máscara de bits. Neste campos, cada bit de seus bytes podem significar alguma coisa. Um bom exemplo é o campo "Characteristics" do cabeçalho de seções do arquivo PE, que veremos mais adiante.
Conhecemos bem os dez símbolos latinos 0, 1, 2, 3, 4, 5, 6, 7, 8 e 9 utilizados no sistema de numeração decimal. Neste sistema, o símbolo 0 (zero) é utilizado para descrever uma quantidade nula, enquanto o símbolo 1 (um) descreve uma quantidade, o 2 (dois) duas quantidades e assim sucessivamente, até que atinjamos a quantidade máxima com apenas um dígito, que é 9 (nove). Para representar uma quantidade a mais que essa, a regra é: pegamos o símbolo que representa uma quantidade e colocamos à sua direita o que representa uma quantidade nula formando, assim, 10 (dez). O mesmo processo ocorre com este zero à direita, até que os dígitos "acabem" novamente e aí incrementamos o 1 da esquerda em uma unidade, até que chegamos ao 20. Estudos futuros definiram este conjunto como números naturais e adicionaram outros: números inteiros (que contemplam os negativos), fracionários, complexos, etc.
Mas este não é o único - nem é o primeiro - sistema para representação de quantidades. Ou seja, não é o único sistema de numeração possível. Os computadores, diferente dos humanos, são máquinas elétricas. Sendo assim, a maneira mais fácil de números fluírem por eles seria com um sistema que pudesse ser interpretado a partir de dois estados: ligado e desligado.
O sistema binário surgiu há muito tempo e não vou arriscar precisar quando ou onde, mas em 1703 o alemão Leibniz publicou um trabalho refinado, com tradução para o inglês disponível em http://www.leibniz-translations.com/binary.htm, baseado na dualidade taoísta chinesa do yin e yan a qual descrevia o sistema binário moderno com dois símbolos: 0 (nulo) e 1 (uma unidade). Por ter somente dois símbolos, ficou conhecido como sistema binário, ou de base 2. A contagem segue a regra: depois de 0 e 1, pega-se o símbolo que representa uma unidade e se insere, à sua direita, o que representa nulo, formando o número que representa duas unidades neste sistema: 10.
Daí vem a piada nerd que diz haver apenas 10 tipos de pessoas no mundo: as que entendem linguagem binária e as que não entendem.
Assim sendo, se formos contar até dez unidades, teremos: 0, 1, 10, 11, 100, 101, 110, 111, 1000, 1001 e 1010.
Perceba que a lógica de organização dos símbolos no sistema binário é a mesma do sistema de numeração decimal. No entanto, em binário, como o próprio nome sugere, só temos dois símbolos disponíveis para representar todas as quantidades.
Por utilizar dois símbolos que são idênticos aos do sistema decimal, num contexto genérico, números binários são normalmente precedidos com 0b para não haver confusão. Então para expressar dez quantidades faríamos 0b1010. Por exemplo, a seguinte linha na console do Python imprime o valor 10:
Ou em linguagem C:
Como o próprio nome sugere, o sistema octal possui oito símbolos: 0, 1, 2, 3, 4, 5, 6 e 7. À esta altura já dá pra sacar que para representar oito quantidades em octal o número é 10. Nove é 11, dez é 12 e assim sucessivamente.
O sistema octal é utilizado para as permissões de arquivo pelo comando chmod nos sistemas baseados em Linux e BSD. Os números 1, 2 e 4 representam permissão de execução, escrita e leitura, respectivamente. Para combiná-las, basta somar seus números correspondentes. Sendo assim, uma permissão 7 significa que se pode-se tudo (rwx) enquanto uma permissão 6 somente escrita e leitura (rw-). Tais números foram escolhidos para não haver confusão. Se fossem os números 1, 2 e 3 a permissão 3 poderia significar tanto ela mesma quanto 1+2 (execução + escrita). Usando 1, 2 e 4 não há brechas para dúvida. ;)
Na programação, normalmente um número octal é precedido de um algarismo 0 para diferenciar-se do decimal. Por exemplo, 12 é doze em decimal, mas 012 é octal, que vale dez em decimal. Logo, preste atenção antes de ignorar um zero à esquerda.
Veja um exemplo em Python (lembre-se: abra o Python e estude junto agora):
Finalmente o queridinho hexa; o sistema de numeração que mais vamos utilizar durante todo o livro.
O hexadecimal apresenta várias vantagens sobre seus colegas, a começar pelo número de símbolos: 16. São eles: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E e F. Os números que eles formam são normalmente prefixados com 0x, embora alguns programas utilizem um sufixo h. Por exemplo: 0x1234 ou 1234h.
Perceba que todos os sistemas apresentados até agora utilizam os mesmos símbolos latinos. Isso é só para facilitar mesmo.
Aqui cabe uma tabela comparativa, só para exercitar:
Existem algumas propriedades interessantes quando relacionamos os diferentes sistemas de numeração vistos aqui. São elas:
Quanto mais símbolos existem no sistema, menos dígitos utilizamos para representar mais quantidades.
0xF é igual a 0b1111, assim como 0xFF equivale a 0b11111111 e 0xFE é o mesmo que 0b11111110.
0x10 é 16. Então, 0x20 é 32 e 0x40 é 64.
Em hexadecimal, 9 + 1 é A, então 19 + 1 é 1A.
Na arquitetura x86, os endereços são de 32-bits. Ao analisar a pilha de memória, é bom se acostumar com decrementos de 4 bytes. Por exemplo, 12FF90, 12FF8C, 12FF88, 12FF84, 12FF80, 12FF7C e assim sucessivamente.
Na conversão de hexadecimal para binário, cada dígito hexa pode ser compreendido como quatro dígitos binários. Para exemplificar, tomemos o número 0xB0B0CA. Separando cada dígito hexa e convertendo-o para binário, temos:
Por isso podemos dizer que 0xB0B0CA é 0b101100001011000011001010.
Em hexadecimal, zeros à esquerda e letras maiúsculas ou minúsculas não importam. Veja no Python:
Falaremos bastante em endereços de memória no conteúdo de engenharia reversa e todos estão em hexadecimal, por isso é importante "pensar em hexa" daqui para frente. Mas não se preocupe, se precisar calcular algo, sempre poderá recorrer à calculadora ou ao Python.
Um bom exercício para fixar este conteúdo é criar o seu sistema de numeração com símbolos à sua escolha. Por exemplo, inventarei agora um sistema ternário chamado Lulip's que possui os seguintes símbolos para representar zero, uma e duas quantidades respectivamente: @, # e $. Olha só como ficaria a comparação dele com o sistema decimal:
É importante que você perceba a lógica utilizada para contar no sistema Lulip's. Apesar de não ser um sistema que exista por aí, ele serve de base para você entender como qualquer valor, em qualquer sistema, pode ser convertido para outro.
Na linguagem C, foi criado um padrão para saber programaticamente o fim de uma string: ela precisa ser terminada com um byte nulo, também chamado de nullbyte. Este nada mais é que um byte zerado. Sendo assim, a string ASCII "fernando", se utilizada num programa escrito em C, fica no binário compilado (no .exe gerado pelo processo de compilação, por exemplo) da seguinte forma:
É importante não confundir o nullbyte com o caractere de nova linha. Este pode ser o Line Feed (0x0a), também conhecido por newline. Já no DOS/Windows, a nova linha é definida por um par de caracteres: Carriage Return (0x0d) seguido de Line Feed, sequência conhecida por CrLf em linguagens como Visual Basic.
Se a string for UTF-16, então dois bytes nulos serão adicionados ao fim. Se for UTF-32, quatro.
Talvez você se pergunte qual a razão pela qual este conceito é útil em engenhria reversa. Bem, no caso de busca de strings num binário compilado, você pode refinar a busca por sequências de bytes na tabela ASCII, usando ou não uma codificação do padrão Unicode (já que os valores ASCII são os mesmos) terminados com por um ou mais nullbytes. Por exemplo, supondo que você esteja buscado a string "Erro" dentro de um programa, o primeiro passo é descobrir quais são os bytes equivalentes na tabela ASCII desta string. Ao invés de usar a tabela, você pode usar o Python:
Agora sabemos que a sequência de bytes a ser procurada vai depender do tipo de string. Se for UTF-16-LE (Little Endian, que é o padrão no Windows), podemos montar a string sem nem precisar do Python, bastando para isso colocar os zeros depois de cada byte:
45 00 72 00 72 00 6f 00
Mas para sermos mais assertivos, caso não haja mais nada depois do "o" da palavra "Erro" no programa, podemos adicionar o nullbyte na busca:
Em ASCII:
45 72 72 6f 00
Em UTF-16-LE:
45 00 72 00 72 00 6f 00 00 00
Claro que os programas feitos para buscarem texto dentro de arquivos já possuem esta inteligência, no entanto, a proposta deste livro é entender a fundo como a engenharia reversa funciona e por isso não poderíamos deixar de cobrir este importante assunto.
0
6
47 49 46 38 39 61
Cabeçalho
6
2
<variável>
Largura em pixels
8
2
<variável>
Altura em pixels
Nomenclatura Microsoft
Nome do tipo em C (Microsoft Visual Studio)
Tamanho em bytes
BYTE
unsigned char
1
WORD
unsigned short
2
DWORD
unsigned long
4
QWORD
__int64
8
0
0
0
0
1
1
1
1
2
2
2
10
3
3
3
11
4
4
4
100
5
5
5
101
6
6
6
110
7
7
7
111
8
8
10
1000
9
9
11
1001
A
10
12
1010
B
11
13
1011
C
12
14
1100
D
13
15
1101
E
14
16
1110
F
15
17
1111
0
@
1
#
2
$
3
#@
4
##
5
#$
6
$@
7
$#
8
$$
9
#@@
10
#@#
11
#@$
Não estranhe o nome deste cabeçalho. Ele é parte do que chamamos de stub do MS-DOS: um executável completo de MS-DOS presente no início de todo executável PE, para fins de retrocompatibilidade.
Sendo assim, todo executável PE começa com este cabeçalho, que é definido pela seguinte estrutura:
Este cabeçalho possui 64 bytes de tamanho. Para chegar a esta conclusão basta somar o tamanho de cada campo, onde uint16_t é um tipo na linguagem C que define uma variável de 16 bits ou 2 bytes. Os seguintes campos variam deste tamanho:
uint16_t e_res[4] que é um array de 4 campos de 16 bits, totalizando em 64 bits ou 8 bytes.
uint16_t e_res2[10] que é um array de 10 campos de 16 bits, totalizando em 160 bits ou 20 bytes.
uint32_t e_lfanew que é um campo de 32 bits ou 4 bytes.
Os outros 16 campos possuem o tamanho de um uint16_t (16 bits ou 2 bytes). Então somando os tamanhos de todos os campos, temos 64 bytes.
Por ser um cabeçalho ainda presente no formato PE somente por questões de compatibilidade com o MS-DOS, não entraremos em muitos detalhes, mas estudaremos alguns de seus campos a seguir.
Este campo de 2 bytes sempre contém os valores 0x4d e 0x5a, que são os caracteres 'M' e 'Z' na tabela ASCII. Portanto é comum verificar que todo arquivo executável do Windows que segue o formato PE começa com tais valores, que representam as iniciais de Mark Zbikowski, um dos criadores deste formato para o MS-DOS.
Podemos utilizar um visualizador hexadecimal como o hexdump no Linux para verificar tal informação. Vamos pedir, por exemplo, os primeiros 16 bytes de um arquivo putty.exe:
Perceba os bytes 0x4d e 0x5a logo no início do arquivo.
O hexdump exibe um caractere de ponto (.) na terceira coluna quando o byte não está na faixa ASCII imprimível, ao contrário do wxHexEditor que exibe um caractere de espaço em branco.
O próximo campo importante para nós é o e_lfanew, um campo de 4 bytes cujo valor é a posição no arquivo do que é conhecido por assinatura PE, uma sequência fixa dos seguintes 4 bytes: 50 45 00 00.
Como o cabeçalho do DOS possui um tamanho fixo, seus campos estão sempre numa posição fixa no arquivo. Isso se refere aos campos e não a seus valores que, naturalmente, podem variar de arquivo para arquivo. No caso do e_lfanew, se fizermos as contas, veremos que ele sempre estará na posição 0x3c (ou 60 em decimal), já que ele é o último campo de 4 bytes de um cabeçalho de 64 bytes.
Para ver o valor deste campo rapidamente podemos pedir ao hexdump que pule (opção -s de skip) então 0x3c bytes antes de começar a imprimir o conteúdo do arquivo em hexadecimal. No comando a seguir também limitamos a saída em 16 bytes com a opção -n (number):
O número de 32 bits na posição 0x3c é então 0x000000f8 ou simplesmente 0xf8 (lembre-se do little endian). Este é então o endereço da assinatura PE, que consiste numa sequência dos seguintes 4 bytes: 0x50 0x45 0x00 0x00.
Perceba que os dois primeiros bytes na assinatura PE possuem representação ASCII justamente das letras 'P' e 'E' maiúsculas. Sendo assim, essa assinatura pode ser escrita como "PE\0\0", no estilo C string.
Logo após o cabeçalho há o código do programa que vai imprimir na tela uma mensagem de erro caso um usuário tente rodar este arquivo PE no MS-DOS. Normalmente o texto impresso na tela é:
Depois disso o programa sai. Mas este pequeno programa de MS-DOS é adicionado pelo compilador (pelo linker mais especificamente) e seu conteúdo pode variar, pois não há um padrão rígido a ser seguido.
Para por em prática a análise desta primeira parte do arquivo PE, abra o executável da calculadora do Windows (normalmente em C:\Windows\System32\calc.exe) no HxD.
Note que:
Logo no início do arquivo, há o número mágico "MZ".
Na posição 0x3c, ou seja, no campo e_lfanew, há o endereço da assinatura PE (0xd8 no caso deste executável).
Logo após os 4 bytes do campo e_lfanew, começa o código do programa de MS-DOS, no offset 0x40, com uma sequência de bytes que não fazem sentido para nós por enquanto (veja que o texto impresso na tela pelo DOS stub é todavia bem visível).
Finalmente, na posição 0xd8 encontra-se a assinatura PE\0\0. Aqui sim, começa o formato PE propriamente dito.
Não parece, mas este cabeçalho é muito importante. Seu nome deve-se ao fato de que ele é opcional para arquivos objeto, mas é obrigatório em arquivos executáveis, que são o nosso foco de estudo. O tamanho desde cabeçalho não é fixo. É definido pelo campo SizeOfOptionalHeader do cabeçalho COFF, que vimos anteriormente. Sua estrutura para arquivos PE de 32-bits, também chamados de PE32, é a seguinte:
Vamos analisar agora os campos mais importantes no nosso estudo:
O primeiro campo, de 2 bytes, é um outro número mágico que identifica o tipo de executável em questão. O valor 0x10b significa que o executável é um PE32 (executável PE de 32-bits), enquanto o valor 0x20b diz que é um PE32+ (executável PE de 64-bits).
A Microsoft chama os executáveis de PE de 64-bits de PE32+ e não de PE64.
Este é talvez o campo mais importante do cabeçalho opcional. Nele está contido o endereço do ponto de entrada (entrypoint), abreviado EP, que é onde o código do programa deve começar. Para arquivos executáveis este endereço é relativo à base da imagem (campo que veremos a seguir). Para bibliotecas, ele não é necessário e pode ser zero, já que as funções de bilbioteca podem ser chamadas arbitrariamente.
Imagem é como a Microsoft chama um arquivo executável (para diferenciar de um código-objeto) quando vai para a memória. Neste campo está o endereço de memória que é a base da imagem, ou seja, onde o programa será carregado em memória. Para arquivos executáveis (.EXE) o padrão é 0x400000. Já para bibliotecas (.DLL), o padrão é 0x10000000, embora executáveis do Windows como o calc.exe também apresentem este valor no ImageBase.
Este campo define o tipo de subsistema necessário para rodar o programa. Valores interessantes para nós são:
0x002 - Windows GUI (Graphical User Interface) - para programas gráficos no Windows (que usam janelas, etc).
0x003 - Windows CUI (Character User Interface) - para programas de linha de comando.
Ao contrário do que possa parecer, este campo não é somente para DLL's. Ele está presente e é utilizado para arquivos executáveis também. Assim como o campo Characteristics do cabeçalho COFF visto anteriormente, este campo é uma máscara de bits com destaque para os possíveis valores:
6
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE
8
IMAGE_DLLCHARACTERISTICS_NX_COMPAT
O estado bit 6 nos diz se a randomização de endereços de memória, também conhecida por ASLR (Address Space Layout Randomization), está ativada para este binário, enquanto o estado do bit 8 diz respeito ao DEP (Data Execution Prevention), também conhecido pela sigla NX (No eXecute). O estudo aprofundado destes recursos foge do escopo inicial deste livro, mas é importante que saibamos que podemos desabilitar tais recursos simplesmente forçando estes bits para zero.
Ainda como parte do cabeçalho opcional, temos os diretórios de dados, ou Data Directories. São 16 diretórios ao todo, cada um com uma função. Concentraremos, no entanto, nos mais importantes para este estudo inicial. A estrutura de cada diretório de dados é conhecida por IMAGE_DATA_DIRECTORY e tem a seguinte definição:
Vejamos agora alguns diretórios:
O primeiro diretório de dados aponta para a tabela de exports, ou seja, de funções exportadas pela aplicação. Por isso mesmo sua presença (campos VirtualAddress e Size diferentes de zero) é muito comum em bibliotecas.
O campo VirtualAddress aponta para uma outra estrutura chamada EDT (Export Directory Table), que contém os nomes das funções exportadas e seus endereços, além de um ponteiro para uma outra estrutura, preenchida em memória, chamada de EAT (Export Address Table). Entenderemos mais sobre estas e outras estruturas em breve.
Sendo a contraparte da Export Table, a Import Table aponta para a tabela de imports, ou seja, de funções importadas pela aplicação. O campo VirtualAddress aponta para a IDT (Import Directory Table), que tem um ponteiro para a IAT (Import Address Table), que estudaremos mais à frente.
Aponta para uma estrutura de árvore binária que armazena todos os resources num executável (ícones, janelas, strings - principalmente quando o programa suporta vários idiomas), etc. Também estudaremos um pouco mais sobre estes "recursos" no futuro.
Após o cabeçalho opcional, encontramos os cabeçalhos das seções (estas serão explicadas no próximo capítulo). Neste cabeçalho há um array de estruturas como a seguir:
Cada estrutura define uma seção no executável e a quantidade de estrutura (quantidade de elementos neste array) é igual ao número de seções no executável, definido no campo NumberOfSections do cabeçalho COFF. Vamos aos campos importantes:
Este campo define o nome da seção. Como é um array de 8 elementos do tipo uint8_t, este nome está limitado à 8 caracteres. A string .text por exemplo ocupa apenas 5 bytes, então os outros 3 devem estar zerados. Há suporte à UTF-8 para estes nomes.
O tamanho em bytes da seção depois de ser mapeada (carregada) em memória pelo loader. Se este valor for maior que o valor do campo SizeOfRawData, os bytes restantes são preenchidos com zeros.
O endereço relativo à base da imagem (campo ImageBase do cabeçalho Opcional) quando a seção é carregada em memória. Por exemplo, se para uma seção este valor é 0x1000 e o valor de ImageBase é 0x400000, quando carregada em memória esta seção estará no endereço 0x401000. Para chegar nesta conclusão basta somar os dois valores.
Tamanho em bytes da seção no arquivo PE, ou seja, antes de ser mapeada em memória. Alguns autores também usam a expressão "tamanho em disco" ou simplesmente "tamanho da seção".
O offset em disco da seção no arquivo. É correto dizer que aponta para o primeiro byte da seção. Por exemplo, se para dada seção este valor é 0x400 e o valor do campo SizeOfRawData é 0x1800, para ver somente seu conteúdo em hexadecimal com o hexdump poderíamos fazer:
Este é um campo que define algumas flags para a seção, além das permissões em memória que ela deve ter quando for mapeada pelo loader. Ele possui 32-bits, onde alguns significam conforme a tabela a seguir:
5
IMAGE_SCN_CNT_CODE
A seção contém código executável
6
IMAGE_SCN_CNT_INITIALIZED_DATA
A seção contém dados inicializados
7
IMAGE_SCN_CNT_UNINITIALIZED_ DATA
A seção contém dados não inicializados
29
IMAGE_SCN_MEM_EXECUTE
Terá permissão de execução
30
IMAGE_SCN_MEM_READ
Terá permissão de leitura
31
IMAGE_SCN_MEM_WRITE
Terá permissão de escrita
As flags que contém o texto "MEM" no nome dizem respeito às permissões que a seção terá quando mapeada em memória. De acordo com elas o SO vai setar as permissões nas páginas de memória nas quais a seção é carregada.
É importante notar que campos como o Characteristics são o que chamamos de máscaras de bits. Por exemplo, a tabela anterior diz que se o bit 30 deste campo está setado (seu valor é 1), então esta seção terá permissão de leitura quando em memória. O valor de campo Characteristics seria então 01000000000000000000000000000000 em binário, mas você provavelmente vai encontrar este valor representado em hexadecimal (0x40000000) nos analisadores de executáveis que for utilizar. Aliás, agora é uma boa hora para abrir o DIE e analisar alguns arquivos executáveis, colocando em prática tudo o que foi visto até aqui.
Normalmente, chamamos de arquivos executáveis os arquivos que quando clicados duas vezes (no caso do Windows) executam. Os mais famosos no Windows são os de extensão .exe
, que serão o foco do nosso estudo. Para entender como estes arquivos são criados, é preciso notar os passos:
Escrita do código-fonte na linguagem escolhida num arquivo de texto.
Compilação.
Linking.
O compilador cria os chamados arquivos objeto a partir do código-fonte (em texto). Estes objetos contém instruções de máquina e dados.
O linker serve para juntar todos os objetos num único arquivo, realocar seus endereços e resolver seus símbolos (nomes de função importadas, por exemplo).
O processo de compilação (transformação do código-fonte em texto em código de máquina) gera como saída um arquivo chamado de objeto.
No que diz respeito ao processo de linking, estes executáveis podem ser de dois tipos:
Todo o código das funções externas ao executável principal é compilado junto a ele. O resultado é um executável livre de dependências, porém grande. É raro encontrar executáveis assim para Windows.
O executável vai depender de bibliotecas externas (DLL's, no caso do Windows) para funcionar, como estudamos na seção Tabela de Importações.
O nosso binário CRACKME.EXE, que usamos em vários momentos deste livro é dinâmico. Nós já vimos isso ao analisar sua Imports Table com o DIE, mas agora vamos utilizar novamente a ferramenta dumpbin para checar suas dependências:
Existem vários parsers de executáveis alternativos ao dumpbin. Consulta o apêndice Ferramentas para ver uma lista.
Foi visto que um dos diretórios de dados presentes no cabeçalho opcional é reservado para a Import Table, também conhecida por sua sigla IT. Nele há um ponteiro para a IDT (Import Directory Table), apontado pelo valor do campo VirtualAddress.
A IDT, por sua vez, é um array de estruturas do tipo IMAGE_IMPORT_DESCRIPTOR definidas a seguir:
Não se deve confundir a IDT (Import Descriptor Table) com a IDT (Interrupt Descriptor Table). Esta última é uma estrutura que mapeia interrupções para seus devidos handlers, assunto que não é coberto neste livro.
O número de elementos do array de estruturas IMAGE_IMPORT_DESCRIPTOR é igual ao número de bibliotecas que o executável PE depende, ou seja, o número de DLL's das quais o executável importa funções. Há ainda um elemento adicional, completamente zero (preenchido com null bytes) para indicar o fim do array.
O campo OriginalFirstThunk (chamado de rvaImportLookupTable em algumas literaturas) aponta para o que chamamos Import Lookup Table (ILT), um array de números de 32-bits (64-bits para PE32+), onde seu bit mais significativo (MSB - Most Significant Bit) define se a função será importada por número ordinal (caso o bit seja 1). Já no caso de este bit estar zerado, a importação da função dá-se por nome e os outros 31 bits do número representam um endereço para uma estrutura que finalmente contém o nome da função.
Sendo assim, o número de elementos do array ILT é igual ao número de funções importadas por uma DLL em particular, definida na estrutura IMAGE_IMPORT_DESCRIPTOR.
Este campo aponta finalmente para o que chamamos de IAT (Import Address Table), muito conhecida dos engenheiros reversos. Essa tabela é em princípio idêntica à Import Lookup Table, mas no processo de carregamento do executável (load time, que estudaremos mais à frente no livro), é preenchida com os endereços reais das funções importadas. Isto porque um executável dinamicamente linkado não sabe ainda qual o endereço de cada função de cada DLL que ele precisa chamar.
É importante lembrar o conceito de biblioteca compartilhada aqui. A ideia é ter apenas uma cópia dela carregada em memória e todos os programas que a utilize possam chamar suas funções. Por isso todo este esquema de preenchimento da IAT pelo loader é necessário.
Para fixar o conteúdo, é interessante validar tais informações. O exemplo abaixo utiliza o DIE para ver o diretório de dados de um arquivo PE:
Neste exemplo o campo VirtualAddress do Import Directory tem o valor 0x3000. Este é um endereço relativo, que você aprenderá sobre na próxima seção, no entanto, por agora você precisa apenas saber que este endereço é somado ao ImageBase para funcionar. No caso, o ImageBase deste binário é 0x400000 (muito comum), então o endereço da Import Descriptor Table, apontado por este campo VirtualAddress, é 0x403000.
Perceba que o DIE chama o campo VirtualAddress dos diretórios apenas de Address.
Ao clicar no botão "H" à direita do diretório IMPORT, vemos o conteúdo do primeiro elemento do array IDT, que é justamente o campo OriginalFirstThunk:
Neste exemplo o valor é 0x00003078 (lembre-se que números são armazenados em little-endian). Sendo novamente um endereço relativo, então o endereço da ILT é 0x403078.
Seguindo este endereço, encontramos achamos a ILT, que é um array de números de 32-bits como já dissemos:
O primeiro número deste array é então 0x000032cc. Como seu MSB está zerado, sabemos que se trata de uma importação por nome (e não por número da função). Se seguirmos este endereço, novamente somando o ImageBase, finalmente chegamos ao nome da função:
A estrutura que contém o nome da função é chamada de Hint/Name Table _**_onde o nome da função começa no terceiro byte, neste caso, em 0x4032ce. O tamanho do nome é variável (naturalmente o tamanho em bytes do nome de uma função pode variar).
As seções são divisões num binário PE. Uma analogia que torna o conceito de seções simples de entender é a de comparar o binário PE com uma cômoda: as seções seriam suas gavetas. Cada gaveta da cômoda, em teoria, guarda um tipo de dado distinto, e assim é com as seções, apesar de não ser uma regra muito rígida. Elas são necessárias porque diferentes conteúdos exigem diferentes tratamentos quando carregados em memória pelo Sistema Operacional.
Podemos então dizer que um binário PE é completamente definido por cabeçalhos e seções (com seu conteúdo), como na seguinte ilustração:
Como dito, a principal separação que existe entre as seções é em relação a seu conteúdo, que distinguimos entre código ou dados. Apesar de terem seus nomes ignorados pelo loader do Windows, convencionam-se alguns, normalmente iniciados por um ponto. As seções padrão importantes são discutidas a seguir:
Também nomeada CODE em programas compilados com o Delphi, esta seção contém o código executável do programa. Em seu cabeçalho normalmente encontramos as permissões de leitura e execução.
Também chamada de CODE em programas criados com Delphi, esta seção contém dados inicializados com permissão de leitura e escrita. Estes dados podem ser, por exemplo, uma C string declarada e já inicializada. Por exemplo, considere o programa abaixo:
A variável local s é um array de char e pode ser alterada a qualquer momento dentro da função main(). De fato, o código na linha 6 a altera. Sendo assim, é bem possível que um compilador coloque seu conteúdo numa seção de dados inicializados com permissão tanto para leitura quanto para escrita. ;-)
Apesar de fazer sentido, os compiladores não precisam respeitar tal lógica. O conteúdo da variável s no exemplo apresentado pode ser armazenado na seção .rdata (ou mesmo na .text) e ser manipulado na pilha de memória para sofrer alterações. Não há uma imposição por parte do formato e cada compilador escolhe fazer do seu jeito.
Seção que contém dados inicializados, com permissão somente para leitura. Um bom exemplo seria com o programa abaixo:
Neste caso declaramos a variável s como const, o que instrui o compilador a armazená-la numa região de memória somente para leitura, casando perfeitamente com a descrição da seção .rdata.
Seção para abrigar as tabelas de imports, comum em todos os binários que importam funções de outras bibliotecas. Possui permissão tanto para leitura quanto para gravação. Entenderemos o motivo em breve.
O sistema operacional divide a memória RAM em páginas, normalmente de 4 kilobytes (ou 4096 bytes) nas versões atuais do Windows. Nestas páginas de memória o sistema configura as permissões (leitura, escrita e execução). Os arquivos executáveis precisam ser carregados na memória e cada seção pode requerer permissões diferentes. Com isso em mente, considere a seguinte situação hipotética:
Um executável tem seus cabeçalhos ocupando 2 KB.
Sua seção .text tem 6 KB de tamanho e requer leitura e execução.
Sua seção .data tem 5 KB de tamanho e requer leitura e escrita.
O tamanho final do executável em disco é 13 KB.
Para mapear este executável em memória e rodá-lo, o SO precisa copiar o conteúdo de suas seções em páginas de memória e configurar suas permissões de acordo. Analise agora a figura abaixo:
Perceba na figura que a seção .text já ocuparia duas páginas que precisariam ter permissões de leitura e execução. No que sobrou da segunda página, o SO não pode mapear a .data pois esta, apesar de compartilhar a permissão de leitura, exige escrita ao invés de execução. Logo, ele precisa mapeá-la na próxima página.
Como consequência, o tamanho total de cada seção em memória é maior que seu tamanho em disco, devido ao que chamamos de alinhamento de seção. No cabeçalho opcional existe o campo SectionAlignment, que pulei propositalmente. Este campo define qual fator de alinhamento deve ser utilizado para todas as seções do binário quando mapeadas em memória. O padrão é o valor do tamanho da página de memória do sistema.
Como bônus por ter chegado até aqui, segue um código que, depois de compilado e executado, vai dizer qual o tamanho da página de memória na sua versão do Windows.
Para impedir que os programas do usuário acessem ou modifiquem dados críticos do sistema operacional, o Windows suporta dois níveis de execução de código: modo de usuário e modo de kernel, mais conhecidos por suas variante em inglês: user mode e kernel mode.
Os programas comuns rodam em user mode, enquanto os serviços internos do SO e drivers rodam em kernel mode.
Apesar de o Windows e outros sistemas operacionais modernos trabalharem com somente estes dois níveis de privilégios de execução, os processadores Intel e compatíveis suportam quatro níveis, também chamado de anéis (rings), numerados de 0 a 3. Para kernel mode é utilizado o ring 0 e para user mode, o ring 3.
Programas rodando em user mode tampouco possuem acesso ao hardware do computador. Essencialmente, todos estes fatores combinados fazem com que os programas rodando neste privilégio de execução não gerem erros fatais como a famosa "tela azul da morte" (ou BSOD - Blue Screen Of Death).
Passa que toda a parte legal acontece em kernel mode, sendo assim, um processo (na verdade uma thread) rodando em user mode pode executar tarefas em kernel mode através da API do Windows, que funciona como uma interface para tal. Essa comunicação é ilustrada no diagrama a seguir:
Quando um programador cria um programa, em muitos casos ele utiliza funções de bibliotecas (ou libraries em inglês), também chamadas de DLL (Dynamic-Link Library). Sendo assim, analise o seguinte simples programa em C:
Este programa utiliza a função printf(), que não precisou ser implementada pelo programador. Ao contrário, ele simplesmente a chamou, já que esta está definida no arquivo stdio.h.
Quando compilado, este programa terá uma dependência da biblioteca de C (arquivo msvcrt.dll no Windows) pois o código para a printf() está nela.
Tudo isso garante que vários programadores usem tal função, que tenha sempre o mesmo comportamento se usada da mesma forma. Mas já parou para pensar como a função printf() de fato escreve na tela? Como ela lidaria com as diferentes placas de vídeo, por exemplo?
O fato é que a printf() não escreve diretamente na tela. Ao contrário, a biblioteca de C pede ao kernel através de uma função de sua API que seja feito. Sendo assim, temos, neste caso um EXE que chama uma função de uma DLL que chama o kernel. Estudaremos mais a frente como isso é feito.
Quando um programa é executado (por exemplo, com um duplo-clique no Windows), ele é copiado para a memória e um processo é criado para ele. Dizemos então que um processo está rodando, mas esta afirmação não é muito precisa: na verdade, todo processo no Windows possui pelo menos uma thread e ela sim é que roda. O processo funciona como um "container" que contém várias informações sobre o programa rodando e suas threads.
Quem cria esse processo em memória é um componente do sistema operacional chamado de image loader, presente na biblioteca ntdll.dll.
O código do loader roda antes do código do programa a ser carregado. É um código comum a todos os processos executados no sistema operacional.
Dentre as funções do loader estão:
Ler os cabeçalhos do arquivo PE a ser executado e alocar a memória necessária para a imagem como um todo, suas seções, etc.
As seções são mapeadas para a memória, respeitando-se suas permissões.
Ler a tabela de importações do arquivo PE a fim de carregar as DLLs requeridas por este e que ainda não foram carregadas em memória. Esse processo também é chamado de resolução de dependências.
Preencher a IAT com os endereços das funções importadas.
Carregar módulos adicionais em tempo de execução, se assim for pedido pelo executável principal (também chamado de módulo principal).
Manter uma lista de módulos carregados por um processo.
Transferir a execução para o entrypoint (EP) do programa.
Common Object File Format Specification
Imediatamente após a assinatura PE temos o cabeçalho COFF (Common Object File Format Specification) às vezes chamado simplesmente de Cabeçalho do Arquivo (File Header ) ou mesmo Cabeçalho do Arquivo PE (PE File Header). Trata-se de um cabeçalho especificado antes mesmo do formato PE para o sistema operacional VAX/VMS (depois chamado de OpenVMS) da DEC (empresa comprada pela Compaq, que por sua vez, foi comprada pela HP) na década de 70. A razão pela qual a Microsoft teria aproveitado o formato COFF é que boa parte dos engenheiros do time que desenvolveu o Windows NT trabalhavam para a DEC antes.
O cabeçalho COFF possui apenas 20 bytes e é representado pela seguinte estrutura:
Vamos à definição dos campos importantes para nós:
Campo de 2 bytes que define a arquitetura da máquina para qual o programa foi construído. Valores comuns são 0x14c (Intel i386 ou compatíveis) e 0x8664 (AMD64 ou compatíveis). A tabela completa está disponível na documentação oficial.
Também de 2 bytes, o valor deste campo é o número de seções que o arquivo PE em questão possui. As seções serão estudadas mais a frente.
Este é um número de 32 bits que define o número de segundos desde à meia-noite do dia 1 de Janeiro de 1970, conhecido também por Epoch time. Com este valor é possível saber quando o arquivo foi criado.
Vale lembrar que este campo não é utilizado pelo loader de arquivos PE no Windows e seu valor pode ser alterado após a compilação, logo, não é 100% confiável, ou seja, você não pode garantir que um binário PE foi compilado na data e hora informadas pelo valor neste campo.
Contém o tamanho do próximo cabeçalho, conhecido como Cabeçalho Opcional, que estudaremos muito em breve.
Campo que define alguns atributos do arquivo. Este campo é uma máscara de bits, ou seja, cada bit desses 2 bytes diz respeito à uma característica específica do binário. Não cabe aqui explicar todos os possíveis valores, mas os mais comuns são:
Analise novamente o (dump hexadecimal do executável da calculadora)[dos.md#exercicios] considere que:
Logo após a assinatura PE na posição 0xd8 temos o primeiro campo do cabeçalho COFF que é o Machine. Ele é um campo de 2 bytes conforme já dito, então os bytes 0x4c e 0x01 definem seu valor. Considerando o endianness, chegamos ao valor 0x14c, que define que este executável foi criado para máquinas Intel i386 ou compatíveis.
Em seguida, na posição 0xde, temos o NumberOfSections que é 4.
Depois vem o campo TimeDateStamp com o número inteiro de 32 bits (4 bytes) sem sinal 0x4ce7979d que é 1290246045 em decimal. Podemos usar o comando date do Linux para converter para data e hora atuais:
Pulamos então 8 bytes referentes aos campos PointerToSymbolTable e NumberOfSymbols (normalmente zerados mesmo), encontrando o valor da word SizeOfOptionalHeader em 0xec de valor 0xe0.
A próxima word é o valor do campo Characteristics, que neste arquivo é 0x102. Convertendo para binário temos o valor 100000010 (bits 2 e 9 setados) significando que o arquivo é um executável de 32-bits.
Em algumas referências o leitor encontrará o cabeçalho COFF como parte do cabeçalho NT (IMAGE_NT_HEADER), onde o primeiro campo é chamado de Signature Bytes, que é onde fica a assinatura PE para binários PE, mas também pode conter os bytes equivalentes das strings NE, LE ou MZ (executáveis puros de MS-DOS). Na verdade o COFF é uma especificação completa para arquivos do tipo "código-objeto", mas não exploraremos seu uso além do formato PE neste livro.
Baixe e descompate o arquivo CRACKME.ZIP em https://www.mentebinaria.com.br/files/file/19-crackme-do-cruehead/. Usando o comando dumpbin através do Visual Studio Developer Command Prompt (instalado com o Visual Studio Community), exiba o COFF/File Header do binário CRACKME.EXE. Você deve ver algo assim:
Com o DIE, é preciso carregar o CRACKME.EXE nele, marcar a caixa de seleção Advanced, clicar no botão PE (Alt+P), na aba NT Headers e por fim, na aba File Header. Você deve ver uma janela como a abaixo:
Os botões com "..." localizados ao lado direito de vários valores de campos provêem informações adicionais sobre tais valores. Não deixe de experimentar.
Vamos programar um pouco. Neste momento é importante, se ainda não o fez, que você instale o Visual Studio Community.
Abra o Visual Studio e crie um novo projeto do tipo Console App, conforme a imagem abaixo mostra:
Nomeie o projeto como "Mensagem" (sem aspas) e após criá-lo, substitua o conteúdo do arquivo Mensagens.cpp que o Visual Studio criará automaticamente por este:
Tecle F5 para rodar o programa e você deve ver uma janela como esta:
Há vários conceitos escondidos neste código de propósito, de forma que dedeiquemos alguns minutos ao estudo deles. Acompanhe:
Na linha 1, como o Windows utiliza sistemas de arquivos que não são sensíveis ao caso, ou seja, não diferenciam maiúsculas de minúsculas, tanto faz escrever Windows.h
, windows.h
ou mesmo WINDOWS.H
. Vai funcionar.
Na linha 4 chamei a função MessageBox
, mas ela na verdade não existe: é uma macro, substituída pelo pré-processador pelas funções MessageBoxW
(mais comum) ou MessageBoxA
(caso a macro UNICODE
não esteja definida).
Ainda na linha 4 introduzi um conceito novo, de nullptr
ao invés de NULL
, aproveitando que o compilador utilizado é de C++. Acho melhor de digitar.
Nas linhas 5 e 6 (sim, não há o menor problema em colocar os outros parâmetros da função em outras linhas para facilitar a leitura) eu passo para a função o texto e o título, respectivamente. Impossível não notar o L
colado com as aspas duplas que abrem uma string em C não é mesmo? Ele serve para transformar a string subsequente em uma wide string (Unicode), que já estudamos. Este L
é necessário porque a função MessageBox
vai expandir, por padrão, para a MessageBoxW
(perceba o W
no final) que é a versão da MessageBox
que trabalha com strings Unicode. Também usamos o caractere de nova linha duas vezes para dividir a mensagem em três linhas, sendo a segunda vazia.
Na linha 7 eu utilizo uma combinação de duas flags: MB_OK
e MB_ICONINFORMATION
. Esta última configura este ícone de um "i" numa bolinha azul.
Agora vamos criar um programa um pouco maior afim de estudar mais conceitos da API do Windows. Compila aí:
Vamos analisar os conceitos novos aqui, como fizemos com o programa anterior:
Na linha 5 declaro uma variável do tipo LPCWSTR
. A diferença de LPCSTR
, que já estudamos, é este "W", de wide, para definir uma string Unicode.
A linha 7 declara uma variável ret
do tipo int
e já a inicializa com o retorno da chamada à MessageBoxW
.
Nas linhas 12 e 15 comparo o conteúdo da variável res
, que detém o retorno da chamada à MessageBoxW
. Se for igual a IDYES
, novamente uma macro, mostra uma determinada mensagem. Se for igual a IDNO
, mostra outra.
Em relação às strings, há três maneiras de se programar com a Windows API: ASCII (CHAR)
, UNICODE (WCHAR
) ou em compatibilidade (TCHAR
), que expandirá para CHAR
ou WCHAR
, caso a macro UNICODE
esteja definida. Atualmente, é recomendado utilizar WCHAR
e textos L"assim"
.
A tabela abaixo ajuda na compreensão:
Uma API (Application Programming Interface) é uma interface para programar uma aplicação. No caso da Windows API, esta consiste num conjunto de funções expostas para serem usadas por aplicativos rodando em user mode.
Para o escopo deste livro, vamos cobrir uma pequena parte da Win32 API (outro nome para a Windows API), pois o assunto é bastante extenso.
Considere o seguinte programa em C:
A função MessageBox() está definida em windows.h. Quando compilado, o código acima gera um executável dependente da USER32.DLL
(além de outras bibliotecas, dependendo de certas opções de compilação), que provê a versão compilada de tal função. A documentação desta e de outras funções da Win32 está disponível no site da Microsoft. Copiamos seu protótipo abaixo para explicar seus parâmetros:
A Microsoft criou definições de anotações e novos tipos na linguagem C que precisam ser explicadas para o entendimento dos protótipos das funções de sua API. Para entender o protótipo da função MessageBox, é preciso conhecer o significado dos seguintes termos:
Um handle é um número que identifica um objeto (arquivo, chave de registro, diretório, etc) aberto usado por um processo. É um conceito similar ao file descriptor em ambiente Unix/Linux. Handles só são acessíveis diretamente em kernel mode, por isso os programas interagem com eles através de funções da API do Windows. Por exemplo, a função CreateFile() retorna um handle válido em caso de execução com sucesso, enquanto a função CloseHandle() o fecha.
Agora vamos explicar os parâmetros da função MessageBox:
Um handle que identifica qual janela é dona da caixa de mensagem. Isso serve para atrelar uma mensagem a uma certa janela (e impedi-la de ser fechada antes da caixa de mensagem, por exemplo). Como é opcional, este parâmetro pode ser NULL, o que faz com que a caixa de mensagem não possua uma janela dona.
Um ponteiro para um texto (uma string) que será exibido na caixa de mensagem. Se for NULL, a mensagem não terá um conteúdo, mas ainda assim aparecerá.
Um ponteiro para o texto que será o título da caixa de mensagem. Se for NULL, a caixa de mensagem terá o título padrão "Error".
Configura o tipo de caixa de mensagem. É um número inteiro que pode ser definido por macros para cada flag, definida na documentação da função. Se passada a macro MB_OKCANCEL (0x00000001L), por exemplo, faz com que a caixa de mensagem tenha dois botões: OK e Cancelar. Se passada a macro MB_ICONEXCLAMATION (0x00000030L), a janela terá um ícone de exclamação. Se quiséssemos combinar as duas características, precisaríamos passar as duas flags utilizando uma operação OU entre elas, assim:
Como macros e cálculos assim são resolvidos em C numa etapa conhecida por pré-compilação, o resultado da operação OU entre 1 e 0x30 será substituído neste código, antes de ser compilado, ficando assim:
Dizer que um parâmetro é opcional não quer dizer que você não precise passá-lo ao chamar a função, mas sim que ele pode ser NULL, ou 0, dependendo do que a documentação da função diz. Como o Visual Studio é um compilador de C++, você também pode usar nullptr.
Veremos agora algumas funções da Windows API para funções básicas, mas você encontrará informações sobre outras rotinas no apêdice Funções da API do Windows.
Processo é um objeto que representa uma instância de um executável rodando. No Windows, processos não rodam. Quem roda mesmo são as threads de um processo.
Um processo possui um PID (Process IDentificator), uma tabela de handles abertos (será explicado no capítulo sobre a Windows API), um espaço de endereçamento virtual, e outras informações associadas a ele.
Para ver os processos ativos no seu sistema Windows neste momento, você pode usar o Gerenciador de Tarefas (aba "Detalhes") ou o comando tasklist
:
Na imagem anterior, a coluna Image Name define o nome do arquivo executável (o arquivo no disco) associado ao processo. Perceba que há vários processos do svchost.exe
por exemplo. É normal.
2
IMAGE_FILE_EXECUTABLE_IMAGE
Obrigatório para arquivos executáveis
9
IMAGE_FILE_32BIT_MACHINE
Arquivo de 32-bits
14
IMAGE_FILE_DLL
O arquivo é uma DLL
LPSTR
char*
LPCSTR
const char*
LPWSTR
wchar_t*
LPCWSTR
const wchar_t*
LPTSTR
char or wchar_t dependendo da UNICODE
LPCTSTR
const char or const wchar_t dependendo da UNICODE
[in]
Define que o parâmetro é de entrada
[optional]
O parâmetro é opcional (pode ser NULL, ou 0 normalmente)
HWND
Um handle (identificador) da janela
LPCTSTR
Long Pointer to a Const TCHAR STRing
UINT
unsigned int ou DWORD (32-bits ou 4 bytes)
Chegamos no capítulo onde a engenharia reversa de fato começa. Aqui vamos estudar a depuração, ou debugging em inglês. O conceito, como o nome em sugere, é buscar identificar erros (bugs) num programa, a fim de corrigi-los. No entanto, os debuggers - como são chamados os softwares que servem a este fim - servem para muito mais que isso.
Neste livro usaremos o x64dbg por ser um debugger de código aberto, gratuito e muito atualizado para Windows.
Na próxima seção apresentaremos como baixar e configurar o x64dbg. Também utilizaremos um binário de exemplo durante o livro, que é um desafio disponível em menteb.in/analyseme00. Com ele estudaremos os conceitos de engenharia reversa que precisamos para criar um fundamento sólido para avançar nesta área.
O registro do Windows é um repositório de dados utilizado normalmente para armazenar configurações de programas instalados no sistema operacional e do próprio sistema, mas na real ele não faz distinção do que pode ser armazenado lá, já que suporta vários tipos de dados, incluindo textos, números e dados binários.
A estrutura do registro é parecida com um sistema de arquivos. As chaves são como as pastas e os valores são como os arquivos. Os dados de um valor são como o conteúdo dos arquivos.
O registro tem algumas chaves especiais em sua raiz. São elas:
As quatro primeiras chaves são as mais comuns. Dentro delas, é possível criar e ler subchaves e manipular seus valores. Vamos ver como fazer isso estudando a função RegCreateKey
.
Embora a Microsoft recomende utilizar a versão mais nova dessa função chamada RegCreateKeyEx
, muitos programas ainda utilizam a versão mais antiga, que estudaremos agora. Eis o protótipo da versão ASCII desta função:
Agora vamos aos parâmetros:
Uma das chaves raíz, por exemplo: HKEY_CURRENT_USER
ou HKEY_LOCAL_MACHINE
(para essa o usuário rodando o programa precisa ter privilégios administrativos).
A subchave desejada, por exemplo, se o parâmetro hKey
HKEY_LOCAL_MACHINE
e lpSubKey
é Software\Microsoft\Windows\
, o caminho completo utilizado pela função será HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\
.
Alguns textos abreviam essas chaves raíz com as letras iniciais de seu nome. Por exemplo, HKCU
para HKEY_CURRENT_USER
, HKCR
para HKEY_CLASSES_ROOT
e HKLM
para HKEY_LOCAL_MACHINE
. Tais abreviaçõe são válidas para acesso ao registro através de programas como o Registry Editor (regedit.exe), mas não são válidas para código em C.
Um ponteiro para uma váriável do tipo HKEY
, previamente alocada, pois é aqui que a função vai escrever o handle da chave criada ou aberta por ela.
Colocando tudo junto, se quisermos criar a sub-chave HKCU\Software\Mente Binária
, basta fazer:
Perceba que, assim como um handle para arquivo, o handle para chave também precisa ser fechado depois de seu uso.
Como o nome sugere, essa função configura um valor em uma chave. Seu protótipo é:
Já sabemos o que são os parâmetros hKey
e lpSubKey
. Nos restam então os seguintes:
Um ponteiro para uma string contendo o nome do valor. Caso seja NULL
ou aponte para uma string vazia, o valor padrão da chave é considerado.
O tipo do valor. Pode ser um dos seguintes:
Os dados do valor, que deve ser casar com o tipo configurado no parâmetro dwType
.
O tamanho dos dadoos do valor.
O código abaixo cria uma chave HKCU\Software\Mente Binária
, configura um valor "Habilitado" do tipo REG_DWORD
com o dado 1
e um valor "Website" do tipo REG_SZ
com o dado textual "https://menteb.in":
Os processadores possuem uma área física em seus chips para armazenamento de dados (de fato, somente números, já que é só isso que existe neste mundo!) chamadas de registradores, justamente porque registram (salvam) um número por um tempo, mesmo este sendo não determinado.
Os registradores possuem normalmente o tamanho da palavra do processador, logo, se este é um processador de 32-bits, seus registradores possuem este tamanho também.
Um registrador de uso geral, também chamado de GPR (General Purpose Register) serve para armazenar temporariamente qualquer tipo de dado, para qualquer função.
Existem 8 registradores de uso geral na arquitetura Intel x86. Apesar de poderem ser utilizados para qualquer coisa, como seu nome sugere, a seguinte convenção é normalmente respeitada:
EAX
Accumulator
Usado em operações aritiméticas
EBX
Base
Ponteiro para dados
ECX
Counter
Contador em repetições
EDX
Data
Usado em operações de E/S
ESI
Source Index
Ponteiro para uma string de origem
EDI
Destination Index
Ponteiro para uma string de destino
ESP
Stack Pointer
Ponteiro para o topo da pilha
EBP
Base Pointer
Ponteiro para a base do stack frame
Para fixar o assunto, é importante trabalhar um pouco. Vamos escrever o seguinte programa em Assembly no Linux ou macOS:
Salve-o como ou.s
e para compilar, vamos instalar o Netwide Assembler (NASM), que para o nosso caso é útil por ser multiplataforma:
Confira como ficou o código compilado no arquivo objeto com a ferramenta objdump, do pacote binutils:
Perceba os opcodes e argumentos idênticos aos exemplificados na introdução deste capítulo.
O NASM compilou corretamente a linguagem Assembly para código de máquina. O objdump mostrou tanto o código de máquina quanto o equivalente em Assembly, processo conhecido como desmontagem ou disassembly.
Agora cabe à você fazer mais alguns testes com outros registradores de uso geral. Um detalhe importante é que os primeiros quatro registradores de uso geral podem ter sua parte baixa manipulada diretamente. Por exemplo, é possível trabalhar com o registrador de 16 bits AX, a parte baixa de EAX. Já o AX pode ter tanto sua parte alta (AH) quanto sua parte baixa (AL) manipulada diretamente também, onde ambas possuem 8 bits de tamanho. O mesmo vale para EBX, ECX e EDX.
Para entender, analise as seguintes instruções:
Após a execução das instruções acima, EAX conterá o valor 0xaabbeeff, já que somente sua parte baixa foi modificada pela segunda instrução. Agora analise o seguinte trecho:
Após a execução das quatro instruções acima, EAX volta para o valor 0xaabbccdd.
A imagem a seguir ilustra os ditos vários registradores dentro dos quatro primeiros registradores de uso geral.
Os registradores EBP, ESI, EDI e ESP também podem ser utilizados como registradores de 16-bits BP, SI, DI e SP, respectivamente. Note porém que estes últimos não são sub-divididos em partes alta (high) e baixa (low).
Existe um registrador chamado de EIP (Extended Instruction Pointer), também de PC (Program Counter) em algumas literaturas que aponta para a próxima instrução a ser executada. Não é possível copiar um valor literal para este registrador. Portanto, uma instrução mov eip, 0x401000
não é válida.
Outra propriedade importante deste registrador é que ele é incrementado com o número de bytes da última instrução executada. Para fixar, analise o exemplo do disasembly a seguir, gerado com a ferramenta objdump:
Quando a primeira instrução do trecho acima estiver prestes à ser executada, o registrador EIP conterá o valor 0x8049000. Após a execução desta primeira instrução MOV, o EIP será incrementado em 5 unidades, já que tal instrução possui 5 bytes. Por isso o objdump já mostra o endereço correto da instrução seguinte. Perceba, no entanto, que a instrução no endereço 804900a possui apenas 2 bytes, o que vai fazer com o que o registrador EIP seja incrementado em 2 unidades para apontar para a próxima instrução MOV, no endereço 804900c.
Estes registradores armazenam o que chamamos de seletores, ponteiros que identificam segmentos na memória, essenciais para operação em modo real. Em modo protegido, que é o modo de operação do processador que os sistemas operacionais modernos utilizam, a função de cada registrador de segmento fica a cargo do SO. Abaixo a lista dos registradores de segmento e sua função em modo protegido:
CS
Code Segment
DS
Data Segment
SS
Stack Segment
ES
Extra Data Segment
FS
Data Segment (no Windows em x86, aponta para o TEB (Thread Environment Block) da thread atual do processo em execução)
GS
Data Segment
Não entraremos em mais detalhes sobre estes registradores por fugirem do escopo deste livro.
O registrador de flags EFLAGS é um registrador de 32-bits usado para flags de estado, de controle e de sistema.
Flag é um termo genérico para um dado, normalmente "verdadeiro ou falso". Dizemos que uma flag está setada quando seu valor é verdadeiro, ou seja, é igual a 1.
Existem 10 flags de sistema, uma de controle e 6 de estado. As flags de estado são utilizadas pelo processador para reportar o estado da última operação (pense numa comparação, por exemplo - você pede para o processador comparar dois valores e a resposta vem através de uma flag de estado). As mais comuns são:
0
Carry
CF
Setada quando o resultado estourou o limite do tamanho do dado. É o famoso "vai-um" na matemática para números sem sinal (unsigned).
6
Zero
ZF
Setada quando o resultado de uma operação é zero. Do contrário, é zerada. Muito usada em comparações.
7
Sign
SF
Setada de acordo com o MSB (Most Significant Bit) do resultado, que é justamente o bit que define se um inteiro com sinal é positivo (0) ou negativo (1), conforme visto na seção Números negativos.
11
Overflow
OF
Estouro para números com sinal.
Além das outras flags, há ainda os registradores da FPU (Float Point Unit), de debug, de controle, XMM (parte da extensão SSE), MMX, 3DNow!, MSR (Model-Specific Registers), e possivelmente outros que não abordaremos neste livro em prol da brevidade.
Agora que já reunimos bastante informação sobre os registradores, é hora de treinarmos um pouco com as instruções básicas do Assembly.
Apesar de não estudarmos todos os aspectos da linguagem Assembly, alguns assuntos são de extrema importância, mesmo para os fundamentos da engenharia reversa de software. Um deles é como funcionam as funções criadas em um programa e suas chamadas, que discutiremos agora.
Basicamente, uma função é um bloco de código reutilizável num programa. Tal bloco faz-se útil quando um determinado conjunto de instruções precisa ser invocado em vários pontos do programa. Por exemplo, suponha um programa em Python que precise converter a temperatura de Fahrenheit para Celsius várias vezes no decorrer de seu código. Ele pode ser escrito assim:
O programa funciona e a saída é a esperada:
No entanto, é pouco prático, pois repetimos o mesmo código várias vezes. Além disso, uma versão compilada fica maior em bytes. Toda esta repetição também prejudica a manutenção do código, pois se o programador precisar fazer uma alteração no cálculo, vai ter que alterar em todos eles. É aí que entram as funções. Analise a seguinte versão do mesmo programa:
A saída é a mesma, mas agora o programa está utilizando uma função, onde o cálculo só foi definido uma única vez e toda vez que for necessário, o programa a chama.
Uma função normalmente tem:
Argumentos, também chamados de parâmetros, que são os dados que a função recebe, necessários para cumprir seu propósito.
Retorno, que é o resultado da conclusão do seu propósito, seja bem sucedida ou não.
Um nome (na visão do programador) ou um endereço de memória (na visão do processador).
Agora cabe a nós estudar como isso tudo funciona em baixo nível.
Nos primórdios da computação as funções eram chamadas de procedimentos (procedures). Em algumas linguagens de programação, no entanto, possuem tanto funções quanto procedimentos. Estes últimos são "funções que não retornam nada". Já no paradigma da programação orientada a objetos (POO), as funções de uma classe são chamadas de métodos.
Em baixo nível, uma função é implementada basicamente num bloco que não será executado até ser chamado por uma instrução CALL. Ao final de uma instrução, encontramos normalmente a instrução RET. Vamos analisar uma função simples de soma para entender:
Olha como ela fica compilada no Linux em 32-bits:
Removi partes do código intencionalmente, pois o objetivo neste momento é apresentar as instruções que implementam as chamadas de função. Por hora, você só precisa entender que a instrução CALL (no endereço 0x804842d em nosso exemplo) chama a função soma() em 0x0804840b e a instrução RET (em 0x8048417) retorna para a instrução imediatamente após a CALL (0x8048432), para que a execução continue.
A memória RAM para um processo é dividida em áreas com diferentes propósitos. Uma delas é a pilha, ou stack.
Essa área de memória funciona de forma que o que é colocado lá fique no topo e o último dado colocado na pilha seja o primeiro a ser retirado, como uma pilha de pratos ou de cartas de baralho mesmo. Esse método é conhecido por LIFO (Last In First Out).
Seu principal uso é no uso de funções, tanto para passagem de argumentos (parâmetros da função) quanto para alocação de variáveis locais da função (que só existem enquanto a função executa).
Na arquitetura IA-32, a pilha é alinhada em 4 bytes (32-bits). Por consequência, todos os seus endereços também o são. Logo, se novos dados são colocados na pilha (empilhados), o endereço do topo é decrementado em 4 unidades. Se um dado for desempilhado, o endereço do topo é incrementado em 4 unidades. Perceba a lógica invertida, porque a pilha começa num endereço alto e cresce em direção a endereços menores.
Existem dois registradores diretamente associados com a pilha de memória alocada para um processo. São eles:
O ESP, que aponta para o topo da pilha.
O EBP, que aponta para a base do stack frame.
Veremos agora as instruções de manipulação de pilha. A primeira é a instrução PUSH (do inglês "empurrar") que, como o nome sugere, empilha um dado. Na forma abaixo, essa instrução faz com que o processador copie o conteúdo do registrador EAX para o topo da pilha:
Também é possível empilhar um valor literal. Por exemplo, supondo que o programa coloque o valor um na pilha:
Além de copiar o valor proposto para o topo da pilha, a instrução PUSH decrementa o registrador ESP em 4 unidades, conforme já explicado o motivo. Sempre.
Sua instrução antagônica é a POP, que só precisa de um registrador de destino para copiar lá o valor que está no topo da pilha. Por exemplo:
Seja lá o que estiver no topo da pilha, será copiado para o registrador EDX. Além disso, o registrador ESP será incrementado em 4 unidades. Sempre.
Temos também a instrução CALL, que faz duas coisas:
Coloca o endereço da próxima instrução na pilha de memória (no caso do exemplo, 0x8048432).
Coloca o seu parâmetro, ou seja, o endereço da função a ser chamada, no registrador EIP (no exemplo é o endereço 0x804840b).
Por conta dessa atualização do EIP, o fluxo é desviado para o endereço da função chamada. A ideia de colocar o endereço da próxima instrução na pilha é para o processador saber para onde tem que voltar quando a função terminar. E, falando em terminar, a estrela do fim da festa é a instrução RET (de RETURN). Ela faz uma única coisa:
Retira um valor do topo da pilha e coloca no EIP.
Isso faz com que o fluxo de execução do programa volte para a instrução imediatamente após a CALL, que chamou a função.
Vamos agora analisar a pilha de memória num exemplo com a função MessageBox, da API do Windows:
Perceba que quatro parâmetros são empilhados antes da chamada à MessageBoxA (versão da função MessageBox que recebe strings ASCII, por isso o sufixo A).
Os parâmetros são empilhados na ordem inversa.
Já estudamos o protótipo desta função no capítulo que apresenta a Windows API e por isso sabemos que o 0x31, empilhado em 00401516, é o parâmetro uType
e, se o decompormos, veremos que 0x31 é um OU entre 0x30 (MB_ICONEXCLAMATION) e 0x1 (MB_OKCANCEL).
O próximo parâmetro é o número 404000, um ponteiro para a string "Johnny", que é o título da mensagem. Depois vem o ponteiro para o texto da mensagem e por fim o zero (NULL), empilhado em 00401522, que é o handle.
O resultado é apresentado a seguir:
É importante perceber que, após serem compreendidos, podemos controlar estes parâmetros e alterar a execução do programa conforme quisermos. Este é o assunto do próximo capítulo, sobre depuração.
Assembly é, por si só, um assunto extenso e bastante atrelado à arquitetura e ao sistema operacional no qual se está trabalhando. Este capítulo apresentou uma introdução ao Assembly Intel x86 e considerou o Windows como plataforma. Dois bons recursos de Assembly, que tomam o Linux como sistema base, são os livros gratuito Aprendendo Assembly, do Felipe Silva e Linguagem Assembly para i386 e x86-64, do Frederico Pissara.
Na sua máquina Windows, baixe o snapshot mais recente do x64dbg. É um arquivo .zip chamado snapshot_YYYY-MM-DD_HH-MM.zip que vai variar dependendo da data e hora do release (quando o software é liberado) pelos autores do projeto.
Ao descompactar o arquivo .zip, execute o arquivo x96dbg.exe dentro do diretório release. Esse nome deve-se ao fato de que o x64dbg tem suporte tanto a 32 quanto a 64-bits, então o autor resolveu somar 32+64 e nomear o binário assim.
O x96dbg.exe é o launcher do x64dbg e tem três botões. Escolha Install e responda "Sim" para todas as perguntas.
À esta altura você já deve ter o atalho x32dbg na área de trabalho. Ao clicar, você verá a tela inicial do debugger. A ideia é que você depure binários (.exe, .dll, etc) portanto, vamos abrir o AnalyseMe-00.exe e seguir.
Vá em Options -> Preferences e desmarque a caixa System breakpoint. Isso vai fazer com que o debugger pare direto no entrypoint de um programa ao abrirmos.
Clique em Save.
Existem muitas outras opções de configuração que você pode experimentar, mas para o momento isso basta.
Descompacte e abra o AnalyseMe-00.exe
no x32dbg clicando em File -> Open. Você deverá ver uma tela como esta:
A aba CPU é sem dúvida a mais utilizada no processo de debugging, por isso, fizemos questão de nomear algumas de suas áreas, que descreveremos agora.
Nesta região são exibidos os endereços (VA's), os opcodes e argumentos em bytes de cada instrução, seu disassembly (ou seja, o que significam em Assembly) e alguns comentários úteis na quarta coluna, como a palavra EntryPoint, na primeira instrução do programa a ser executada (em 401000 no nosso exemplo).
Tomei a liberdade de nomear essa seção de Helper, porque de fato ela ajuda. Por exemplo, quando alguma instrução faz referência a um dado em memória ou em um registrador, ela já mostra que dado é este. Assim você não precisa ir buscar. É basicamente um economizador de tempo. Supondo que o debugger esteja parado na instrução push esi
, no Helper aparecerá o valor do registrador ESI.
O dump é um visualizador que você pode usar para inspecionar bytes em qualquer endereço. Por padrão há cinco abas de dump, mas você pode adicionar mais se precisar.
Como o nome sugere, mostra o valor de cada registrador do processador.
Mostra a pilha de memória, onde o endereço com fundo em preto indica o topo da pilha.
Na próxima seção, iremos depurar o binário de exemplo e devemos nos atentar às informações exibidas em cada uma das regiões da tela do debugger, acima apresentadas.
Agora que você já sabe como um binário PE é construído, está na hora de entender como o código contido em suas seções de código de fato executa. Acontece que um processador é programado em sua fábrica para entender determinadas sequências de bytes como código e executar alguma operação. Para entender isso, vamos fazer uma analogia com um componente muito mais simples que um processador, um circuito integrado (popularmente chamado de chip).
Um circuito integrado (CI) bastante conhecido no mundo da eletrônica é o 7400 que tem o seguinte diagrama esquemático:
Se você já estudou portas lógicas, vai perceber que este CI tem 4 portas NAND (AND com saída negada). Cada porta possui duas entradas e uma saída, cada uma delas conectada a seu respectivo pino/perna.
Admitindo duas entradas (A e B) e uma saída S, a tabela verdade de cada uma das portas deste CI é a seguinte:
0
0
0
1
1
0
0
1
0
1
0
1
1
1
1
0
Podemos dizer então que este CI faz uma única operação sempre, com as entradas de dados que recebe.
Se seu projeto precisasse também de portas OR, XOR, AND, etc você precisaria comprar outros circuitos integrados, certo? Bem, uma solução inteligente seria utilizar um chip que fosse programável. Dessa forma, você o configuraria, via software, para atuar em certos pinos como porta NAND, outros como porta OR e por aí vai, de acordo com sua necessidade. Aumentando ainda mais a complexidade, temos os microcontroladores, que podem ser programados em linguagens de alto nível, contando com recursos como repetições, condicionais, e tudo que uma linguagem de programação completa oferece. No entanto, estes chips requerem uma reprogramação a cada mudança no projeto, assim como se faz com o Arduino hoje em dia.
Neste sentido um microprocessador, ou simplesmente processador é muito mais poderoso. Ao invés de o usuário gravar um programa nele, o próprio fabricante já o faz, de modo que este microprograma entenda diferentes instruções para realizar diferentes operações de muito alto nível (se comparadas às simples operações booleanas). Sua entrada de dados também é muito mais flexível: ao invés de entradas binárias, um processador pode receber números bem maiores. O antigo Intel 8088 já possuía um barramento de 8 bits, por exemplo.
Isso significa que se um processador receber em seu barramento um conjunto de bytes específico, sabe que vai precisar executar uma operação específica. À estes bytes possíveis damos o nome de opcodes. Ao conjunto dos opcodes + operandos damos o nome de instrução.
Supondo que queiramos então fazer uma operação OR entre os valores 0x20 e 0x18 utilizando um processador x86. Na documentação deste processador, constam as seguintes informações:
Ao receber o opcode 0xb8, os próximos quatro bytes serão um número salvo em memória (similar àquela memória M+ das calculadoras), acessível através do byte identificador 0xc8.
Ao receber o opcode 0x83, seguido de um byte 0xc8 (que identifica a memória), seguido de mais um byte, o número armazenado na memória identificada pelo segundo byte vai sofrer uma operação OR com este terceiro byte.
Precisaríamos então utilizar as duas instruções, da seguinte forma:
Na primeira, que tem um total de 5 bytes, o opcode 0xb8 é utilizado para colocar o número de 32-bits (4 bytes) na sequência em memória. Como nosso número desejado possui somente 1 byte, preenchemos os outros três com zero, respeitando o endianess.
A segunda instrução tem 3 bytes sendo que o primeiro é o opcode dela (OR), o segundo é o identificador da área de memória e o terceiro é o nosso operando 0x18, que deve sofrer o OR com o valor na área de memória indicada por C8.
Temos que concordar que criar um programa assim não é nada fácil. Para resolver este problema foi criada uma linguagem de programação, completamente presa à arquitetura do processador (seus opcodes, suas instruções), chamada Assembly. Com ela, os programadores poderiam escrever o programa acima praticamente em inglês:
De posse de um compilador de Assembly, chamado na época de assembler, o resultado da compilação do código-fonte acima é justamente um arquivo (objeto) que contém os opcodes e argumentos corretos para o processador alvo, onde o programa vai rodar.
Agora você sabe o motivo pelo qual um programa compilado não é compatível entre diferentes processadores, de diferentes arquiteturas. Como estes possuem instruções diferentes e opcodes diferentes, não há mesmo compatibilidade.
Perceba que Assembly é uma linguagem legível para humanos, diferente da linguagem de máquina que não passa de uma "tripa de bytes". Os comandos da linguagem Assembly são chamados de mnemônicos. No exemplo de código acima, utilizamos dois: o MOV e o OR. Estudaremos mais mnemônicos em breve.
Esses apêndices servem tanto de referência como de material de apoio para estudar assuntos que não foram abordados com profundeza neste livro.
Agora que já sabemos o básico do debugging e sabemos colocar breakpoints de software, vamos começar a manipular o programa da maneira que queremos.
Em geral, quando falamos de manipulação, falamos de alguma alteração no código do programa, para que este execute o que queremos, da forma como queremos.
Tomemos como exemplo o AnalyseMe-00 mesmo. Supondo que não sabemos o que o mesmo faz. Um bom início para a engenharia reversa é a busca por chamadas intermodulares (o botão com um celular e uma seta azul). Ao clicar nele, encontrará duas chamadas à função DeleteFileA da KERNEL32.DLL. Colocaremos um breakpoint em todas as chamadas à estas funções, bastando para isso dar um clique com o botão direito do mouse em uma delas e escolher a opção "Set breakpoint on all calls to DeleteFileA", como sugere a imagem abaixo:
Ao voltar à aba CPU e rodar o programa (F9), paramos aqui:
Esta função é bem simples. No endereço 00401105 há um PUSH que coloca o endereço 402020 na pilha, depois há a chamada da DeleteFileA em si.
O x64dbg já resolve a referência do endereço e, caso encontre uma string, exibe ao lado (na quarta coluna), como acontece com a string "C:\Windows\System32\cmd.exe". Ora, se este é o argumento passado para a função DeleteFile(), este é o caminho do arquivo que o programa AnalyseMe-00 pretende deletar.
O que a gente vai fazer é mudar esta string, mudando assim o programa que o AnalyseMe-00 tenta deletar. Para isso, clique com botão direito do mouse sobre a instrução PUSH e escolha "Follow in Dump -> 402020".
O endereço em questão é exibido no Dump 1. Outra opção seria ir no Dump 1, teclar Ctrl+G, digitar 402020 e clicar em OK.
Para alterar a string, você vai precisar selecionar todos os bytes desejados, pois o x64dbg não sabe exatamente onde começa e onde termina cada bloco de dados usado pelo programa. Supondo que queiramos alterar "cmd.exe" para "calc.exe", fazendo assim com que o programa tente excluir a calculadora do Windows. Para este caso, selecionamos o trecho e pressionamos Ctrl+E, que é o equivalente ao clicar com o botão direito sobre a seleção e escolher "Binary -> Edit".
Após fazer a alteração e clicar em OK, perceba que o Dump 1 agora destaca os bytes alterados em vermelho:
Ao seguir com a execução da chamada à DeleteFileA (F8), o programa tenta excluir o calc.exe ao invés de o cmd.exe. No entanto, como em versões modernas do Windows o conteúdo deste diretório é protegido, a função retorna zero (perceba o registrador EAX zerado), que no caso desta função, indica que houve falha, e as variáveis LastError
e LastStatus
são modificadas para refletir o que aconteceu.
Espero que com esta seção você entenda que, tendo o programa sob o controle de um debugger, é possível modificar praticamente tudo o que queremos. Podemos impedir que funções sejam chamadas, podemos chamar novas funções, alterar dados, modificar parâmetros, enfim, a lista é quase infinita. Na próxima seção vamos ver como salvar as alterações feitas.
E possível encontrar versões anteriores desta tabela, mas a mais usada é a seguinte:
Esta seção aborda não somente ferramentas utilizadas no livro, mas também outras que vale a pena citar na esperança que o leitor se sinta atraído a baixar, usar e tirar suas próprias conclusões em relação à eficiência delas.
Este tipo de ferramenta é útil para editar arquivos binários em geral, não somente executáveis, dumpar (copiar) conteúdo de trechos de arquivos, etc. Também é possível editar uma partição ou disco com bons editores hexadecimal a fim de recuperar arquivos, por exemplo.
Analisam estaticamente os binários, sem carregá-los. São úteis para uma primeira visão sobre um executável desconhecido.
Existem outros projetos como Limon, Detux, HaboMalHunter, mas na lista abaixo procurei deixar somente os que estão ativos.
Esta lista não inclui serviços de sandbox puramente comerciais.
Abaixo um comparativo onde dumpamos os primeiros 32 bytes de um binário /bin/ls
utilizando os visualizadores acima:
Ao observar a região que chamamos de disassembly, você verá 5 colunas onde a primeira exibe algumas informações e relações entre endereços. A segunda mostra os endereços em si. A terceira mostra os bytes do opcode e operandos das instruções. A quarta mostra os mnemônicos onde podemos ler Assembly e por fim, a quinta mostra alguns comentários, sejam estes gerados automaticamente pelo debugger ou adicionados pelo usuário.
Perceba que por padrão o debugger já destaca vários aspectos das instruções, na janela de disassembly. O nome mais comum para este destaque é sua versão em inglês highlight.
O highlight refere-se principalmente às cores, mas o debugger também dá altas dicas. Na instrução acima, perceba que a operação em si está em azul, enquanto o argumento está em verde.
O endereço para o qual o ponteiro de instrução aponta (EIP, embora o x64dbg chame-o genericamente de CIP, já que em x86-64 seu nome é RIP) é destacado com um fundo preto (na imagem anterior, vemos que é o endereço 401000).
Na coluna de comentários, temos os comentários automáticos em marrom.
É importante lembrar que no arquivo há somente os bytes referentes às instruções (terceira coluna). Todo o resto é interpretação do debugger para que a leitura seja mais intuitiva.
Neste primeiro momento, o debugger está parado e a próxima instrução a ser executada é justamente o que chamamos de OEP (Original EntryPoint).
O primeiro comando que aprenderemos é o Step over, que pode ser ativado por pelo menos três lugares:
Menu Debug -> Step over.
Botão Step over na barra de botões (é o sétimo botão).
Tecla de atalho F8.
Digitando um dos comandos a seguir na barra de comandos: StepOver/step/sto/st
Se você emitir este comando uma vez, verá que o debugger vai executar uma única instrução e parar. Na janela do disassembly, você vai perceber que o cursor (EIP) "pulou uma linha" e a instrução anterior foi executada. No caso de nosso binário de teste, é a instrução PUSH EBP. Após sua execução, perceba que o valor de EBP foi agora colocado no topo da pilha (observe a pilha, abaixo dos registradores).
Você pode seguir teclando F8 até alcançar a primeira instrução CALL em 401007, destacada por um fundo azul claro.
O comando Step over sobre uma CALL faz com que o debugger execute a rotina apontada pela instrução e "volte" para o endereço imediatamente após a CALL. Você não verá essa execução, pois o debbugger não a instrumentará. Caso queira observar o que foi executado "dentro" da CALL, é necessário utilizar o Step into (F7). Vamos fazer dois testes:
Com o EIP apontado para a CALL em 401007, tecle F8. Você verá que a execução simplesmente "passa para a linha abaixo da CALL". Isso quer dizer que ela foi executada, mas você "não viu no debugger".
Agora reinicie o programa no debugger (CTRL + F2), vá teclando F8 até chegar sobre a CALL novamente e tecle F7, que é o Step into. Perceba que o debugger agora "entrou" na CALL. Neste caso, você vai precisar teclar F8 mais três vezes até voltar ao fluxo de execução original, isso porque esta CALL só possui três instruções.
Um outro comando importante é o Run (F9). Ele simplesmente inicia a execução a partir do EIP de todas as instruções do programa. Se você emiti-lo com este binário, vai ver que a execução vai terminar, o que significa que o programa rodou até o final e saiu. Aí basta reiniciar o programa (CTRL + F2) para recomeçar nossos estudos. ;)
Na próxima seção, vamos entender os pontos de paradas, mais conhecidos como breakpoints.
Através de várias bibliotecas como kernel32.dll, user32.dll, advapi32.dll e ntdll.dll, só para citar algumas, a Windows API provê inúmeras funções para os programas em usermode chamarem. Criar uma lista com todas essas funções seria insano, mas destaco aqui algumas comumente encontradas em programas para Windows. Optei também por colocar o protótipo de algumas delas, para rápida referência, mas todas as informações sobre estas funções podem ser encontradas na documentação oficial. Basta buscar por um nome de função em seu buscador preferido que certamente o site da Microsoft com a documentação da função estará entre os primeiros.
Mesmo que você não sabia tudo sobre a função funciona, essa lista te ajudará a saber onde colocar breakpoints, de acordo com o seu caso. Por exemplo, caso suspeite que um programa está abrindo um arquivo, pode colocar um breakpoint nas funções CreateFileA
e CreateFileW
.
Vamos agora à lista de funções, separadas por categoria.
Utilizadas em técnicas básicas para evitar a depuração de aplicações.
Utilizadas para exibir janelas com mensagens.
Ver também: MessageBoxEx
.
Usadas para ler textos de caixas de texto.
Implementam algoritmos de criptografia como algoritmos de hash e criptografia simétrica e assimétrica.
Ver também: CryptDecrypt
, CryptGenKey
, e CryptImportKey
.
Configuram e consultam data e hora do sistema.
Ver também: SetSystemTime
e SetLocalTime
.
Pegam informações sobre num disco, pen drive, drives de rede mapeados, etc e também sobre seus volumes (partições).
Ver também: GetDiskFreeSpace
, GetDriveType
e GetVolumeInformationA
.
Manipulam arquivos e outros objetos.
CreateFileA
e CreateFileW
abrem para ler e/ou para escrever e também criam e até truncam (zeram) arquivos no disco. Também trabalham com outros objetos como pipes, diretórios e consoles.
Ver também: CopyFile
, CreateFileMapping
, DeleteFile
, GetFullPathName
, GetTempPath
, LoadLibrary
, MoveFile
, OpenFile
, OpenFileMapping
e OpenMutex
.
Funções que falam com a internet, normalmente via HTTP.
Ver também: HttpSendRequestA
, InternetOpenA
, InternetOpenW
, InternetOpenUrlW
e InternetReadFile
.
Habilitam ou desabilitam janelas ou itens dentro dela.
Gerenciam memória, alocando ou deselocando, configurando permissões, dentre outras operações.
Ver também: VirtualAllocEx
, VirtualFree
, VirtualLock
, VirtualProtect
e VirtualQuery
.
Manipulam processos e threads. Podem enumerar, alterar o estado, executar e mais.
Ver também: CreateRemoteThread
, CreateThread
, CreateToolhelp32Snapshot
, ExitProcess
, ExitThread
, Heap32First
, Heap32ListFirst
, Heap32ListNext
, Heap32Next
, Module32First
, Module32Next
, OpenProcess
, OpenProcessToken
, OpenThreadToken
, Process32First
, Process32Next
, ShellExecute
, TerminateProcess
, Toolhelp32ReadProcessMemory
, WriteProcessMemory
, ZwQueryInformationProcess
e ZwSetInformationThread
.
Criam e alteram chaves e valores no registro.
Ver também: RegCloseKey
, RegEnumKeyExA
, RegOpenKey
, RegOpenKeyEx
, RegQueryValueA
, RegSetValueW
, RegSetValueExA
e RegSetValueExW
.
Manipulam cadeias de texto.
Ver também: lstrcat
, lstrcpy
e lstrlen
.
Reuni aqui alguns exemplos de códigos em Assembly, úteis para a compreensão de trechos de binários quando fazemos engenharia reversa.
Outra versão:
Outra versão:
Parece bobo, mas "fazer nada" corretamente significa não alterar nenhuma flag, nem nenhum registrador. A instrução em Assembly Intel mais famosa para tal é a NOP (NO Operation):
Mas também é possível atingir o mesmo resultado com instruções como a XCHG (eXCHanGe). Por exemplo, se você trocar o valor do registrador EAX com ele mesmo, acaba por não fazer "nada":
Instruções que não fazem nada também podem ser utilizadas como padding necessário para o correto alinhamento das seções do binário em memória. Já vi o GCC utilizar XCHG AX, AX neste caso.
Shareware
Multiplataforma, bastante usado. Suporta templates e scripts. Alguns dizem que é o melhor atualmente.
Livre
Editor de interface de texto (TUI), similar ao HT Editor. Tem versão para DOS/Windows mas reina mesmo é no Linux.
Livre
Editor gráfico multiplataforma capaz de exibir o binário graficamente, além de suportar expressões regulares na busca.
Livre
Editor REPL genérico (não só para executáveis) com uma abordagem muito interessante. Escrevemos um artigo sobre.
Comercial
Editor incrível para Windows. Tem recursos muito legais como edição e alocação de memória, operações com dados (XOR, SHL, etc) e na versão paga tem suporte a vários algoritmos criptográficos, disassembly e mais.
Comercial
Pago, somente para Windows, antigo, mas muito bem feito.
Livre
Somente para macOS, com recursos legais como diff e data inspector.
Livre
Multiplataforma, interface de texto (TUI), suporta editar via SSH. É novo e tem futuro!
Shareware
Linux, macOS e Windows. Tem templates (chamados de "grammars"), scripting em Python e Lua e compara binários.
Livre
Baseado na ncurses, portanto de interface gráfica de texto. Tinha potencial, mas o desenvolvimento parou no final de 2022.
Comercial
Editor (e disassembler) muito poderoso, principalmente por conta de seus módulos HEM. Tem suporte à vários formatos e é muito usado por analistas de malware, mas é pago.
Livre
Interface gráfica baseada em texto, parecido com o Hiew. Tem muitos recursos, mas infelizmente o desenvolvimento parou em 2015. Hora de alguém fazer um fork!
Freeware
Bem bom. Possui recursos extras como geração de hashes e suporte a abrir discos.
Livre
Impressionante editor gráfico para Windows, macOS e Linux. Possui uma linguagem de patterns para aplicar no arquivo, exporta/importa patches e muito mais.
Livre
Nova proposta de um editor especificamente para engenharia reversa.
Comercial
Para macOS, desenvolvido por quem faz o Hexinator. Tem o mesmo nível de recursos.
Comercial
Editor com edições especiais focadas em forense, mas faz tudo que os outros fazem também. Somente para Windows.
Livre
Multiplataforma, recursos interessantes. Infelizmente o desenvolvimento parou em 2017.
Freeware
Sem muitos recursos, mas quebra um galho no Windows para uma edição rápida.
Livre
Detecta as capacidades de executáveis PE e shellcodes para Windows.
Freeware
Sendo parte do Explorer Suite, é na real um editor de PE. Com ele é possível adicionar imports, remover seções, etc.
Freeware
Detecta compilador, linker, packer e protectors em binários. Também edita os arquivos.
Freeware
Analisador de PE de linha de comando disponível no SDK do Visual Studio.
Freeware
Parser de linha de comando para Windows. Suporta arquivos NE, LX/LE, PE/PE32+, ELF/ELF64 (little-endian), Mach-O (little-endian), e TE.
Freeware
Detecta compilador, packer, protectors e edita os arquivos, além de suportar vários plugins loucos. Tem versão VIP mediante doação.
Livre
Mais um nacional pra uma primeira impressão de arquivos suspeitos, URLs e domínios. Checa também APKs.
Livre
Parte do GNU binutils, também analisa PE, além de ELF, a.out, etc.
Freeware
Analisador gráfico (Qt) multiplataforma que também detecta packers/protectors.
Livre
Analisador online muito legal!
Freeware
Analisador de PE padrão da indústria, com foco em malware. Tem versão Pro (paga).
Livre
Também parte do binutils, analisador de ELF.
Livre
Anteriormente chamado de pev, este é o nosso 💚 toolkit de ferramentas de linha de comando para análise de PE. Artigo introdutório aqui.
Freeware
Analisador e editor com suporte a plugins, assinaturas do antigo PEiD, editor de recursos e mais.
Livre
Binary File Descriptor é a biblioteca usada por programas como readelf e objdump. Tem suporte a muitos tipos de arquivos, incluindo PE e ELF, claro.
Livre
Nossa 💚 biblioteca multiplataforma para parsing de arquivos PE.
Livre
Biblioteca em C++ para PE usada pelo PE-Bear.
Livre
Famosa biblioteca em Python pra fazer qualquer coisa com arquivos PE.
Livre
Biblioteca em .Net para PE.
Livre
Biblioteca em Java para PE.
Livre
Biblioteca em Python para parsear binários ELF.
Livre
Assembler bem recente que já vem com vários exemplos de código. Windows e Linux.
Livre
Também chamado simplesmente de as, é o assembler do projeto GNU e provavelmente já está instalado no seu Linux!
Freeware
Atualmente já vem com o Visual Studio da Microsoft, mesmo na versão Community. Segue um tutorial de como compilar um "Hello, world".
Livre
Multiplataforma, suporte à sintaxe Intel e bem popular. Veja um tutorial de como compilar um "Hello, world" no Linux.
Livre
Multiplataforma, escrito com base no NASM pra ser um substituto mas acho que não vingou. hehe
Comercial
Novo disassembler que teoricamente compete com o IDA. Possui versão online gratuita mediante registro.
Livre
Disassembler livre lançado pela NSA. Temos um treinamento em vídeo sobre ele.
Comercial
Disassembler com foco em binários de Linux e macOS.
Comercial
Disassembler interativo padrão de mercado. Possui versão freeware.
Livre
Somente para .NET, descompila, debuga e (dis)assembla.
Livre
Debugger gráfico (Qt) com foco em binários ELF no Linux.
Livre
Super conhecido debugger pra Linux do projeto GNU. Tem uma versão não oficial pra Windows também. É modo texto, mas possui vários front-ends como GDBFrontend, dentre outros.
Livre
O GDB Enhanced Features extende o GDB com recursos para engenharia reversa.
Livre
Debugger que roda no nível do hypervisor, também conhecido como ring -1.
Freeware
Poderoso debugger, apesar de não mais mantido.
Livre
O Python Exploit Development Assistance também extende o GDB, assim como o GEF. A interface é um pouco diferente, no entanto.
Freeware
Debugger ring0/3, parte integrante do SDK do Windows.
Livre
Debugger user-mode para Windows com suporte a 32 e 64-bits.
Livre
Para .NET. Conhece vários ofuscadores e permite trabalhar genericamente também.
Livre
Para programas feito em Java. Suporta os ofuscadores mais comuns como Stinger e ZKM.
Livre
Descompilador multiplataforma para .NET.
Livre
Para binários compilados em Delphi.
Livre
Descompilador genérico com versão de linha de comando, gráfica e até web.
Livre
Descompilador para C/C++ feito pelo time do Avast. Inclui detector de compilador e packer, plugins para IDA e radare2.
Livre
Descompilador livre para C/C++. Pode ser usado como plugin no IDA, x64dbg, etc.
Livre
Descompilador para Java de linha de comando. O output é o código fonte e só.
Mista
Na verdade é uma IDE para programação em Java mas mesmo a versão Community possui descompilador e debugger para classes compiladas em Java.
Livre
Descompilador para Java com GUI.
Livre
Para Java, com suporte a recursos novos da linguagem. É modo texto mas há GUI's disponíveis documentadas no link.
Comercial
Descompilador para VB5/6 e disassembler para VB .NET.
Livre
Framework que combina emulação com instrumentação de binários. É possível emular programas de Windows e Linux, além de outros SOs, incluindo ring0.
Livre
Emulador feito pela FireEye, inicialmente para programas nativos de Windows (ring0) mas possui um crescente suporta à programas em usermode (ring3), emulando várias das funções da Windows API.
Livre
Framework para análise estática e simbólica de binários.
Livre
Framework para instrumentação dinâmica de binários.
Livre
Plataforma de análise dinâmica baseada no QEMU, que faz até replay de ações.
Livre
Suíte completa com debugger, disassembler e outras ferramentas para quase todo tipo de binário existente!
Freeware
Clássica ferramenta do conjunto SysInternals, que exibe muito mais que o Gerenciador de Tarefas comum.
Livre
Versão do Pavel Yosifovich que conta com algumas vantagens sobre o Process Explorer, como visualizar informações de processos protegidos (através de um driver), listar todas as threads abertas no Windows, todos os jobs, etc.
System Informer (antigo Process Hacker)
Livre
Talvez o melhor da categoria. Mostra os handles, objetos, processos, quem tá usando o que e muita mais.
Livre
Possui uma API bem simples e suporta binários ELF compilados para x86, x86-64, ARM e MIPS.
Livre
Projeto co-financiado pelo CERT da Polônia que usa o engine DRAKVUF para criar uma sandbox de Windows 7 ou Windows 10 sem agente no guest.
Livre
Sandbox específica para extração de configuração de malware.
Livre
Provavelmente a sandbox mais popular e utilizada.
Community
Apesar do nome, só suporta artefatos de Windows, mas é muito bom!
Community
Sandbox muito boa. Também é um portal de investigação inclusive com suporte à regras de Yara.
Community
Um dos primeiros serviços. Suporta ELF (x86 e x86-64), PE e documentos.
Livre
Clone nosso 💚, multiplataforma (funciona no Windows!) do hexdump que imita a saída do hd.
Livre
Multiplataforma, com saída colorida e recursos interessantes como seek negativo (a partir do fim do arquivo).
Gratuito
Visualizador web que faz análises estatísticas visuais muito úteis.
Livre
Multiplataforma, também com output colorido, exibição de borda, etc.
hexdump/hd
Livre
Padrão no BSD e Linux. Se chamado por "hd" exibe saída em hexa/ASCII.
od
Livre
Padrão no UNIX e Linux. O comando od -tx1 produz uma saída similar à do hd.
xxd
Livre
Vem com o vim. Uma saída similar à do hd é obtida com xdd -g1.
Livre
Se você pensa em parsear um formato desconhecido ou tá estudando PE/ELF, vale olhar este gerador de parser livre.
Community
Online. Requer um pequeno cadastro. Tem um recursos de identificar "genes" estaticamente de famílias de binários.
A primeira edição deste livro ainda está em progresso. Os capítulos de Assembly e Depuração foram finalizados. Uma seção sobre Processos foi adicionada no capítulo Execução de Programas. O capítulo sobre a Windows API foi espandido. Os exemplos em Python foram atualizados para Python 3. O apêndice de Ferramentas foi atualizado e no momento estou trabalhando nos exemplos e na revisão geral do livro para publicá-lo.
Coisas nas quais estou trabalhando:
Migração de todos os exemplos de shell para Python.
Remoção do Linux como dependência deste livro.
Atualização de ferramentas (editor hexadecimal, analisador de PE, etc).
Recriação das imagens utilizada no livro de forma padronizada.
Primeira versão pública do livro. A seção Tabela de Importações e o capítulo Execução de Programas ainda estão sendo desenvolvidos. Além disso, os capítulos Assembly e Depuração estão pendentes.
A esta altura você já pode imaginar a dificuldade que programadores enfrentam em trabalhar com diferentes codificações de texto, mas existe um esforço chamado de Unicode mantido pelo Unicode Consortium que compreende várias codificações, que estudaremos a seguir. Essas strings são comumente chamadas de wide strings (largas, numa tradução livre).
O padrão UTF (Unicode Transformation Format) de 8 bits foi desenhado originalmente por Ken Thompson (sim, o criador do Unix!) e Rob Pike para abranger todos os caracteres possíveis nos vários idiomas deste planeta.
Os primeiros 128 caracteres da tabela UTF-8 possuem exatamente os mesmos valores da tabela ASCII padrão e somente necessitam de 1 byte para serem representados. Chamamos estes números de codepoints. Os próximos caracteres utilizam 2 bytes e compreendem não só o alfabeto latino (como na ASCII estendida com codificação ISO-8859-1) mas também os caracteres gregos, árabes, hebraicos, dentre outros. Já para representar os caracteres de idiomas como o chinês e japonês, 3 bytes são necessários. Por fim, há os caracteres de antigos manuscritos, símbolos matemáticos e até emojis, que utilizam 4 bytes.
Concluímos que os caracteres UTF-8 variam de 1 a 4 bytes. Sendo assim, como ficaria o texto "mentebinária" numa sequência de bytes? Podemos ver novamente com o Python, mas dessa vez ao invés declarar um objeto do tipo bytes
com aquele prefixo b
, vamos converter um tipo str
para bytes
utilizando a função encode()
. Isso é necessário porque queremos ver uma string UTF-8 e não ASCII:
Como dito antes, os codepoints da tabela ASCII são os mesmos em UTF-8, mas o caractere 'á' (que não existe em ASCII puro) utiliza 2 bytes (no caso, C3 A1) para ser representado. Esta é uma string UTF-8 válida. Dizemos que seu tamanho é 11 porque ela contém 11 caracteres, mas em bytes seu tamanho é 12.
Também conhecido por UCS-2, este tipo de codificação é frequentemente encontrado em programas compilados para Windows, incluindo os escritos em .NET. É de extrema importância que você o conheça bem.
Representados em UTF-16, os caracteres da tabela ASCII possuem 2 bytes de tamanho onde o primeiro byte é o mesmo da tabela ASCII e o segundo é um zero. Por exemplo, para se escrever "A" em UTF-16, faríamos: 41 00. Vamos entender melhor com a ajuda do Python.
Primeiro, exibimos os bytes em hexa equivalentes de cada caractere da string:
Até aí, nenhuma novidade, mas vamos ver como essa string seria codificada em UTF-16:
Duas coisas aconteceram nesta conversão: a primeira é que uma sequência de dois bytes, FF FE, foi colocada no início da string. Esta sequência é chamada de Byte Order Mark (BOM) ou Marca de Ordem de Byte, em português. A segunda coisa é que os bytes foram sucedidos por zeros. De fato, a BOM diz se os bytes da string serão sucedidos (FF FE) ou precedidos (FE FF) por zeros quando necessário, mas também é possível utilizar uma variação da codificação UTF-16 que é a UTF-16-LE (Little Endian), onde os bytes são sucedidos por zeros, mas não há o uso de BOM:
A codificação UTF-16-LE (lembre-se: sem BOM) é a utilizada pelo Visual Studio no Windows quando tipos WCHAR
são usados, como nos argumentos das funções MessageBoxW()
e CreateFileW()
. Também é a codificação padrão para programas em .NET. Isso é importante de saber pois se você precisar alterar uma string UTF-16-LE durante a engenharia reversa, vai ter que respeitar essas regras.
Além da UTF-16-LE, temos a UTF-16-BE (Big Endian), onde os bytes são precedidos com zeros:
Além disso, é importante ressaltar que em strings UTF-16 também há a possibilidade de caracteres de quatro bytes. Por exemplo, um emoji:
Os números (codepoints) utilizados pela ISO-8859-1 para seus caracteres são também os números utilizados em strings UTF-16. No Windows, o padrão é o UTF-16-LE. Para entender como isso funciona, observe primeiro os bytes da string "binária" na codificação ISO-8859-1:
Perceba que o byte referente ao "á" é o E1. Até aí nenhuma novidadade. Sabemos que é uma string ASCII estendida que usa a tabela ISO-8859-1, também conhecida por Latin-1. Agora, vejamos como ela fica em UTF-16-LE:
Nesse caso, "binária" é uma string UTF-16-LE (cada caractere sucedido por zeros), sem BOM. Os bytes dos caracteres em si coincidem com os da ISO-8859-1. Doido né? Mas vamos em frente!
Perceba que o "á" em UTF-8 é C3 A1, mas em UTF-16 é E1 (precedido ou sucedido por zero), assim como em ISO-8859-1.
Sendo pouco utilizado, este padrão utiliza 4 bytes para cada caractere. Vamos ver como fica a string "mb" em UTF-32 com BOM:
Perceba o BOM de quatro bytes ao invés de dois.
Agora em UTF-32-LE:
E por fim em UTF-32-BE:
É importante ressaltar que simplesmente dizer que uma string é Unicode não diz exatamente qual codificação ela está utilizando, fato que normalmente depende do sistema operacional, da pessoa que programou, do compilador, etc. Por exemplo, um programa feito em C no Windows e compilado com Visual Studio tem as wide strings em UTF-16 normalmente. Já no Linux, o tamanho do tipo wchar_t é de 32 bits, resultando em strings UTF-32.
Há muito mais sobre codificação de texto para ser dito, mas isso foge ao escopo deste livro. Se o leitor desejar se aprofundar, basta consultar a documentação oficial dos grupos que especificam estes padrões. No entanto, cabe ressaltar que para a engenharia reversa, a prática de compilar programas e buscar como as strings são codificadas é a melhor escola.
A Crash Course in Everything Cryptographic - https://medium.com/dataseries/a-crash-course-in-everything-cryptographic-50daa0fda482 - Leo Whitehead, 2019.
Linguagem compilada - https://pt.wikipedia.org/wiki/Linguagem_compilada - Wikipedia.
Linguagem interpretada - https://pt.wikipedia.org/wiki/Linguagem_interpretada - Wikipedia.
Microprocessador - https://pt.wikipedia.org/wiki/Microprocessador - Wikipedia.
Qual a diferença entre linguagem compilada para linguagem interpretada? - https://pt.stackoverflow.com/questions/77070/qual-a-diferen%C3%A7a-entre-linguagem-compilada-para-linguagem-interpretada - StackOverflow.
Transístor - https://pt.wikipedia.org/wiki/Transístor - Wikipedia.
Understand flags and conditional jumps - http://www.godevtool.com/GoasmHelp/usflags.htm - Jeremy Gordon, 2002-2003.
Pluralsight's Windows Internals - https://app.pluralsight.com/library/courses/windows-internals - Pavel Yosifovich, 2013.
Intel® 64 and IA-32 Architectures Software Developer's Manual.
Microsoft Portable Executable and Common Object File Format Specification.
Crackproof Your Software: Protect Your Software Against Crackers - Pavol Cerven, 2002.
Fundamentos em programação Assembly - José Augusto N. G. Manzano, 2006.
História da Matemática - Carl B. Boyer, 1974.
Reversing: Secrets of Reverse Engineering - Eldad Eilam, 2005.
Agora que já temos um olhar mais abstraído sobre os números, é necessário entender como o computador trabalha com eles. Acontece que é preciso armazenar estes números em algum lugar antes de usá-los em qualquer operação. Para isso foi criado o byte, a unidade de medida da computação. Consiste em um espaço para armazenar bits (8 na arquitetura Intel, também chamado de octeto). Então, neste livro, sempre que falarmos em em 1 byte, leia-se, "um espaço onde cabem 8 bits". Sendo assim, o primeiro número possível num byte é 0b00000000, ou simplesmente 0 (já que zero à esquerda não vale nada). E o maior número possível é 0b11111111 que é igual a 0xff ou 255, em decimal.
Uma maneira rápida de calcular o maior número positivo que pode ser representado num espaço de x bits é usando a fórmula 2^x - 1. Por exemplo, para os 8 bits que mencionamos, basta elevar 2 à oitava potência (que resulta em 256) e diminuir uma unidade: 2^8 - 1 = 255. E por que diminuir um? Porque que o zero precisa ser representado também. Se podemos representar 256 números diferentes e o zero é um deles, ficamos com a faixa de 0 a 255.
Agora que você já sabe o que é um byte, podemos apresentar seus primos nibble (metade dele), word, double word e quad word. Veja a tabela:
Medida
Tamanho (Intel)
Nomenclatura Intel
nibble
4 bits
byte
8 bits
BYTE
word
16 bits
WORD
double word
32 bits
DWORD
quad word
64 bits
QWORD
Fica claro que o maior valor que cabe, por exemplo, numa variável, depende de seu tamanho (quantidade de espaço para armazenar algum dado). Normalmente um tipo inteiro tem 32 bits, portanto, podemos calcular 2 elevado a 32 menos 1, que dá 4294967295. O inteiro de 32 bits ou 4 bytes é muito comum na arquitetura Intel x86.
Já vimos que um byte pode armazenar números de 0 a 255 por conta de seus 8 bits. Mas como fazemos quando um número é negativo? Não temos sinal de menos (-), só bits. E agora? Não é possível ter números negativos então? Claro que sim, do contrário você não poderia fazer contas com números negativos e o código em Python abaixo falharia:
Mas não falhou! Isso acontece porque na computação dividimos as possibilidades quase que "ao meio". Por exemplo, sabendo que 1 byte pode representar 256 possibilidades (sendo o 0 e mais 255 de números positivos), podemos dividir tais possibilidades, de modo a representar de -128 até +127. Continuamos com 256 possibilidades diferentes (incluindo o zero), reduzimos o máximo e aumentamos o mínimo. :-)
O bit mais significativo (mais à esquerda) é utilizado para representar o sinal. Se for 0, é um número positivo. Se for 1, é um número negativo.
Há ainda a técnica chamada de complemento de dois, necessária para calcular um valor negativo. Para explicá-la, vamos ao exemplo de obter o valor negativo -10 a partir do valor positivo 10, considerando o espaço de 1 byte. Os passos são:
Converter 10 para binário, que resulta em 0b1010.
Acrescentar à esquerda do valor binário os zeros para formar o byte completo (8 bits): 0b00001010.
Inverter todos os bits: 0b11110101 (essa operação é chamada de complementação ou complemento de um).
Somar 1 ao resultado final, quando finalmente chegamos ao complemento de dois: 0b11110110.
Sendo assim, vamos checar em Python:
O que aconteceu? Bem, realmente 0b11110110 é 246 (em decimal), se interpretado como número sem sinal. Acontece que temos que dizer explicitamente que vamos interpretar um número como número com sinal (que pode ser positivo ou negativo). Em Python, um jeito é usando o pacote ctypes:
Já em C, é preciso especificar se uma variável é signed ou unsigned. O jeito como o processador reconhece isso foge do escopo deste livro, mas por hora entenda que não há mágica: 0b11110110 (ou 0xf6) pode ser tanto 246 quanto -10. Depende de como é interpretado, com ou sem sinal.
Por fim, é importante notar que a mesma regra se aplica para números de outros tamanhos (4 bytes por exemplo). Analise a tabela abaixo, que considera números de 32 bits:
10000000000000000000000000000000
80000000
-2147483648
2147483648
11111111111111111111111111111111
FFFFFFFF
-1
4294967295
00000000000000000000000000000000
00000000
0
0
01111111111111111111111111111111
7FFFFFFF
2147483647
2147483647
Perceba que o número 0x7fffffff tem seu primeiro bit zerado, portanto nunca será negativo, independente de como seja interpretado. Para ser um número negativo, é necessário que o primeiro bit do número esteja setado, ou seja, igual a 1.
Onde já se viu dois executáveis com o ImageBase em 0x400000 rodarem ao mesmo tempo se ambos são carregados no mesmo endereço de memória? Bem, a verdade é que não são. Existe um esquema chamado de memória virtual que consiste num mapeamento da memória RAM real física para uma memória virtual para cada processo no sistema, dando a eles a ilusão de que estão sozinhos num ambiente monotarefa como era antigamente (vide MS-DOS e outros sistemas antigos). Essa memória virtual também pode ser mapeada para um arquivo em disco, como o pagefile.sys. O desenho a seguir ilustra o mecanismo de mapeamento:
Conforme explicado no capítulo sobre as Seções dos arquivos PE, a memória é dividida em páginas, tanto a virtual quanto a física. No desenho, os dois processos possuem páginas mapeadas pelo kernel (pelo gerenciador de memória, que é parte deste) em memória física e em disco (sem uso no momento). Perceba que as páginas de memória não precisam ser contíguas (uma imediatamente após a outra) no layout de memória física, nem no da virtual. Além disso, dois processos diferentes podem ter regiões virtuais mapeadas para a mesma região da memória física, o que chamamos de páginas compartilhadas.
Em resumo, o sistema gerencia uma tabela que relaciona endereço físico de memória (real) com endereço virtual, para cada processo. Todos "acham" que estão sozinhos no sistema, mas na verdade estão juntos sob controle do kernel.
O endereço virtual, em inglês Virtual Address, ou simplesmente VA, é justamente a localização virtual em memória de um dado ou instrução. Por exemplo, quando alguém fazendo engenharia reversa num programa diz que no endereço 0x401000 existe uma função que merece atenção, quer dizer que ela está no VA 0x401000 do binário quando carregado. Para ver a mesma função, você precisa carregar o binário em memória (normalmente feito com um debugger, como veremos num capítulo futuro) e verificar o conteúdo de tal endereço.
Em inglês, Relative Virtual Address, é um VA que, ao invés de ser absoluto, é relativo à alguma base. Por exemplo, o valor do campo entrypoint no cabeçalho Opcional é um RVA relativo à base da imagem (campo ImageBase no mesmo cabeçalho). Com isso em mente, avalie seu valor na saída a seguir:
No exemplo acima, o campo entrypoint tem o valor 0x39c2, que é um RVA. Como este campo é relativo ao ImageBase, o VA (endereço virtual) do entrypoint é então dado pela sua soma com o valor de ImageBase:
Os RVA's podem ser relativos à outras bases que não a base da imagem. É preciso consultar na documentação qual a relatividade de um RVA para convertê-lo corretamente para o VA correspondente.
É muito comum softwares trabalharem com arquivos. O mesmo vale para malware. Considero importante, do ponto de vista de engenharia reversa, saber como as funções do Windows que trabalham com arquivos são chamadas.
Vamos começar pela função CreateFile
, que tanto cria quando abre arquivos e outros objetos no Windows. O protótipo da versão Unicode dessa função é o seguinte:
Agora vamos aos parâmetros:
O caminho do arquivo que será aberto para escrita ou leitura. Se somente um nome for especificado, o diretório de onde o programa é chamado será considerado. Este parâmetro é do tipo LPCSTR na versão ASCII da função e do tipo LPCSWSTR na versão UNICODE.
Este é um campo numérico que designa o tipo de acesso desejado ao arquivo. Os valores possíveis são:
Também é possível combinar tais valores. Por exemplo, GENERIC_READ | GENERIC_WRITE
para abrir um arquivo com acesso de leitura e escrita.
O modo de compartilhamento deste arquivo com outros processos. Os valores possíveis são:
No entanto, o valor 0
é bem comum e faz com que nenhum outro processo apossa abrir o arquivo simultâneamente.
Um ponteiro para uma estrutura especial do tipo SECURITY_ATTRIBUTES
. Em geral, usamos NULL
.
Ações para tomar em relação à criação do arquivo, pode ser:
Atributos e flags especiais para os arquivos. O mais comum é passar somente FILE_ATTRIBUTE_NORMAL
, mas a documentação oficial prevê muitos outros possíveis valores.
Um handle válido para um arquivo modelo, para ter os atributos copiados. Normalmente é NULL
.
Colocando tudo junto, podemos criar um arquivo usando a API do Windows assim:
Logo após a chamada à CreateFileA
, é comum encontrar uma comparação para saber se o objeto foi aberto com sucesso. Como esta função retorna um handle para o arquivo ou o valor INVALID_HANDLE_VALUE
(0xffffffff) em caso de falha, podemos fazer na sequência:
Por fim, é importante fechar o handle obtido para o arquivo. Isso é feito com a função CloseHandle
:
O código que construímos só abre o arquivo, criando-o sempre, e depois o fecha. Nada é escrito nele. Vamos agora escrever algum texto antes de fechar, mas para isso precisamos de mais uma função.
Essa função escreve dados num objeto. Seu protótipo é o seguinte:
hFile
é o handle de um arquivo previamente aberto com a CreateFile
. O próximo parâmetro, lpBuffer
, é um ponteiro para os dados que pretendemos escrever no arquivo. A quantidade de bytes a serem escritos é informada pelo parâmetro nNumberOfBytesToWrite
e a quantidade de bytes que a função conseguiu escrever é colocada num parâmetro de saída opcional lpNumberOfBytesWritten
. Por fim, o parâmetro lpOverlapped
é um ponteiro para uma estrutura do tipo OVERLAPPED
utilizada em casos especials, mas normalmente é NULL
.
A WriteFile
retorna TRUE
se a escrita ocorreu, ou FALSE
em caso de falha.
Com tais definições, podemos completar nosso programa para fazê-lo escrever um texto no arquivo antes de fechar o arquivo com a CloseHandle
. O código final fica assim:
Ao compilar e rodar este código que produzimos, o programa deve criar o arquivo log.txt
e escrever o texto "Programando usando a API do Windows" nele.
Uma instrução é um conjunto definido por um código de operação (opcode) mais seus operandos, se houver. Ao receber bytes específicos em seu barramento, o processador realiza determinada operação. O formato geral de uma instrução é:
Onde opcode representa um código de operação definido no manual da Intel, disponível em seu website. O número de operandos, que podem variar de 0 a 3 na IA-32 (Intel Architecture de 32-bits), consistem em números literais, registradores ou endereços de memória necessários para a instrução funcionar. Por exemplo, considere a seguinte instrução, que coloca o valor 2018 no registrador EAX:
O primeiro byte é o opcode. Os outros 4 bytes representam o primeiro e único argumento dessa instrução. Sabemos então que 0xB8 faz com que um valor seja colocado em EAX. Como este registrador tem 32-bits, nada mais natural que o argumento dessa instrução ser também de 32-bits ou 4 bytes. Considerando o endianess, como já explicado anteriormente neste livro, o valor literal 2018 (0x7E2 ou, em sua forma completa de 32-bits, 0x000007E2) é escrito em little-endian com seus bytes na ordem inversa, resultando em E2 07 00 00.
Na arquitetura Intel IA-32, uma instrução (considerando o opcode e seus argumentos) pode ter de 1 à 15 bytes de tamanho.
Uma instrução muito comum é a MOV, forma curta de "move" (do Inglês, "mover"). Apesar do nome, o que a instrução faz é copiar o segundo operando (origem) para o primeiro (destino). O operando de origem pode ser um valor literal, um registrador ou um endereço de memória. O operando de destino funciona de forma similar, com exceção de não poder ser um valor literal, pois não faria sentido mesmo. Ambos os operandos precisam ter o mesmo tamanho, que pode ser de um byte, uma word ou uma doubleword, na IA-32. Analise o exemplo a seguir:
A instrução acima copia um valor literal 0xB0B0CA para o registrador EBX. A versão compilada desta instrução resulta nos seguintes bytes:
Naturalmente, processadores fazem muitos cálculos matemáticos. Veremos agora algumas dessas instruções, começando pela instrução ADD, que soma valores. Analise:
No código acima, a instrução ADD soma 1 ao valor de ECX (que no nosso caso é 7, conforme instrução anterior). O resultado desta soma é armazenado no operando de destino, ou seja, no próprio registrador ECX, que passa a ter o valor 8.
Uma outra forma de atingir este resultado seria utilizar a instrução INC, que incrementa seu operando em uma unidade, dessa forma:
A instrução INC recebe um único operando que pode ser um registrador ou um endereço de memória. O resultado do incremento é armazenado no próprio operando, que em nosso caso é o registrador ECX.
O leitor pode se perguntar por que existe uma instrução INC se é possível incrementar um operando em uma unidade com a instrução ADD. Para entender, compile o escreva o seguinte programa:
Salve como soma.s
e compile com o NASM. Terminada a compilação, verifique o código objeto gerado com o comando objdump
:
Há duas diferenças básicas entre as instruções ADD e INC neste caso. A mais óbvia é que a instrução ADD EAX, 1 custou três bytes no programa, enquanto a instrução INC EAX utilizou somente um. Isso pode parecer capricho, mas não é: binários compilados possuem normalmente milhares de instruções Assembly e a diferença de tamanho no resultado final pode ser significativa.
Existem sistemas onde cada byte economizado num binário é valioso. Alguns exigem que os binários sejam os menores possíveis, tanto em disco (ou memória flash) quanto sua imagem na memória RAM. Este consumo de memória é por vezes chamado de footprint, principalmente em literatura sobre sistemas embarcados.
Outra vantagem da INC sobre a ADD é a velocidade de execução, já que a segunda requer que o processador leia os operandos.
A instrução SUB funciona de forma similar e para subtrair somente uma unidade, também existe uma instrução DEC (de decremento). Vamos então estudar um pouco sobre a instrução MUL agora. Esta instrução tem o primeiro operando (o de destino) implícito, ou seja, você não precisa fornecê-lo: será sempre EAX ou uma sub-divisão dele, dependendo do tamanho do segundo operando (de origem), que pode ser um outro registrador ou um endereço de memória. Analise:
A instrução MUL EBX vai realizar uma multiplicação sem sinal (sempre positiva) de EBX com EAX e armazenar o resultado em EAX.
Perceba que não se pode fazer diretamente MUL EAX, 2. Foi preciso colocar o valor 2 em outro registrador antes, já que a MUL não aceita um valor literal como operando.
A instrução DIV funciona de forma similar, no entanto, é recomendável que o leitor faça testes e leia sobre estas instruções no manual da Intel caso queira se aprofundar no entendimento delas.
Neste ponto acredito que o leitor esteja confortável com a aritimética em processadores x86, mas caso surjam dúvidas, não deixe de discuti-las em https://menteb.in/forum.
Já explicamos o que são as operações bit-a-bit quando falamos sobre cálculo com binários, então vamos dedicar aqui à particularidades de seu uso. Por exemplo, a instrução XOR, que faz a operação OU EXCLUSIVO, pode ser utilizada para zerar um registrador, o que seria equivalente a mover o valor 0 para o registrador, só que muito mais rápido. Analise:
Além de menor em bytes, a versão XOR é também mais rápida. Em ambas as instruções, depois de executadas, o resultado é que o registrador ECX terá o valor 0 e a flag ZF será setada, como em qualquer operação que resulte em zero.
Faça você mesmo testes com as instruções AND, OR, SHL, SHR, ROL, ROR e NOT. Todas as suas operações já foram explicadas na seção Cálculos com Binários.
Sendo uma operação indispensável ao funcionamento dos computadores, a comparação precisa ser muito bem compreendida. Instruções chave aqui são a CMP (Compare) e TEST. Analise o código a seguir:
A instrução CMP neste caso compara o valor de EAX (previamente setado para 0xB0B0) com 0xFE10. O leitor tem alguma ideia de como tal comparação é feita matematicamente? Acertou quem pensou em diminuir de EAX o valor a ser comparado. Dependendo do resultado, podemos saber o resultado da comparação da seguinte maneira:
Se o resultado for zero, então os operandos de destino e origem são iguais.
Se o resultado for um número negativo, então o operando de destino é maior que o de origem.
Se o resultado for um número positivo, então o operando de destino é menor que o de origem.
O resultado da comparação é configurado no registrador EFLAGS, o que significa dizer que a instrução CMP altera as flags, para que instruções futuras tomem decisões baseadas nelas. Por exemplo, para operandos iguais, a CMP faz ZF=1.
A instrução CMP é normalmente precedida de um salto, como veremos a seguir.
A ideia de fazer uma comparação é tomar uma decisão na sequencia. Neste caso, decisão significa para onde transferir o fluxo de execução do programa, o que é equivalente a dizer para onde pular, saltar, ou para onde apontar o EIP (o ponteiro de instrução). Uma maneira de fazer isso é com as instruções de saltos (jumps).
Existem vários tipos de saltos. O mais simples é o salto incondicional produzido pela instrução JMP, que possui apenas um operando, podendo ser um valor literal, um registrador ou um endereço de memória. Para entender, analise o programa abaixo:
A instrução ADD EAX, 4 nunca será executada pois o salto faz a execução pular para o endereço 0x0A, onde temos a instrução INC EAX. Portanto, o valor final de EAX será 2.
Note aqui o opcode do salto incondicional JMP, que é o 0xEB. Seu argumento, é o número de bytes que serão pulados, que no nosso caso, são 3. Isso faz a execução pular a instrução ADD EAX, 4 inteira, já que ela tem exatamente 3 bytes.
Você pode entender o salto incondicional JMP como um comando goto na linguagem de programação C.
Os saltos condicionais J_cc_ onde cc significa condition code, podem ser de vários tipos. O mais famoso deles é o JE (Jump if Equal), utilizado para saltar quando os valores da comparação anterior são iguais. Em geral ele vem precedido de uma instrução CMP, como no exemplo abaixo:
A instrução no endereço 0x5 compara o valor de EAX com 1 e vai sempre resultar em verdadeiro neste caso, o que significa que a zero flag será setada.
O salto JE ocorre se ZF=1, ou seja, se a zero flag estiver setada. Por essa razão, ele também é chamado de JZ (Jump if Zero). Abaixo uma tabela com os saltos que são utilizados para comparações entre números sem sinal e as condições para que o salto ocorra:
JZ (Zero)
JE (Equal)
ZF=1
JNZ (Not Zero)
JNE (Not Equal)
ZF=0
JC (Carry)
JB (Below) ou JNAE (Not Above or Equal)
CF=1
JNC (Not Carry)
JNB (Not Below) ou JAE (Above or Equal)
CF=0
JA (Above)
JNBE (Not Below or Equal)
CF=0 e ZF=0
JNA (Not Above)
JBE (Below or Equal)
CF=1 ou ZF=1
JP (Parity)
JPE (Parity Even)
PF=1
JNP (Not Parity)
JPO (Parity Odd)
PF=0
JCXZ (CX Zero)
Registrador CX=0
JECXZ (ECX Zero)
Registrador ECX=0
Nem é preciso dizer que vai ser necessário você criar programas em Assembly para treinar a compreensão de cada um dos saltos, é?
Já vimos que comparações são na verdade subtrações, por isso os resultados são diferentes quando utilizados números com e sem sinal. Apesar de a instrução ser a mesma (CMP), os saltos podem mudar. Eis os saltos para comparações com sinal:
JG (Greater)
JNLE (Not Less or Equal)
JGE (Greater or Equal)
JNL (Not Less)
JL (Less)
JNGE (Not Greater or Equal)
JLE (Less or Equal)
JNG (Not Greater)
JS (Sign)
SF=1
JNS (Not Sign)
SF=0
JO (Overflow)
OF=1
JNO (Not Overflow)
OF=0
Não se preocupe com a quantidade de diferentes instruções na arquitetura. O segredo é estudá-las conforme o necessário, na medida em que surgem nos programas que você analisa. Para avançar, só é preciso que você entenda o conceito do salto. Muitos problemas de engenharia reversa são resolvidos com o entendimento de um simples JE (ZF=1). Se você já entendeu isso, é suficiente para prosseguir. Se não, releia até entender. É normal não compreender tudo de uma vez e vários dos assuntos necessitam de revisão e exercícios para serem completamente entendidos.
As bibliotecas, ou DLLs no Windows, são também arquivos PE, mas sua intenção é ter suas funções utilizadas (importadas e chamadas) por arquivos executáveis. Elas também importam funções de outras bibliotecas, mas além disso, exportam funções para serem utilizadas.
Novamente, é possível utilizar o DIE para ver as funções importadas e exportadas por uma DLL, mas no exemplo a seguir, utilizamos novamente o dumpbin contra a biblioteca Shell32.dll
, nativa do Windows:
Utilizamos o comando findstr do Windows para filtrar a saída por funções que criam caixas de mensagem. Este comando é como o grep no Linux. A sua opção /i faz com que o filtro de texto ignore o case (ou seja, funciona tanto com letras maiúsculas quanto com minúsculas).
Para chamar uma função desta DLL, teríamos que criar um executável que a importe. No entanto, o próprio Windows já oferece um utilitário chamado rundll32.exe, capaz de chamar funções de uma biblioteca. A maneira via linha de comando é como a seguir:
Como a função ShellAboutA() recebe um texto ASCII para ser exibido na tela "Sobre" do Windows, podemos testá-la da seguinte forma:
Utilizar o rundll32.exe para chamar funções de biblioteca não é a maneira mais adequada de fazê-lo e não funciona com todas as funções, principalmente as que precisam de parâmetros que não são do tipo string. Somente o utilizamos aqui para fins de compreensão do conteúdo.
Em breve também aprenderemos como debugar uma DLL, mas por hora o conhecimento reunido aqui é suficiente.
Um patch é qualquer alteração, seja nos dados ou no código de um programa. O que fizemos na seção anterior é justamente isto. No entanto, não salvamos nossas modificações e neste caso elas serão perdidas caso você feche o x64dbg.
É possível salvar as alterações acessando o menu View -> Patch file..., clicando no botão com um pequeno curativo (tipo um band-aid) na barra de ferramentas ou pressionando Ctrl+P.
Se você veio da seção anterior e tem ainda as modificações no AnalyseMe-00, sua tela de patches aparecerá assim:
A partir desta tela, é possível exportá-los para um arquivo (Export), mas também criar um novo arquivo executável com as alterações salvas (Patch File).
Perceba que você pode fazer qualquer tipo de patch, desde uma simples alteração da lógica de um salto até inserir de fato novas funções num programa. Pode dar trabalho, mas é possível.
E assim, chegamos ao fim desta primeira etapa de estudo dos fundamentos de engenharia reversa de software. Espero que tenha curtido a jornada e aproveitado o conteúdo. Ainda há um longo caminho pela frente, mas tenho certeza que agora será mais fácil entender conceitos mais profundos de Assembly x86, iniciar estudos de outras arquiteturas como x86-64 e ARM, de conceitos mais avançados no Windows ou mesmo de engenharia reversa em outras plataformas como Linux e macOS.
Se este livro te ajudou, considere nos apoiar em https://menteb.in/apoie, pois a Mente Binária depende de doações e venda de cursos para sustentar sua operação e continuar entregando conteúdo de qualidade para milhares de pessoas. Muito obrigado por chegar até aqui! Sucesso!
Um breakpoint nada mais é que um ponto do código onde o debugger vai parar para que você analise o que precisa. É o mesmo conceito dos breakpoints presentes nos IDE's como Visual Studio, NetBeans, CodeBlocks, etc. A diferença é que nestas IDE's colocamos breakpoints em determinadas linhas do código-fonte. Já nos debuggers destinados à engenharia reversa, colocamos breakpoints em endereços (VA's), onde há instruções.
Há várias maneiras de se colocar um breakpoint em um endereço utilizando o x64dbg. Você pode selecionar a instrução e pressionar F2, usar um dos comandos SetBPX/bp/bpx ou simplesmente clicar na pequena bolinha cinza à esquerda do endereço. Ao fazê-lo, este ficará com um fundo vermelho, como sugere a imagem:
Um segundo clique desabilita o breakpoint, mas não o exclui da aba Breakpoints (Alt+B). O terceiro clique de fato o deleta.
Após colocar o breakpoint nesta CALL, rode o programa (F9). O que acontece? O debugger executa todas as instruções anteriores a este breakpoint e pára onde você pediu. Simples assim.
Talvez você tenha notado que ao atingir um breakpoint, o x64dbg denuncia na barra de status: INT3 breakpoint at analyseme-00.00401007 (00401007)!. Este é um tipo de breakpoint de software. Existem ainda os de memória e de hardware, mas não trataremos neste curso básico.
A instrução INT é uma instrução Assembly que gera uma interrupção. A interrupção número 3 é chamada de Breakpoint Exception (#BP) no manual da Intel. Seu opcode (0xcc) tem somente um byte, o que facilita para que os debuggers implementem-na.
De forma resumida, para parar nesta CALL, o que o x64dbg faz é:
Substituir o primeiro byte do opcode da CALL (0xff, neste caso) por 0xcc e salvar o original numa memória.
Executar o programa.
Restaurar o primeiro byte do opcode da CALL, substituindo o 0xcc por 0xff (neste caso).
Isso poderia ser feito manualmente, mas os debuggers facilitam o trabalho, bastando você pressionar F2 ou clicar na bolinha para que todo este trabalho seja executado em segundo plano, sem que o usuário perceba. Incrível, não é?
Você pode adicionar quantos breakpoints de software quiser numa sessão de debugging. Todos ficam salvos na aba Breakpoints, a não ser que você os exclua. Veja a imagem abaixo:
Você também pode assistir a , que trata sobre este assunto.