arrow-left

All pages
gitbookPowered by GitBook
1 of 4

Loading...

Loading...

Loading...

Loading...

Registradores

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.

hashtag
Registradores de Uso Geral

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:

Registrador
Significado
Uso sugerido

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.

hashtag
Subregistradores

Vários GPRs (mas não todos) podem ser subdivididos em registradores menores para fins de compatibilidade. Vamos analisar como isso se dá.

hashtag
Subregistradores de RAX, RBX, RCX e RDX

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.

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. :)

hashtag
Subregistradores de RSI, RDI, RBP e RSP

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.

hashtag
Subregistradores de R8-R15

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.

hashtag
Exercícios

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.

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.

hashtag
Ponteiro de Instrução

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.

Endereço Virtual (VA)
Opcodes e parâmetros
Assembly

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.

hashtag
Registrador de Flags

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:

Bit
Nome
Sigla
Descriçã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.

Instruções Básicas

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.

hashtag
Copiando Valores

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:

hashtag
Aritmética

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 de MOV 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.

hashtag
Operações Bit-a-bit

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.

hashtag
Comparando Valores

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.

hashtag
Alterando o Fluxo do Programa

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).

hashtag
Salto Incondicional

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.

hashtag
Saltos Condicionais Sem Sinal

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:

Instrução
Alternativa
Condição

hashtag
Saltos Condicionais Com Sinal

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:

Instrução
Alternativa
Condição

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.

opcode operando1, operando2, operando3
B8 E9 07 00 00

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

