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...
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 ou quer sugerir alguma mudança, por favor abra um issue no repositório do livro no GitHub em https://github.com/mentebinaria/fundamentos-engenharia-reversa/issues ou fale conosco no 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. Considere nos apoiar seguindo as instruções em https://menteb.in/apoie. Seu apoio também vai ajudar a manter este livro atualizado e torná-lo cada vez 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.
Conhecemos bem os dez símbolos latinos utilizados no sistema de numeração decimal: 0, 1, 2, 3, 4, 5, 6, 7, 8 e 9. 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 representável 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, o 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.
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 na tela:
Ou em linguagem C:
Na prática, os códigos acima funcionam como conversores de binário para decimal.
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.
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:
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
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 grandes 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 depois do prefixo 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:
0
@
1
#
2
$
3
#@
4
##
5
#$
6
$@
7
$#
8
$$
9
#@@
10
#@#
11
#@$
É 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.
Tudo é número (Pitágoras)
Costumo dizer quando ministro aulas 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 AEC (Antes da Era Comum) 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.
Neste capítulo vamos focar nos números. Em breve veremos como o processador trabalha com eles também.
Pois bem, 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 é, você 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. Representar somente zeros e uns 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.
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.
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.
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 pode confundir quem lê 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.
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 AND, OR e XOR.
Cada frase deste livro, a não ser que expressado diferente, considera a arquitetura Intel x86, 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
flat assembler - https://flatassembler.net/
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 Community.
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!
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 as quantidades máxima e miníma representáveis. :)
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, se 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.
Nesta seção faremos cálculos com números binários considerando cada um de seus dígitos, também chamados de bits. Além de operações aritmé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.
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 é 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:
| x | y | x \| y | | ------------------------------------------------------- | ------------------------------------------------------- | ------- | | 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, obteremos 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.
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.
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 zero em decimal representa a mesma quantidade que zero em binário). E o maior número possível é 0b11111111 que é igual a 0xff em hexa ou 255 em decimal.
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:
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.
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 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.
À 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. As strings neste formato 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 (o criador do Unix!) e Rob Pike (o criador da linguagem Go) 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, mesmo que não precisem. O byte adicional estará zerado. 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 vejamos como essa string seria codificada em UTF-16:
A primeira dupla de bytes é FF FE, mas de onde ela veio? Esta é a Byte Order Mark (BOM) ou Marca de Ordem de Byte, em português e define a ordem (ou endianness) dos bytes nos codepoints. Se for FF FE como neste caso, os bytes estão em little-endian, o que significa que o byte menos significativo está à esquerda. Em outras palavras, o número 0x0006d será representado como 6D 00. Se o bom fosse FE FF, então esse número seria representado como 00 6D.
Também é possível utilizar a codificação UTF-16-LE que já utiliza little-endian por padrão, sem precisar da 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. Isto é 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 estão em big-endian, ou seja, na ordem direta, com o byte mais significativo à esquerda:
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, como já falado, 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 sem BOM. Os bytes dos caracteres em si coincidem com os da ISO-8859-1. Doido né? Mas vamos em frente!
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 utilizado, dentre outros fatores. Por exemplo, um programa feito em C no Windows e compilado com Visual Studio tem as wide strings em UTF-16-LE 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 do escopo deste livro. Se 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.
Provavelmente 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ó possuem seu próprio 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. Criamos 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 Detect It Easy. 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.
Cabeçalhos, como o próprio nome sugere, são áreas de dados no início de um arquivo. Eles contém diferentes campos, que admitem valores.
Cada campo possui um tipo que 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:
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
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 se estiverem ligados. Um bom exemplo é o campo "Characteristics" do cabeçalho de seções do arquivo PE, que veremos oportunamente.
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á.
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 você 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 representaçã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 de 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:
0
6
47 49 46 38 39 61
Cabeçalho
6
2
<variável>
Largura em pixels
8
2
<variável>
Altura em pixels
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 outros formatos podem utilizar magic numbers de diferentes tamanhos. Não há regra específica.
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 voltar num conceito importante que é o endianness. Acontece que na arquitetura Intel os bytes de um número inteiro são armazenados de trás para frente (ordem essa chamda 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, nos diz 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 isto foge do nosso escopo, 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.
Como explicado no capítulo anterior, a maioria dos tipos de arquivo que trabalhamos possui 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 muitos programas no Windows, isso inclui os famosos arquivos EXE mas também arquivos DLL, OCX, CPL e SYS. Seu nome deriva do fato de o formato não estar preso à 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.
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.
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á na faixa da tabela ASCII. Podemos usar o Python para rapidamente imprimir os valores ASCII de cada caractere de uma string:
É 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, newline 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.
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 no Brasil é a ISO-8859-1, também chamada de Latin-1, que você vê no Apêndice Tabela ISO-8859-1/Latin-1.
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 incluem 0x8664 para executáveis x86-64 de 64-bits, 0x14c para executáveis x86 de 32-bits e 0xaa64 para executáveis ARM de 64-bits. A tabela completa está disponível na documentação oficial do formato.
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 dizer que este campo não é utilizado pelo loader de arquivos PE no Windows e seu valor pode ser alterado pelo compilador ou 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:
1
IMAGE_FILE_EXECUTABLE_IMAGE
Obrigatório para arquivos executáveis
5
IMAGE_FILE_LARGE_ADDRESS_AWARE
Pode endereçar mais de 2 GB de memória
8
IMAGE_FILE_32BIT_MACHINE
O arquivo é de 32-bits
13
IMAGE_FILE_DLL
O arquivo é uma DLL
Vamos analisar novamente o executável da calculadora. Considere que:
Logo após a assinatura PE na posição 0x100, temos o primeiro campo do cabeçalho COFF que é o Machine. Ele é um campo de 2 bytes conforme já dito, então os bytes 0x64 e 0x86 definem seu valor. Considerando o endianness, chegamos ao valor 0x8664, que define que este executável foi criado para rodar em computadores com processadores de arquitetura x86-64.
Em seguida, na posição 0x106, temos o NumberOfSections que é 7.
Depois vem o campo TimeDateStamp com o número inteiro de 32 bits (4 bytes) sem sinal 0xee8136fb que é 4001445627 em decimal.
Pulamos então 8 bytes referentes aos campos PointerToSymbolTable e NumberOfSymbols (normalmente zerados mesmo), encontrando a word SizeOfOptionalHeader em 0x114 cujo valor é 0xf0.
A próxima word é o valor do campo Characteristics, que neste arquivo é 0x22. Convertendo para binário temos o valor 00100010. Contando-se a partir de zero e da direita para a esquerda, identificamos que os bits 1 e 5 estão ligados, significando que o executável pode endereçar mais de 2 GB de memória, o que é comum em executáveis de 64-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.
Abra o Developer Command Prompt for VS 2022 (ele é instalado junto ao Visual Studio Community) e exiba o COFF/File Header do binário da calculadora do Windows:
Você deve ver uma saída parecida com essa:
O que o dumpbin e outros analisadores de PE fazem é interpretar toda a estrutura do arquivo com base na documentação do formato PE e produzir uma saída mais legível.
Com o Detect It Easy, também é possível ver os cabeçalhos de um PE. Para ver o cabeçalho COFF, abra o executável no DIE e marque a caixa de seleção Advanced. Clique no botão PE e no menu à esquerda escolha IMAGE_FILE_HEADER. Você deve ver uma janela como esta:
Aproveite e nague pelo programa para descobrir mais detalhes sobre o arquivo antes de irmos para o Cabeçalho Opcional.
No final do cabeçalho opcional, mas ainda como parte dele, temos os diretórios de dados, ou Data Directories. São 16 diretórios ao todo, mas um executável pode conter somente alguns. Nos concentraremos, 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 alguns diretórios importantes neste momento:
O primeiro diretório de dados aponta para a tabela de exports, ou seja, de funções exportadas pela aplicação. Por este motivo, a presença deste diretório (campos VirtualAddress e Size com valores 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).
Sendo a contraparte do Export Table, o diretório Import Table aponta para uma tabela de imports, ou seja, de funções importadas pela aplicação. Tal tabela é chamada de IDT (Import Directory Table). Nós a estudaremos em mais à frente.
Aponta para uma estrutura de árvore binária que armazena todos os resources num executável como ícones, janelas e strings, principalmente quando o programa suporta vários idiomas.
Antigamente chamado de "Security", este diretório contém o endereço da Certificate Table, que pode conter um certificado para binários assinados digitalmente.
Este diretório aponta para a Import Address Table, que veremos em breve.
Para binários criados em .NET, há um outro cabeçalho específico que é apontado por este diretório.
A ordem na qual estes diretórios aparecem no arquivo PE é especificada na documentação do formato. Vamos agora para o nosso último cabeçalho, o das Seções.
A julgar pelo seu nome, pode não parecer, mas este cabeçalho é muito importante. Ele é opcional para arquivos objeto, mas é obrigatório em arquivos executáveis, que são o nosso foco de estudo. Ao contrário dos outros cabeçalhos que vimos até agora, o tamanho deste cabeçalho não é fixo, mas sim definido pelo campo SizeOfOptionalHeader do cabeçalho COFF, que vimos anteriormente. Sua estrutura para arquivos PE de 64-bits, também chamados de PE32+ (ou de PE64 de forma não oficial), é a seguinte:
Vamos analisar agora os campos mais importantes para o 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 0x20b diz que é um PE32+ (executável PE de 64-bits), enquanto o valor 0x10b significa que o executável é um PE32 (executável PE de 32-bits).
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, o endereço onde o programa será carregado em memória. É comum encontrar valores como 0x140000000 ou 0x400000 neste campo.
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 o nome possa sugerir, este campo não é somente para DLLs. 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:
O estado bit 5 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 7 diz respeito à DEP (Data Execution Prevention), também conhecido pela sigla NX (No eXecute). O estudo aprofundado destes recursos foge do escopo deste livro, mas é importante que saibamos que podemos desabilitar tais recursos para um binário específico simplesmente desligando estes bits se quisermos.
O último campo importante para nós é o DataDirectory, que veremos a seguir.
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 conteúdo diferente. Assim é com as seções.
Elas são necessárias porque diferentes tipos de 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 nomes de seção normalmente encontrados em executáveis:
Também nomeada CODE em programas compilados em 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 em memória.
Também chamada de DATA 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 string declarada e já inicializada. 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, logo após a sua declaração ela é alterada e logo depois acessada/lida pela função puts()
. Em sua configuração padrão, o compilador coloca essa string numa seção de dados inicializados com permissão tanto para leitura quanto para escrita.
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 escrita. Entenderemos o motivo dessas permissões 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 de 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 numa 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 te dizer qual o tamanho da página de memória na sua versão do Windows.
Já sabemos como é a estrutura de um arquivo PE, mas precisamos voltar um pouquinho no assunto de diretórios para falar da Import Table, que veremos agora.
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 editor hexadecimal como o HxD para verificar tal informação. Vamos abrir, em caráter de exemplo, o executável da calculadora no Windows, normalmente em C:\Windows\System32\calc.exe.
Perceba os bytes 0x4d e 0x5a logo no início do arquivo.
O HxD exibe um caractere de ponto (.) na coluna Decoded text quando o byte não está na faixa ASCII imprimível. Esta é uma decisão de quem programou o editor hexadecimal. Outras opções comum incluem exibir um caractere de espaço.
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. No entanto, seus valores podem variar de arquivo para arquivo. No caso do e_lfanew, se fizermos as contas, veremos que ele sempre está 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 HxD que vá para a posição 0x3c. Clique em Search -> Go to... ou aperte Ctrl+G. Certifique-se de que as opções "hex" e "begin" estão selecionadas e clique em OK.
No meu arquivo, assim como na imagem anterior, os quatro bytes nesta posição são 00 01 00 00. Sabendo que números são armazenados em little-endian, devemos ler este número como 00 00 01 00, ou seja, 0x0000100 ou simplesmente 0x100. Este é então o endereço da assinatura PE, que consiste numa sequência dos seguintes 4 bytes: 0x50 0x45 0x00 0x00.
Logo após o cabeçalho do DOS, 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 (0x100 no caso deste executável, mas pode ser diferente no seu ambiente).
Logo após os 4 bytes do campo e_lfanew, começa o código do stub do DOS, sempre 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 programa é todavia bem visível).
Finalmente, na posição 0x100 encontra-se a assinatura PE\0\0. Aqui sim, começa o formato PE propriamente dito.
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 são o foco do nosso estudo. Para entender como estes arquivos são criados, é preciso notar os seguintes passos:
Escrita do código-fonte na linguagem escolhida num arquivo de texto.
Compilação.
Linkedição (Linking).
O compilador cria os chamados arquivos objeto a partir do código-fonte. 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 é a transformação do código-fonte em texto em código de máquina. Como saída, ele 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 (DLLs, no caso do Windows) para funcionar e fará uso da Import Table conforme estudamos no capítulo anterior.
Como exemplo, vamos checar as dependências do binário da calculadora:
Mas e as DLLs, como "rodam"? Vejamos agora.
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 usuário e modo kernel, mais conhecidos por seus nomes 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 chamados de anéis (rings), numerados de 0 a 3, onde o anel zero é o mais privilegiado. 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", também chamada de 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 alguém cria um programa, em muitos casos se utiliza de funções de bibliotecas (ou libraries em inglês), também chamadas de DLLs (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 por quem o programou. Quem escreveu o código só precisou chamar a função, 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 da printf() está nela.
Esta arquitetura garante que diferetens programadores e programadoras usem tal função e que ela terá sempre o mesmo comportamento se usada da mesma forma. Mas você 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. Na verdade, a biblioteca de C, que contém a implementação da printf(), pede ao kernel através de uma função de sua API para que ele escreva na tela. O kernel, por sua vez, utiliza o driver da placa de vídeo que conhece a placa e a escrita acontece. Sendo assim, temos, neste caso um EXE que chama uma função de uma DLL que chama o kernel. Estudaremos mais a frente como essas chamadas acontecem.
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 "contêiner" 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 Windows 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 Windows.
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, que é quando ele de fato começa a rodar.
Foi visto que no diretório de dados Import Table há um ponteiro para a IDT (Import Directory Table), apontado pelo valor do campo VirtualAddress. Vamos conhecer essa IDT de uma vez.
A IDT é um array de estruturas do tipo IMAGE_IMPORT_DESCRIPTOR definidas a seguir:
Não se deve confundir esta IDT (Import Descriptor Table) com outra IDT (Interrupt Descriptor Table). Esta última é uma estrutura que mapeia interrupções para seus devidos handlers, assunto que foge do escopo deste 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 diferentes DLLs das quais o executável importa funções. Há ainda um elemento final, completamente zerado (preenchido com null bytes) para indicar o fim do array.
O campo RvaImportLookupTable (antigamente conhecido por OriginalFirstThunk que era unido com Characteristics) aponta para uma tabela chamada de Import Lookup Table (ILT)
Essa tabela é um array de números de 64-bits (ou 32-bits para PE32). Para cada um desses números, seu bit mais significativo (MSB - Most Significant Bit), se ligado, define que a função será importada por número. Se desligado, a função é importada por nome. Os bits remanescentes guardam um endereço para uma estrutura que finalmente contém o nome da função. Essa estrutura é chamada de Hint/Name Table.
Hint/Name Table
Os elementos desta tabela obedecem a seguinte estrutura:
Onde Name possui tamanho variável pois contém o nome da função a ser importada.
Note que o número de entradas na ILT (número de elementos deste array) é igual ao número de funções importadas por uma DLL em particular definida na IDT.
Este campo contém o endereço de uma string que é o nome da DLL importada. Por exemplo: SHELL32.dll. A string é terminada em null.
Este campo aponta finalmente para o que chamamos de IAT (Import Address Table), muito conhecida por quem faz engenharia reversa. 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), ela é preenchida com os endereços reais das funções importadas. Isto porque um executável dinamicamente linkado não sabe, antes de ser carregado em memória, 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 utilizam podem chamar suas funções. Esta é a razão para o longo esquema de preenchimento da IAT pelo loader.
Para fixar este conteúdo, é importante validar o que foi aprendido. Abra o executável da calculadora no DIE, marque a caixa Advanced se já não estiver marcada e clique no botão PE como a imagem a seguir mostra:
No menu da esquerda, vá em IMAGE_NT_HEADERS -> IMAGE_OPTIONAL_HEADER -> IMAGE_DIRECTORY_ENTRIES e copie o endereço (coluna Address) do segundo diretório, que é justamente o endereço da IDT, como a imagem a seguir ilustra:
Perceba que o DIE chama o campo VirtualAddress dos diretórios apenas de Address. Inconsistências assim podem ocorrer em várias ferramentas e literaturas, mas se você souber do que se trata, vai sempre se dar bem, mesmo que nomes diferentes sejam usados para se referir à mesma coisa.
Agora no HxD, abra o mesmo binário e vá até este offset da IDT com o Ctrl+G. No binário que usei aqui o endereço é 0x38f8.
Lá, os primeiros quatros bytes são o valor do campo RvaImportLookupTable do primeiro elemento do array. Se você seguir este offset, vai chegar na ILT. O primeiro elemento da ILT é um número de 64-bits, ou seja, de 8 bytes que aponta para a Hint/Name Table. Nesta tabela, o nome da função começa no terceiro byte conforme definição, logo após o campo Hint.
Como desafio adicional, descubra à qual DLL a função pertence apenas olhando para a imagem anterior. Dica: busque pelo campo Name da IDT e siga o valor que ele contém neste binário.
A documentação completa do formato PE é mantida pela própria Microsoft e está disponível em .
5
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE
7
IMAGE_DLLCHARACTERISTICS_NX_COMPAT
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 (experimente apertar Ctrl+Shift+ESC) ou o comando tasklist:
Na saída do comando tasklist, a coluna Image Name mostra 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.
Há muito mais para falar sobre processos, mas para nosso objetivo aqui, saber que eles representam um programa em execução é suficiente. Agora vamos entender como os programas fazem uso da API que o Windows oferece para que ações significativas ocorram no sistema.
Como poderiam dois executáveis com o mesmo ImageBase 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, o que dá aos processos 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 os "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 em memória. 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 do comando dumpbin a seguir:
No exemplo acima, o campo entrypoint, que é um RVA, tem o valor 0x1740. 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 à da imagem. É preciso consultar na documentação qual a relatividade de um determinado RVA para convertê-lo corretamente para o VA correspondente.
Com isso encerramos o capítulo sobre o formato PE. Agora que você conhece a estrutura de um executável, vamos ver o que acontece depois que alguém dá um duplo-clique nele.
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. Ele é bem limitado, mas para este exemplo funciona. A maneira via linha de comando é:
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 exemplificar a chamada de funções exportadas por uma DLL.
Tanto para DLLs quanto para executáveis, quando eles rodam, um processo é criado. Vamos agora ver que isto significa.
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 a 8 caracteres. A string .text por exemplo ocupa apenas 5 bytes, então os outros 3 devem estar zerados. A codificação usada é a UTF-8.
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 é 0x1000, para ver seu conteúdo no HxD basta ir até o offset 0x1000 com Ctrl+G.
Este é um campo que define algumas flags (características) 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:
É 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. Vamos agora conhecer as seções que estes cabeçalhos definem.
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 indeterminado.
Embora os processadores atuais possam trabalhar em diferentes modos, vamos focar aqui no long mode, onde processadores atuais trabalham com registradores de 64-bits.
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 16 registradores de 64-bits de uso geral na arquitetura x86-64. Apesar de poderem ser utilizados para qualquer coisa, como o termo GPR sugere, a seguinte convenção de uso existe:
Não se preocupe se você não faz ideia de como seria este uso sugerido. Chegaremos lá. Enquanto isso, vamos ver como esses registradores se dividem.
Vários GPRs (mas não todos) podem ser subdivididos em registradores menores para fins de compatibilidade. Vamos analisar como isso se dá.
Tomemos o registrador RAX como exemplo. Ele tem 64 bits, mas ele também pode ser usado como um registrador de 32, 16 ou até de 8 bits. O legal é que essas subdivisões têm nome. Acompanhe o esquema a seguir, que detalha o como o registrador RAX divide seus 64 bits, começando pelo bit 0 à direita:
O esquema anterior é poderoso e requer estudo. Cada um destes subregistradores é um registrador, por mais que compartilhem sua memória interna. Analise as afirmações a seguir:
O registrador EAX tem 32 bits. Dizemos que ele é a parte baixa da RAX. Neste contexto, "parte" quer dizer "metade".
O registrador AX é a parte baixa de EAX. Ele tem 16 bits.
O registrador AH é a parte alta de AX. Ele tem, naturalmente, 8 bits.
O registrador AL é a parte baixa de AX. Ele tem, também, 8 bits.
Perceba que somente o registrador AX se subdivide em dois registradores: AH para a parte alta e AL para a parte baixa.
Como já dito, os subregistradores compartilham a memória interna de seu registrador "pai". Então, ao fazer:
EAX conterá 0x55667788, AX conterá 0x7788, AH conterá 0x77 e, por fim, AL conterá 0x88. O mesmo se aplica aos registradores RBX, RCX e RDX.
Para facilitar nossa vida, outros registradores se subdividem de outras maneiras. Vejamos. :)
Para explicar a subdivisão destes registradores, vamos usar o RSI como exemplo. Analise:
A única diferença é que não há um subregistrador para a parte alta de SI. Para a parte baixa de SI é o SIL. O mesmo se aplica a RDI, RBP e RSP.
Os subregistradores de 64 bits R8, R9, R10, R11, R12, R13, R14 e R15 seguem a mesma lógica anterior: não um registradores que se relacionam com a parte alta do subregistrador de 16-bits deles. No entanto, os nomes deles mudam e por isso cabe o diagrama novamente:
Usei o R8 como exemplo, mas a lógica dos demais é a mesma.
Para fixar o assunto, é importante trabalhar um pouco. Aba o flat assembler (fasm) e escreva o seguinte programa:
Este pequeno programa em Assembly faz algum sentido para você? Vamos comentá-lo:
Na linha 1 estamos dizendo que o arquivo de saída será um PE de 64-bits. Apesar de o nome oficial ser PE32+, muita gente "forçou" o uso do termo PE64 em alguns lugares e é assim que informamos o fasm para usar este formato. Ainda na linha 1, temos a palavra GUI. Ela pede ao fasm que coloque o valor 2 naquele campo SubSystem do cabeçalho Opcional, lembra? :)
A linha 2 define o endereço do entrypoint através de um rótulo (label em inglês). Será explicado melhor na linha 6.
A linha 4 cria uma seção chamada .text que conterá código e que precisa ser mapeada em páginas de memória com permissões de leitura e execução. Se isso soa familiar, é porque realmente o é. :)
A linha 6 define onde o rótulo start começa, dentro da seção .text. Ou seja, o entrypoint configurado na linha 2 será seja qual for o endereço do primeiro byte da seção .text.
Nas linhas 7 e 8 temos as instruções em Assembly que desejamos codificar. Em outras palavras, converter para código de máquina.
Agora é só pedir para o fasm fazer o trabalho. Salve o arquivo como ou.asm
e, para compilar, clique em Run -> Compile ou pressione Ctrl+F9
. Um arquivo ou.exe
será gerado no mesmo diretório onde você salvou seu código-fonte.
Abra agora o ou.exe
no HxD. Como é um programa pequeno, mesmo no olho você consegue notar as instruções que codificou. Destaquei o campo PointerToRawData da seção .text que aponta para o início da seção .text na imagem a seguir.
![Instruções MOV e OR na seção .text][image-1]
Perceba os opcodes e argumentos idênticos aos exemplificados na introdução deste capítulo.
Existe um registrador de 64 bits chamado de RIP (o IP quer dizer Instruction Pointer), ou 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. O valor dele é atualizado de um jeito especial: ele é incrementado com o número de bytes da última instrução executada. Para fixar, analise o exemplo a seguir.
Quando a primeira instrução do trecho acima estiver prestes à ser executada, o registrador RIP conterá o valor 0x140001740. Após a execução desta instrução MOV, o RIP será incrementado em 10 unidades, já que tal instrução possui 10 bytes. Por isso o endereço da instrução seguinte é 0x14000174A. Perceba, no entanto, que a instrução no endereço 0x14000174A possui apenas 3 bytes, o que vai fazer com o que o registrador RIP seja incrementado em 3 unidades para apontar para a próxima instrução. Qual será o valor do registrador RIP após a execução desta instrução? Se você pensou em 0x14000174D, acertou. Vamos agora ver um registrador muito importante sobre o qual temos pouco controle, mas que precisamos entender bem.
O registrador de flags RFLAGS é um registrador de 64-bits usado para armazenar 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:
Além das outras flags, há ainda os registradores de segmento, 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.
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
RAX
Acumulador
Usado em operações aritiméticas
RBX
Base
Ponteiro para dados
RCX
Contador
Contador em repetições
RDX
Dados
Usado em operações de E/S
RSI
Índice de origem
Ponteiro para uma string de origem
RDI
Índice de desitno
Ponteiro para uma string de destino
RBP
Ponteiro base
Ponteiro para a base do stack frame
RSP
Ponteiro pilha
Ponteiro para o topo da pilha
R8-15
-
Registradores adicionais
140001740
48B88877665544332211
mov rax, 1122334455667788
14000174A
4831C0
xor rax, rax
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.
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:
Os valores à direita representam códigos para acessar cada chave. 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\
.
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. Perceba que este é um parâmetro de saída, ou seja, quem chamou a função receberá algo nesta variável que pode ser útil após a chamada de função.
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 ter seu tipo compatível com o tipo configurado no parâmetro dwType
.
O tamanho dos dados 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":
Com este programa finalizamos esta breve introdução à Windows API. Existem, é claro, centenas de outras funções disponíveis para uso, mas é preciso saber programar em C/C++ para utilizá-las em seus programas. Vamos agora ver como os programas ficam depois que os compilamos como você fez aqui. Para isso, vamos iniciar nossos estudos de Assembly.
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. Os operandos de uma instrução consistem em números literais, registradores ou endereços de memória necessários para que ela seja codificada corretamente. Por exemplo, considere a seguinte instrução, que coloca o valor 2025 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 2025 (0x7E9 ou, em sua forma completa de 32-bits, 0x000007E9) é escrito em little-endian com seus bytes na ordem inversa, resultando nos bytes E9 07 00 00.
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, mas não pode ser um valor literal, pois não faria sentido. Ambos os operandos precisam ter o mesmo tamanho, que pode ser de um byte, uma word, uma doubleword ou uma quadword. Analise o exemplo a seguir:
A instrução acima copia um valor literal 0x 1122334455667788 para o registrador RBX. 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 RCX (que no nosso caso é 7, conforme instrução anterior). O resultado desta soma é armazenado no operando de destino, ou seja, no próprio registrador RCX, 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 RCX.
Claro que você atingiria o mesmo objetivo se utilizasse ADD RCX, 1
ao invés de INC RCX
. No entanto, a INC é uma instrução menor em quantidade de bytes, simples de ler e mais rápida para executar e isso pode fazer diferença num programa grande, que executa dezenas de milhares de instruções.
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 RAX ou um subregistrador 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 RBX vai realizar uma multiplicação sem sinal (sempre positiva) de RBX com RAX e armazenar o resultado em RAX.
Não se pode fazer diretamente MUL RAX, 2. É preciso colocar o valor 2 em outro registrador antes, já que a MUL não aceita um valor literal como operando.
Por padrão, compiladores tentam otimizar seu código sempre que podem. Por exemplo, a instrução
MOV RBX, 2
tem o mesmo efeito deMOV EBX, 2
e o compilador pode escolher utilizar esta última porque ela é menor (em número de bytes ocupados no programa). O efeito é o mesmo porque ao copiar um valor de 32-bits para um registrador de 64-bits, os 32 bits mais altos deste registrador são zerados.
A instrução DIV funciona de forma similar, mas estudar aritmética a fundo foge do nosso objetivo. Consulte um bom livro de Assembly se assim desejar.
Já explicamos o que são as operações bit-a-bit quando falamos sobre cálculo com binários. Vamos cobrir agora as 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 mais rápido. Analise:
Além de menor em bytes, a versão com XOR é também mais rápida. Em ambas as instruções, depois de executadas, o resultado é que o registrador RCX terá o valor 0 e a flag ZF será ligada, como em qualquer operação que resulte em zero.
Há também as instruções AND, OR, SHL, SHR, ROL, ROR e NOT. Todas essas operações foram cobertas no capítulo Números.
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 (que é 0xB0B0 após a instrução MOV) com 0xFE10. Como será que tal comparação é feita matematicamente? Acertou se você 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 é armazenado no registrador RFLAGS, o que significa dizer que a instrução CMP altera este registrador para que instruções futuras tomem decisões baseadas nelas. Por exemplo, para operandos iguais, como o resultado é zero, a CMP liga a zero flag no registrador RFLAGS.
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 sequência. 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 RIP (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
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á ligada.
O salto JE ocorre se ZF=1, ou seja, se a zero flag estiver ligada. 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
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 que você apresentadas aqui. O segredo é estudá-las conforme o necessário, na medida em que elas aparecerem 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.
Uma API (Application Programming Interface) é uma interface para uma aplicação "falar" com outra. 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 Windows API, pois o assunto é 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 Windows API 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:
[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)
Agora vamos explicar os parâmetros da função MessageBox:
É um parâmetro de entrada, ou seja, é uma informação que a função precisa (e não quem chamou). Neste caso, é 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" (pode rir).
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 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, que também está disponível em C a partir da C23.
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.
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 neste código. Vamos dedicar um tempo a eles. 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 letras maiúsculas de minúsculas, tanto faz escrever Windows.h
, 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 que será 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 uma linha 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 à MessageBox
.
Nas linhas 12 e 15 comparo o conteúdo da variável res
, que detém o retorno da chamada à MessageBox
. 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:
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
Vamos fazer algo um pouco mais significativo agora. Vamos pedir ao kernel do Windows que crie um arquivo para nós.
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 é executado. 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 (genericamente chamado de chip).
Um circuito integrado (CI) bastante conhecido no mundo da eletrônica é o 7400. Seu funcionamento interno é detalhado no seguinte diagrama a seguir.
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 do CI.
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? Alternativamente, uma solução seria utilizar um chip que fosse programável. Dessa forma, você o configuraria, via software, para atuar em certos pinos como porta NAND, em outros como porta OR e por aí vai, de acordo com sua necessidade. Para casos assim, existem os microcontroladores. Eles podem podem ser programados em linguagens de alto nível que contam 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 nós gravarmos um programa nele como fazemos com os microcontroladores, o próprio fabricante do processador é que grava um programa lá, de modo que este microprograma entenda diferentes instruções para realizar diferentes operações de muito mais complexidade se comparadas às simples operações booleanas. Sua entrada de dados também é muito mais larga, de modo que mais bits podem ser enviados por vez.
Isso significa que se um processador receber em seu barramento um conjunto de bits específico, sabe que vai precisar executar uma operação específica. Considere agora bytes como conjuntos de 8 bits, como já aprendemos. À estes bytes possíveis damos o nome de opcodes. Ao conjunto dos opcodes + operandos damos o nome de instrução.
Para entender melhor, suponha que queiramos então fazer uma operação OR entre os valores 0x20 e 0x18 utilizando um processador de arquitetura x86-64. Na documentação deste processador, suponha que constem as seguintes informações:
Ao receber o opcode 0xb8, os próximos quatro bytes serão um número salvos numa memória interna (similar àquela memória M+ das calculadoras).
Para acessar essa memória interna específica, utiliza-se o byte identificador 0xc8.
Ao receber o opcode 0x83, o próximo byte identifica a memória interna a ser acessada e o byte seguinte é um operando que efetua uma operação OR dele com o número de quatro bytes armazenado na memória interna.
Precisaríamos então enviar para este processador 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 (próximos 4 bytes) na memória interna. Como nosso número desejado possui somente 1 byte, preenchemos os outros três com zero, respeitando o endianness.
A segunda instrução tem 3 bytes sendo que o primeiro é o opcode dela (OR), o segundo é o identificador da memória interna e o terceiro é o nosso operando 0x18.
Temos que concordar que criar um programa assim não seria nada fácil. Para resolver este problema foi criada uma linguagem de programação, completamente atrelada à arquitetura do processador, aos seus opcodes e às suas instruções, chamada Assembly. Ela dá nomes, por exemplo, para a tal memória interna, que são chamadas de registradores. Além disso, ao invés de usar os opcodes numéricos, você pode usar mnemônicos (palavras) em inglês para sinalizar a instrução desejada. Veja se não é mais fácil de entender assim:
De posse de um compilador de Assembly, muitas vezes chamado de assembler (ou montador em Português), 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 essa compatibilidade.
Perceba que Assembly é uma linguagem legível para seres humanos, diferente da linguagem de máquina que não passa de uma "tripa de bytes" onde você tem que sar seu jeito para fazer sentido.
Como afirmados antes, as palavras em linguagem Assembly que você usa para sinalizar instruções são chamadas de mnemônicos. No exemplo de código acima, utilizamos dois: o MOV e o OR. Estudaremos mais mnemônicos em breve.
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.
É muito comum programas 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 quanto 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 uma das seguintes macros:
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 à CreateFile
, é 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. Teste-o e se tudo funcionar, 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 especiais. Podemos usar NULL
já que é um parâmetro opcional.
A WriteFile
retorna TRUE
se a escrita teve sucesso 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 handle dele 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
no diretório do projeto do Visual Studio e escrever o texto "Programando usando a API do Windows" nele. Vamos agora ver como fazer para acessar o registro do Windows através da API.
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.
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 pontos diferentes no programa. Por exemplo, suponha que um programa em Python 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 geraria o mesmo conjunto de instruções várias vezes, ocupando um espaço desnecessário no binário final. Toda esta repetição também prejudica a manutenção do código, pois se precisarmos fazer uma alteração no cálculo, teríamos que alterar em todos os pontos onde o cálculo é feito. É 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 pode ter:
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.
Um nome (na visão de quem programa) 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). Algumas linguagens mais antas de programação, no entanto, possuem tanto funções quanto procedimentos. Estes últimos são "funções que não retornam nada". É possível também que você encontre estes termos sendo usados como sinônimos.
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 função, encontramos normalmente a instrução RET. Vamos analisar um programa cuja função principal chama uma simples função de soma:
Olha como este programa pode ficar ao ser compilado no Windows em 64-bits:
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 0x14000102E em nosso exemplo) chama a função soma() em 0x140001010 e a instrução RET (em 0x140001014) retorna para a instrução imediatamente após a CALL (0x140001033), para que a execução continue.
Uma vez entendido isso, vamos agora ver como os argumentos são passados para as funções.
A escolha das instruções que serão utilizadas para representar fielmente um código-fonte em uma linguagem de alto nível é uma decisão do compilador. No caso do exemplo com a função soma(), isso fica a cargo do compilador de C. Há incontáveis maneiras de se fazer a mesma coisa, o que pode envolver o uso de diferentes instruções, em diferentes contextos.
Mas há uma área onde o sistema operacional coloca algumas regras. Uma delas diz respeito a como as funções serão chamadas pelos binários compilados. Essas regras são conhecidas como convenções de chamadas. Elas fazem parte do que chamamos de Application Binary Interface (ABI), um conjunto de regras para os compiladores seguirem de modo que tudo corra bem com os binários compilados.
A convenção mais utilizada no Windows em 64-bits estabelece, dentre outras coisas, que:
Os parâmetros do tipo inteiro serão passados nos registradores RCX, RDX, R8 e R9, nesta ordem.
Se houver mais de quatro parâmetros, os excedentes são passados pela pilha. Falaremos mais da pilha em breve.
O retorno é em RAX.
Voltando ao nosso exemplo de código, o trecho soma(3, 4)
gerou, em Assembly:
A convenção foi de fato seguida. O segundo parâmetro, o literal 4, foi posto em EDX. Como este MOV zera a parte alta de RDX, é o mesmo que dizer que o parâmetro foi posto em RDX.
O segundo parâmetro foi posto em RCX normalmente.
Agora vamos analisar como a função soma()
recupera os parâmetros e retorna:
A primeira instrução soma os parâmetros recebidos e os armazenas em ECX. A segunda copia este resultado para EAX, porque o retorno precisa estar nele. Depois vem o RET, que desempilha o endereço da instrução após a CALL e põe em RIP.
Estamos falando em pilha tem tempo, mas ainda não a detalhamos. Vamos agora entender como essa estrutura funciona.
A memória RAM para um processo é dividida em áreas com diferentes propósitos. Uma delas é a pilha, ou stack em inglês.
Essa área de memória funciona de forma que o que é colocado lá fica no topo e o último dado colocado na pilha é o primeiro a ser retirado, como uma pilha de pratos ou de cartas de baralho. Esse esquema é conhecido por LIFO (Last In First Out), ou seja, o “o último dado a entrar é o primeiro a sair”.
Existem duas operações possíveis na pilha:
Adicionar um dado (empilhar).
Remover um dado (desempilhar).
Tanto para empilhar quanto para desempilhar um dado, é necessário conhecer o endereço do topo da pilha. O registrador que, por convenção, sempre tem essa informação é o RSP.
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 RAX 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 para o topo da pilha, a instrução PUSH decrementa o registrador RSP em 8 unidades, o tamanho da palavra em 64-bits.
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 RDX. Além disso, o registrador RSP será incrementado em 8 unidades.
Temos também a instrução CALL, que faz duas coisas:
Empilha o endereço da próxima instrução.
Coloca o seu parâmetro, ou seja, o endereço da função a ser chamada, no registrador RIP.
Por conta dessa atualização do RIP, 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 RIP.
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.
Em 32-bits, as convenções de chamadas mais usadas usavam a pilha quase que exclusivamente para a passagem de parâmetros, mas aqui em 64-bits ela só é usada para funções com mais de quatro parâmetros. Vamos ver um exemplo comentado:
Para este exemplo eu não pus o código-fonte de propósito, afinal este é um livro de engenharia reversa e precisamos começar a nos acostumar com isso. :)
Este exemplo pode gerar dúvidas. Vamos lá:
Por que reservar tanto espaço na pilha? Esta foi uma decisão do compilador. Acontece que a convenção de chamadas é um pouco mais complexa do que cobrimos aqui. Existe um espaço chamado de shadow space que precisa ser reservado. Ele existe por vários motivos, mas o principal é que a função chamada pode salvar os parâmetros recebidos por registradores na pilha se precisar. Só ele já precisa de 32 bytes pois são quatro parâmetros passados por registrador, de 8 bytes cada.
Ok, mas e os outros 40 bytes? Destes 40, 16 serão usados pelos dois argumentos copiados para a pilha (os literais 6 e 5). Outros 8 são usados para a variável local que guarda o resultado. E por fim, há um alinhamento em 16 bytes exigido pela ABI. O assunto foge do nosso escopo aqui, mas encorajo você a pesquisar sobre.
O que é dword ptr ss:[rsp+28]?
“dword” significa double word e isto nos diz que a instrução está trabalhando com dados de 4 bytes.
"ss" abrevia stack segment e nos conta que o endereço está na stack.
Os colchetes são uma derreferência. Significa que o conteúdo sera armazenado (isto está no operado de destino de um MOV) no endereço apontado por RSP + 28 (em hexa).
Sabendo disso, o código que gerou essas instruções provavelmente foi algo como:
Uma curiosidade: mesmo sem saber o tamanho de um tipo int
em C, pelos registradores usados nas instruções, dá para saber que são de 32-bits. No final é isso: para quem lê Assembly, todo programa é open source. :)
Com isso podemos partir para uma análise mais real. Na próxima seção vamos ver como fica um programa que usa uma função da API do Windows em Assembly.
Veja este código:
Mesmo que não conhecêssemos a função MessageBoxW, dá para ver que ela está recebendo 4 parâmetros. Considerando a convenção de chamadas, temos:
O primeiro zero é o NULL do C. Depois vem o endereço da string que possui o conteúdo a ser exibido na mensagem, seguido pelo endereço da string de título. Por fim, outro zero, provavelmente expandido de MB_OK
. Sabendo que cada instrução é composta de bytes (opcodes e parâmetros), no que você acha que consiste a engenharia reversa senão em entender e poder alterar tais bytes de acordo com o que desejarmos? É este o poder que a engenharia reversa te dá, mas ela pede algo em troca: estudo. Veja o quanto você já leu até chegar aqui. Parabéns!
Assembly é, por si só, um assunto extenso e bastante atrelado à arquitetura na qual se está trabalhando. Este capítulo apresentou uma introdução ao Assembly x86-64 e considerou o Windows como plataforma. Dois bons recursos de Assembly, são os livros gratuitos Aprendendo Assembly, do Felipe Silva e Linguagem Assembly para i386 e x86-64, do Frederico Pissara, ambos disponíveis em menteb.in.
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.
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:
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.
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!
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 . Com ele estudaremos os conceitos de engenharia reversa que precisamos para criar um fundamento sólido para avançar nesta área.
Você também pode assistir a , que trata sobre este assunto.
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.
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.
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
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.
Analisam estaticamente os binários, sem carregá-los. São úteis para uma primeira visão sobre um executável desconhecido.
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
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
Livre
Livre
Multiplataforma, escrito com base no NASM pra ser um substituto mas acho que não vingou. hehe
Comercial
Livre
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
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.
Livre
Debugger para Linux que implementa a "depuração reversa", isto é, você executa uma vez o alvo e o rr grava tudo. Depois você depura essa gravação.
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.
Livre
Talvez o melhor da categoria. Mostra os handles, objetos, processos, quem tá usando o que e muita mais.
Existem outros projetos como Limon, Detux, HaboMalHunter, mas na lista abaixo procurei deixar somente os que estão ativos.
Livre
Possui uma API bem simples e suporta binários ELF compilados para x86, x86-64, ARM e MIPS.
Esta lista não inclui serviços de sandbox puramente comerciais.
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.
Abaixo um comparativo onde dumpamos os primeiros 32 bytes de um binário /bin/ls
utilizando os visualizadores acima:
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.
E possível encontrar versões anteriores desta tabela, mas a mais usada é a seguinte:
Editor REPL genérico (não só para executáveis) com uma abordagem muito interessante. Escrevemos um sobre.
Anteriormente chamado de pev, este é o nosso 💚 toolkit de ferramentas de linha de comando para análise de PE. Artigo introdutório .
Atualmente já vem com o Visual Studio da Microsoft, mesmo na versão Community. Segue um .
Multiplataforma, suporte à sintaxe Intel e bem popular. Veja um .
Novo disassembler que teoricamente compete com o IDA. Possui versão mediante registro.
Disassembler livre lançado pela NSA. Temos um em vídeo sobre ele.
Super conhecido debugger pra Linux do projeto GNU. Tem uma também. É modo texto, mas possui vários front-ends como , dentre .
(antigo Process Hacker)
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 expandido. 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.
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
.
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.
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.
arquivo.txt
no editor hexadecimal