Para um bom Engenheiro Reverso, um pingo de Assembly já é o suficiente para torná-lo capaz de compreender bem a estrutura do programa que está sendo debugado.
- Cabeçalho
- Registradores
- Arquitetura 64 bits e 32 bits
- Conhecendo os registradores
- Diferentes tipos de dados
- Registradores extras - Memória
- Segmento de montagem
- Visão geral das seções de memória
- Stack, Heap, Program Image, PEB & TEB
- Stack Frames
- Endianidade
- Armazenamento de dados
- RBP & RSP em 64 bits - Instruções
- Dados, Fluxos & Ponteiros
- Registradores
- Os processadores possuem uma área física em seus chips para armazenamento de dados chamada de registradores, justamente porque registram (salvam) um número por tempo, mesmo este sendo não determinado.
- Registradores: Arquitetura 64 bits e 32 bits
- Bom, você precisa compreender uma situação que fará a diferença em toda a sua jornada como um Engenheiro Reverso! Registradores em arquitetura 64 bits utilizam a letra R no início de seus nomes e Registradores em arquitetura 32 bits utilizam a letra E no início de seus nomes.
- Ex: (64 BITS) — Registrador RAX | (32 BITS) — Registrador EAX
São os mesmos registradores, porém para cada arquitetura eles possuem a nomenclatura diferente. - Cada registro pode ser dividio em segmentos menores que podem ser referenciados com outros nomes de registros. RAX tem 64 bits, os 32 bits inferiores podem ser referenciados com EAX e os 16 bits inferiores podem ser referenciados com AX. AX é dividído em dois fragmentos de 8 bits. Os 8 bits superiores de AX podem ser referenciados com AH. Os 8 bits inferiores podem ser referenciados com AL.
- RAX consiste em todos os 8 bytes que seriam os bytes 0–7. EAX consiste nos bytes 4–7, AX consiste nos bytes 6–7, AH consiste apenas no byte 6 e AL consiste apenas no byte 7 (o byte final).
- Se 0x0123456789ABCDEF foi carregado em um registro de 64 bits, como RAX, então RAX refere-se a 0x0123456789ABCDEF, EAX refere-se a 0x89ABCDEF, AX refere-se a 0xCDEF, AH refere-se a 0xCD, AL refere-se a 0xEF. O 0x não estaria realmente lá, ele é usado apenas para denotar que estamos trabalhando com números hexadecimais.
- Qual é a diferença entre “E” e “R”? O “E” significa estendido . O “R” significa registrador . Lembre-se, os “registradores E” são registradores de 32 bits. Ao olhar para a montagem x32, você verá EAX em vez de RAX (RAX não existe em x32).
- Registradores: Conhecendo os registradores
- Vamos falar de Registros de Uso Geral (GPR). Você pode pensar nelas como variáveis porque é basicamente o que elas são. Sua CPU tem seu próprio armazenamento de dados que é extremamente rápido. Isso é útil, no entanto, o espaço na CPU é extramemente limitado. Quaisquer dados que a CPU não pode armazenar por conta própria são armazenadas na memória RAM. A memória (RAM) é muito mais lenta para a CPU usar. Por causa da baixa velocidade, a CPU tenta colocar os dados em registradores em vez da memória, se puder.
- Existem 8 principais registradores principais:
- RAX — Conhecido como Registrador do Acumulador. Frequentemente usado para retornar o valor de retorno de uma função. (Significado: Accumulator)
- RBX — Algumas vezes conhecido como Registrador de Base. Às vezes usado como ponteiro base para acesso a memória. (Significado: Base)
- RDX — Algumas vezes conhecido como Registrador de Dados. (Significado: Data)
- RCX — Algumas vezes conhecido como Registrador do Contador. Usado como contador de loop. (Significado: Counter)
- RSI — Conhecido como Índice de Origem. Usado como ponteiro de origem em operações de string. (Significado: Source Index)
- RDI — Conhecido como Índice de Destino. Usado como ponteiro de destino em operações de string. (Significado: Destination Index)
- RSP — O ponteiro da pilha. Contém o endereço do topo da pilha. (Significado: Stack Pointer)
- RBP — O ponteiro base. Contém o endereço da base (parte inferior) da pilha. (Significado: Base Pointer) - Todos os registradores são utilizados para armazenar dados. Algo que quero deixar claro imediatamente é que esses registradores podem ser usados para qualquer coisa. Seu “uso” é apenas uma prática comum. Por exemplo, RAX geralmente é para armazenar o valor de retorno de uma função. Não precisa, mas image que você esteja escrevendo um programa em Assembly. Seria extremamente útil saber para onde foi o valor de retorno de uma função, caso contrário, por que chamar a função? Observe este código abaixo: Ele usa RAX para armazenar a variável x.
if(x == 6){
function();
} else {
return;
}
mov RAX, x
cmp RAX, 6
jne 5
call function
ret
- Também deve-se observar que, embora você possa usar esses registradores para qualquer coisa, há alguns registradores que devem ser deixados de lado ao lidar com dados típicos. Por exemplo, RSP e RBP quase sempre devem ser usados apenas para o que foram projetados. Eles armazenam a localização do quadro de pilha atual (entraremos na pilha em breve), o que é muito importante. Você pode usar RSP e RBP para armazenar dados normais, mas deseja salvar seus valores anteriores para poder restaurá-los ao estado original quando terminar de usá-los.
- Registradores: Diferentes tipos de dados
- Valores de ponto flutuante — Floats e Double
- Valores inteiros — Int, bool, char, pointer, etc.
- Diferentes tipos de dados não podem ser colocados em qualquer registrador. Os valores de ponto flutuante são representados de forma diferente dos números inteiros. Por causa disso, os valores de ponto flutuante possuem registros especiais. Esses registros incluem YMM0 a YMM15 (64 bits) e XMM0 a XMM15 (32 bits). Os registradores XMM são a metade inferior dos registradores YMM, semelhante a como EAX são os 32 bits inferiores de RAX. Algo único sobre esses registradores é que eles podem ser tratados como arrays. Em outras palavras, eles podem conter vários valores. Por exemplo, os registradores YMM# têm 256 bits de largura cada e podem conter 4 valores de 64 bits ou 8 valores de 32 bits. Da mesma forma, XMM# tem 128 bits de largura cada e pode conter 2 valores de 64 bits ou 4 valores de 32 bits. Instruções especiais são necessárias para utilizar esses registradores como vetores.
Uma boa tabela desses registros e mais informações sobre eles pode ser encontrada no Wikipédia.
- Registradores: Registradores extras
- Existem registradores extras que precisam ser mencionados. Esses registradores não são de usos especiais ou algo do tipo. Existem registradores r8 a r15 que são projetados para serem usados por valores do tipo inteiro (não floats ou doubles). Os 4 bytes inferiores (32 bits), 2 bytes (16 bits) e 8 bits (1 byte) podem ser acessados. Eles podem ser acessados anexando a letra “d”, “w” ou “b”.
- R8 — Registro completo de 64 bits (8 bytes).
- R8D — Palavra dupla inferior (4 bytes).
- R8W — palavra inferior (2 bytes).
- R8B — Byte inferior.
- Memória
- A memória do sistema é organizada de uma forma específica. Isso é feito para garantir que tudo tenha um lugar para residir.
- Memória: Segmento de Montagem
- Existem diferentes segmentos/seções nos quais dados ou códigos são armazenados. Eles são dispostos na seguinte ordem:
- Stack — Contém variáveis locais não estáticas. Irei falar brevemente sobre.
- Heap — Contém dados alocados dinâmicamente que podem ser inicializados inicialmente.
- .data — Contém dados globais e estáticos inicializados com um valor diferente de zero.
- .bss — Contém dados globais e estáticos não inicializados ou inicializados em zero.
- .text — Contém o código do programa.
- Memória: Visão geral das seções de memória
- Aqui está uma visão geral de como a memória é disposta no Windows. Isso é extremamente simplificado.
- Importante:
O diagrama acima mostra as variáveis de direção (e quaisquer dados nomeados, até mesmo estruturas) que são colocadas ou retiradas da memória. Os dados reais são colocados na memória de forma diferente. É por isso que os diagramas de pilha variam tanto. Muitas vezes, você verá diagramas de pilha com a pilha e o heap crescendo um em direção ao outro ou endereços de alta memória no topo. Vou explicar mais depois . O diagrama que estou mostrando é o mais relevante para engenharia reversa. Endereços baixos no topo também são a representação mais realista.
- Memória: Stack, Heap, Program Image, PEB & TEB
- Stack (Pilha) — Área na memória que pode ser usada rapidamente para alocação de dados estáticos. Imagine a pilha com endereços baixos no topo e endereços altos na parte inferior. Isso é idêntico a uma lista numérica normal. Os dados são lidos e gravados como “último a entrar, primeiro a sair” (LIFO). A estrutura LIFO da pilha geralmente é representada por uma pilha de placas. Você não pode simplesmente tirar o terceiro prato de cima, você tem que tirar um prato de cada vez para chegar até ele. Você só pode acessar o dado que está no topo da pilha, então para acessar outros dados você precisa mover o que está no topo para fora do caminho. Quando eu disse que a pilha contém dados estáticos, estou me referindo a dados que têm um comprimento conhecido, como um número inteiro. O tamanho de um inteiro é definido em tempo de compilação, o tamanho normalmente é de 4 bytes, então podemos jogá-lo na pilha.No entanto , o endereço/localização da entrada provavelmente será armazenado na pilha para referência futura. Quando você coloca dados no topo da pilha, você os empurra para a pilha. Quando os dados são colocados na pilha, a pilha cresce, em direção aos endereços de memória mais baixos. Quando você remove um pedaço de dados do topo da pilha, você o retira da pilha. Quando os dados são retirados da pilha, a pilha diminui, em direção a endereços mais altos. Tudo isso pode parecer estranho, mas lembre-se, é como uma lista numérica normal onde 1, o menor número, está no topo. 10, o número mais alto, está na parte inferior. Dois registradores são usados para controlar a pilha. O ponteiro da pilha (RSP/ESP/SP)é usado para acompanhar o topo da pilha e o ponteiro base (RBP/EBP/BP) é usado para acompanhar a base/fundo da pilha. Isso significa que, quando os dados são colocados na pilha, o ponteiro da pilha diminui, pois a pilha cresceu em direção a endereços mais baixos. O ponteiro base não tem motivo para mudar quando empurramos ou retiramos algo da pilha. Falaremos mais sobre o ponteiro de pilha e o ponteiro de base com o passar do tempo.
- Heap — Semelhante à pilha, mas usado para alocação dinâmica e é um pouco mais lento para acessar. A pilha é normalmente usada para dados que são dinâmicos (mutáveis ou imprevisíveis). Coisas como estruturas e entrada do usuário podem ser armazenadas no heap. Se o tamanho dos dados não for conhecido em tempo de compilação, eles geralmente serão armazenados no heap. Quando você adiciona dados ao heap, ele cresce em direção a endereços mais altos.
- Program Image — Este é o programa/executável carregado na memória. No Windows, normalmente é um Portable Executable (PE) .
- TEB — O Thread Environment Block (TEB) armazena informações sobre o(s) thread(s) em execução no momento.
- PEB — O Process Environment Block (PEB) armazena informações sobre o processo e os módulos carregados. Uma informação que o PEB contém é “BeingDebugged”, que pode ser usado para determinar se o processo atual está sendo depurado.
Layout da estrutura PEB: Microsoft. - Stack Frame — Stack Frame são blocos de dados para funções. Esses dados incluem variáveis locais, o ponteiro base salvo, o endereço de retorno do chamador e os parâmetros da função. Considere o seguinte exemplo:
int Hello(int x) {
return x*x;
}
int main() {
int num = 3;
Hello(3);
}
- Neste exemplo, a função
main()
é chamada primeiro. Quandomain()
é chamado, um Stack Frame é criado para ele. O stack frame paramain()
, antes da chamada da função paraHello()
, inclui a variável localnum
e os parâmetros passados para ela (neste caso não há parâmetros passados para main). quandomain()
chamaHello()
o ponteiro base (RBP) e o endereço de retorno são salvos. Lembre-se, o ponteiro base aponta para a base/fundo da pilha. O ponteiro base é salvo porque quando uma função é chamada, o ponteiro base é atualizado para apontar para a base da pilha dessa função. Depois que a função retorna, o ponteiro base é restaurado para que aponte para a base do quadro de pilha do chamador. O endereço de retorno é salvo assim que a função retornar, o programa saberá onde retomar a execução. O endereço de retorno é a próxima instrução após a chamada da função. Portanto, neste caso, o endereço de retorno é o fim da funçãomain()
. Isso pode parecer confuso, espero que isso possa esclarecer:
mov RAX, 15
call function
mov RBX, 23
- Eu sei que isso pode ser um pouco confuso, mas é bastante simples em como funciona. Pode não ser intuitivo no começo. É simplesmente dizer ao computador para onde ir (qual instrução executar) quando a função retornar. Você não quer que ele execute a instrução que chamou a função porque isso causará um loop infinito. É por isso que a próxima instrução é usada como endereço de retorno. Portanto, no exemplo acima, RAX é definido como 15, então a função
function
é chamada. Assim que retornar, começará a executar no endereço de retorno, que é a linha que contémmov RBX, 23
.
- Memória: Endianidade
- Dado o valor de 0xDEADBEEF, como ele deve ser armazenado na memória? Isso tem sido debatido por um tempo e ainda gera argumentos hoje. A princípio, pode parecer intuitivo armazená-lo como está, mas quando você pensa nisso da perspectiva de um computador, não é tão simples. Por causa disso, existem duas maneiras pelas quais os computadores podem armazenar dados na memória — big-endian e little-endian.
- Big Endian — O byte mais significativo (extrema esquerda) é armazenado primeiro. Isso seria 0xDEADBEEF do exemplo.
- Little Endian — O byte menos significativo (extrema direita) é armazenado primeiro. Isso seria 0xEFBEADDE do exemplo.
- Memória: Armazenamento de Dados
- Conforme prometido, explicarei como os dados são gravados na memória. É um pouco diferente de como o espaço é alocado para dados. Como uma rápida recapitulação, o espaço é alocado para variáveis de baixo para cima, ou endereços superiores para endereços inferiores. Os dados são colocados neste espaço alocado de maneira muito simples. É como escrever em inglês: left to right, top to bottom. O primeiro dado está no endereço mais baixo. As posições dos dados são referenciadas com base na distância que estão do endereço do primeiro byte de dados, conhecido como endereço base (ou apenas endereço), da variável.
Por exemplo, digamos que temos alguns dados, 12345678. Só para enfatizar, digamos também que cada número tem 2 bytes. Com esta informação, 1 está no deslocamento 0x0, 2 está no deslocamento 0x2, 3 está no deslocamento 0x4, 4 está no deslocamento 0x6 e assim por diante.
Novamente, este é um conceito bastante simples, mas você precisa ter certeza de que o entende.
Outra maneira de dizer tudo isso é que os dados são colocados em seu espaço alocado na direção oposta à alocação do espaço para variáveis.
- Este diagrama ilustra duas coisas. Primeiro, como os dados são colocados em seu espaço alocado. Em segundo lugar, um efeito colateral de como os dados são colocados em sua memória alocada. Vou quebrar o diagrama. À esquerda estão as variáveis que estão sendo criadas. À direita estão os resultados dessas criações variáveis. Vou me concentrar apenas na pilha, por enquanto, a pilha pode ser calculada a partir daí.
- À esquerda, três variáveis recebem valores. A primeira variável, conforme explicado anteriormente, é colocada na parte inferior. A próxima variável é colocada em cima disso, e a próxima em cima disso.
- Depois de alocar o espaço para as variáveis, os dados são colocados nessas variáveis. É tudo muito simples, mas algo interessante está acontecendo com o array. Observe como ele alocou apenas uma matriz de 2 elementos, mas recebeu 3. Como os dados são gravados do endereço inferior para o superior ou da esquerda para a direita e de cima para baixo, ele sobrescreve os dados da variável abaixo dele. Então, em vez destackVar2
ser 2, é substituído pelo 5 que deveria estar emstackArr[2]
. - Espero que tudo faça sentido. Aqui está uma rápida recapitulação:
As variáveis são alocadas na pilha, uma em cima da outra, como uma pilha de bandejas. Isso significa que eles são colocados na pilha de endereços mais altos para endereços mais baixos.
Os dados são colocados nas variáveis da esquerda para a direita, de cima para baixo. Ou seja, dos endereços mais baixos para os mais altos.
É um conceito simples, não complique demais só porque dei uma longa explicação. É vital que você o entenda, e é por isso que dediquei tanto tempo para explicar esse conceito. É por causa desses conceitos que existem tantas representações de memória por aí que vão em direções diferentes.
- Memória: RBP e RSP em x64
- Em x64, é comum ver o RBP usado de maneira não tradicional. Às vezes, apenas RSP é usado para apontar para dados na pilha, como variáveis locais e parâmetros de função, e RBP é usado para dados gerais (semelhante ao RAX).
- Instruções
- A capacidade de ler e compreender o código Assembly é vital para a engenharia reversa. Existem aproximadamente 1.500 instruções, no entanto, a maioria das instruções não são comumente usadas ou são apenas variações (como MOV e MOVS). Assim como na programação de alto nível, não hesite em procurar algo que você não conhece.
- Antes de começarmos, existem três termos diferentes que você deve conhecer: immediate, register e memory.
- Immediate — Um valor imediato (ou apenas immediate, às vezes IM) é algo como o número 12. Um valor imediato não é um endereço de memória ou registro, em vez disso, é algum tipo de dado constante.
- Register — Um registrador está se referindo a algo como RAX, RBX, R12, AL, etc.
- Memory — Memória ou um endereço de memória refere-se a um local na memória (um endereço de memória), como 0x7FFF842B.
- É importante conhecer o formato das instruções que é o seguinte:
(Instruction/Opcode/Mnemonic) <Destination Operand>, <Source Operand>
Exemplo:
mov RAX, 5
MOV é a instrução, RAX é o operando de destino e 5 é o operando de origem. A capitalização de instruções ou operandos não importa. Você me verá usar uma mistura de todas as letras maiúsculas e todas as letras minúsculas. No exemplo dado, 5 é um valor imediato porque não é um endereço de memória válido e certamente não é um registrador.
- Instruções: Dados
- MOV é usado para mover/armazenar o operando fonte no destino. A origem não precisa ser um valor imediato como no exemplo a seguir. No exemplo a seguir, o valor imediato de 5 está sendo movido para RAX.
Isso é equivalente a RAX = 5.
mov RAX, 5
- LEA é a abreviação de Load Effective Address. Isso é essencialmente o mesmo que MOV, exceto para endereços. Também é comumente usado para calcular endereços. No exemplo a seguir, RAX conterá o endereço/localização da memória de num1.
lea RAX, num1
- PUSH é usado para enviar dados para a pilha. Empurrar refere-se a colocar algo no topo da pilha. No exemplo a seguir, RAX é colocado na pilha. O envio funcionará como uma cópia, de modo que o RAX ainda conterá o valor que tinha antes de ser enviado.
push RAX
- O POP é usado para pegar o que estiver no topo da pilha e armazená-lo no destino. No exemplo a seguir, o que estiver no topo da pilha será colocado no RAX.
pop RAX
- Instruções: Aritmética
- INC incrementará os dados em um. No exemplo a seguir, RAX é definido como 8 e depois incrementado. RAX será 9 no final.
mov RAX, 8
inc RAX
- DEC diminui um valor. No exemplo a seguir, RAX termina com o valor 7.
mov RAX, 8
dec RAX
- ADD adiciona uma origem a um destino e armazena o resultado no destino. No exemplo a seguir, 2 é movido para RAX, 3 para RBX e, em seguida, são adicionados. O resultado (5) é então armazenado no RAX.
O mesmo que RAX = RAX + RBX ou RAX += RBX.
mov RAX, 2
mov RBX, 3
add RAX, RBX
- SUB subtrai uma origem de um destino e armazena o resultado no destino. No exemplo a seguir, RAX terminará com o valor 2.
O mesmo que RAX = RAX — RBX ou RAX -= RBX.
mov RAX, 5
mov RBX, 3
sub RAX, RBX
- MUL (sem sinal) ou IMUL (com sinal ) multiplica o destino pela fonte. O resultado é armazenado no destino. IMUL é usado para assinado e MUL é usado para não assinado. No exemplo a seguir, RAX terminará com o valor 15.
mov RAX, 5
mov RBX, 3
mul RAX, RBX
- Instruções: Fluxo
- O CMP compara dois operandos e define os sinalizadores apropriados dependendo do resultado. O seguinte definiria o Sinalizador Zero (ZF) como 1, o que significa que a comparação determinou que RAX era igual a cinco.
mov RAX, 5
cmp RAX, 5
- As instruções JCC são saltos condicionais que saltam com base nos sinalizadores que estão definidos no momento. JCC não é uma instrução, mas um conjunto de instruções que inclui JNE, JLE, JNZ e muito mais. JNE é salto se não for igual e JLE é salto se for menor ou igual. Isso é frequentemente usado em instruções if. O exemplo a seguir encerrará imediatamente se RAX não for igual a 5. Se for igual a 5, definirá RBX como 10 e, em seguida, encerrará.
mov RAX, 5
cmp RAX, 5
jne 5 ; pula paralinha 5 (ret) se não for igual.
mov RBX, 10
ret
- RET é a abreviação de retorno. Isso retornará a execução para a função anterior. O exemplo a seguir define RAX como 10 e depois retorna.
mov RAX, 10
ret
- NOP é a abreviação de No Operation. Esta instrução efetivamente não faz nada. É normalmente usado para preenchimento. O preenchimento é feito porque algumas partes do código gostam de estar em limites específicos, como limites de 16 bits ou limites de 32 bits.
Enfim! Há uma grande possibilidade que com este artigo você tenha conseguido adquirir bastante conhecimento. Assembly é essencial para a vida do Engenheiro Reverso, portanto, estarei trazendo cada vez mais conteúdos sobre Assembly em meu perfil!
- “O grande inimigo do saber é nossa indolência. É essa preguiça original que repugna o esforço, que acaba aceitando, por capricho, aqui ou ali, dar o máximo de si, mas cai novamente bem depressa num automatismo indiferente, achando que um ritmo de trabalho intenso e constante é um verdadeiro martírio. Um, martírio, talvez, levando-se em conta nossa constituição; mas esse martírio, deve-se estar preparado para ele ou renunciar ao estudo, pois o que se há de fazer sem energia varonil? “Tu, ó Deus, Tu vendes todos os bens aos homens pelo preço do esforço”, escrevia Leonardo da Vinci em suas anotações. Ele mesmo tinha-o claro na memoria.”
- A.D. Sertillanges — A vida intelectual
# L1za left…