O registrador AL é a parte baixa de AX. Ele tem, também, 8 bits.
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.

  • 11

    Overflow

    OF

    Estouro para números com sinal.

    RAX

    Acumulador

    Usado em operações aritiméticas

    RBX

    Base

    Ponteiro para dados

    RCX

    Contador

    Contador em repetições

    RDX

    Dados

    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

    Usado em operações de E/S

    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.

    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

    JS (Sign)

    SF=1

    JNS (Not Sign)

    SF=0

    JO (Overflow)

    OF=1

    JNO (Not Overflow)

    OF=0

    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)

    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)

    CF=0

      63                            32 31            16 15     8 7      0
     +--------------------------------+----------------+--------+--------+
     | RAX                                                               |
     +--------------------------------+----------------+--------+--------+
                                      | EAX                              |
                                      +----------------+--------+--------+
                                                       | AX              |
                                                       +--------+--------+
                                                       | AH     | AL     |
                                                       +--------+--------+
    mov rax, 0x1122334455667788 ; copia um número de 64-bits para RAX
      63                            32 31            16 15     8 7      0
     +--------------------------------+----------------+--------+--------+
     | RSI                                                               |
     +--------------------------------+----------------+--------+--------+
                                      | ESI                              |
                                      +----------------+--------+--------+
                                                       | SI              |
                                                       +--------+--------+
                                                                | SIL    |
                                                                +--------+
      63                            32 31            16 15     8 7      0
     +--------------------------------+----------------+--------+--------+
     | R8                                                               |
     +--------------------------------+----------------+--------+--------+
                                      | R8D                              |
                                      +----------------+--------+--------+
                                                       | R8W             |
                                                       +--------+--------+
                                                                | R8B    |
                                                                +--------+
    format PE64 GUI
    entry start
    
    section '.text' code readable executable
    
      start:
            mov eax, 0x20
            or eax, 0x18
    mov rbx, 1122334455667788
    48 BB 88 77 66 55 44 33 22 11
    mov rcx, 7
    add rcx, 1
    mov rcx, 7
    inc rcx
    mov rax, 5
    mov rbx, 2
    mul rbx
    mov rcx, 0
    xor rcx, rcx
    mov    eax, 0xb0b0
    cmp    eax, 0xfe10
       0:    b8 01 00 00 00           mov    eax,0x1
       5:    eb 03                    jmp    0xa
       7:    83 c0 04                 add    eax,0x4
       a:    40                       inc    eax
       0:    b8 01 00 00 00           mov    eax,0x1
       5:    83 f8 01                 cmp    eax,0x1
       8:    74 03                    je     0xd
       a:    83 c0 03                 add    eax,0x3
       d:    40                       inc    eax

    Assembly

    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:

    A
    B
    A & B
    S

    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

    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" sem sentido em princípio.

    Como afirmado 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.

    Funções e Pilha

    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.

    hashtag
    O que é uma Função

    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:

    1. Argumentos, também chamados de parâmetros, que são os dados que a função recebe, necessários para cumprir seu propósito.

    2. Retorno, que é o resultado da conclusão do seu propósito.

    3. 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.

    hashtag
    Funções em Assembly

    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.

    hashtag
    Passagem de parâmetros

    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.

    hashtag
    A Pilha de Memória

    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:

    1. Adicionar um dado (empilhar).

    2. 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:

    1. Empilha o endereço da próxima instrução.

    2. 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:

    1. 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.

    hashtag
    Passagem de parâmetros pela pilha

    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.

    hashtag
    Análise da MessageBox

    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.

    fahrenheit = 230.4
    celsius = (fahrenheit - 32) * 5 / 9
    print(celsius)
    
    fahrenheit = 130.3
    celsius = (fahrenheit - 32) * 5 / 9
    print(celsius)
    
    fahrenheit = 90.1
    celsius = (fahrenheit - 32) * 5 / 9
    print(celsius)
    110.22222222222223
    54.611111111111114
    32.27777777777778
    def fahrenheit2celsius(fahrenheit):
        return (fahrenheit - 32) * 5 / 9
    
    celsius = fahrenheit2celsius(230.4)
    print(celsius)
    
    celsius = fahrenheit2celsius(130.3)
    print(celsius)
    
    celsius = fahrenheit2celsius(90.1)
    print(celsius)
    int soma(int x, int y) {
    	return x + y;
    }
    
    int main(void) {
    	int res = soma(3, 4);
    	return 0;
    }
    <soma>:
    	140001010 | add ecx, edx
    	140001012 | mov eax, ecx
    	140001014 | ret
    
    <main>:
        140001020 | sub rsp, 38                                  
    	140001024 | mov edx, 4
    	140001029 | mov ecx, 3
    	14000102E | call 140001010
    	140001033 | mov dword ptr ss:[rsp+20], eax
    	140001037 | xor eax, eax
    	140001039 | add rsp, 38
    	14000103D | ret
    mov edx, 4
    mov ecx, 3
    call 140001010
    add ecx, edx
    mov eax, ecx
    ret
    push rax
    push 1
    pop rdx
    ; reserva 72 bytes (0x48) na pilha
    sub rsp, 48
    ; copia o sexto argumento para a pilha
    mov dword ptr ss:[rsp+28], 6
    ; copia o quinto argumento para a pilha
    mov dword ptr ss:[rsp+20], 5
    ; quarto argumento em R9D
    mov r9d, 4
    ; terceiro em R8D
    mov r8d, 3
    ; segundo em EDX
    mov edx, 2
    ; primeiro em ECX
    mov ecx, 1
    ; empilha o endereço da MOV após a CALL
    ; e desvia o fluxo para a função soma()
    call soma.140001010
    ; armazena o retorno numa variável local na pilha
    mov dword ptr ss:[rsp+30], eax
    ; zera EAX, que contém o retorno da main()
    xor eax,eax
    ; libera os bytes pré-reservados
    add rsp, 48
    ; retorna para o sistema operacional / fim da main()
    ret
    int main(void) {
    	int res = soma(1, 2, 3, 4, 5, 6);
    	return 0;
    }
    sub rsp, 28
    xor r9d, r9d
    lea r8, qword ptr ds:[140002020]
    lea rdx, qword ptr ds:[140002038]
    xor ecx, ecx
    call qword ptr ds:[<MessageBoxW>]
    xor ecx, ecx
    call qword ptr ds:[<ExitProcess>]
    nop
    add rsp, 28
    ret
    MessageBoxW(0, 0x14002038, 0x140002020, 0);

    1

    1

    0

    bytes
    armazenado na memória interna.

    0

    0

    0

    1

    1

    0

    0

    1

    0

    1

    0

    1

    1

    B8 20 00 00 00
    83 C8 18
    MOV EAX, 20 ; Coloca o valor 0x20 no registrador EAX
    OR EAX, 18  ; Faz um OR do valor em EAX com 0x18 e salva o resultado em EAX