DIABLO - ORiON_CrackMe1 +18
O tutorial é dedicado aos iniciantes em engenharia reversa, mas eu tentarei explicar, o máximo possível, alguns processos do Assembly e da arquitetura Intel x86, para que, até mesmo pessoas que nunca estudaram nada disso, possam acompanhar, e -se eu não falhar na clareza- entender os processos.
E aí, quem sabe, você não crie interesse por está área de estudos.
Caso não saiba usar o Debugger x64dbg, segue uma breve introdução: https://dev.to/ryan_gozlyngg/engenharia-reversa-primeiro-contato-parte-1-2gih
Esse tutorial consiste na quebra de um programa feito para isso.
É de nível extremamente básico, feito, justamente, para iniciantes na área da engenharia reversa.
"Quebra de software" quer dizer: fazer o programa se submeter à sua vontade!
Algumas notas estão presentes para auxiliar no entendimento de coisas que eu julgue que devem ser de conhecimento do leitor.
Lista de Conteúdo
Requisitos para total entendimento do tutorial a seguir
Termos utilizados durante o tutorial
Análise Estática
Examinando as strings do programa
Como o Code (input) é processado
O Processo de Tratamento do Input
Vamos rastrear a origem desse valor no topo da stack/pilha
Observando a Função crackme1.46B828
Entrando na Função crackme1.46B828
Entendendo como chegamos ao resultado
Caso o salto não seja executado, as seguintes instruções são usadas
Saindo da função e obtendo o resultado definitivo
SETNE - Instrução que gera o resultado
Requisitos para total entendimento do tutorial a seguir
- Conhecimento básico sobre segmentos de memória, principalmente sobre o segmento chamado stack/pilha
- Conhecimento sobre bases numéricas
- Conhecimento básico de programação em C/C++
Aqui eu espero poder mostrar a quebra desse software na forma de tutorial, usando partes do processo para compartilhar alguns conhecimentos úteis com aquele leitor que está iniciando, e ainda tem alguma dúvida, ou dificuldade, em entender algo específico.
Também é dedicado aos que desejam ver como esse tipo de coisa é feita.
No mais, é sempre bom ver como outras pessoas solucionam problemas, como a mente delas funciona em determinadas atividades, para, talvez, extrairmos alguma coisa para nós mesmos.
Se você já iniciou os estudos em engenharia reversa, tente quebrar esse software por sua conta primeiro, depois volte aqui para ler.
Termos utilizados durante o tutorial
- Code, CODE ou code: nome da área de input; é o mesmo que "serial", "key", "senha" ou input. Nesse software, os desenvolvedores chamaram a área de input de Code.
- Breakpoint: ponto de interrupção, ponto de parada.
- Setar: ação de aplicar algo sobre alguma coisa: exemplo: "setar breakpoint" significa: aplicar, definir, introduzir um breakpoint em ou sobre determinada instrução.
- Binário: refere-se a um programa, um executável.
Link para o programa CrackMe
- Baixe aqui: https://github.com/ReversingID/Crackmes-Repository/tree/master
- Encontre ele no path: Crackmes.DE\1-very_easy_for_newbies\windows\diablo
- Nota: Usar ou não uma máquina virtual dedicada para rodar o software está a seu critério.
Análise Estática
Em resumo, "análise estática" é uma análise feita apenas com as informações contidas no próprio arquivo do programa, também chamado binário, obtidas independentemente da execução do programa.
Para saber em qual arquitetura o programa foi compilado, você pode usar o programa DIE - Detect It Easy:
O DIE é um programa muito poderoso, por hora, vamos ficar somente com essas informações da interface inicial. Recomendo que você se aprofunde no uso do DIE.
Na caixa de baixo, com grande destaque em vermelho, podemos ver algumas informações interessantes. "PE32" indica que o programa é um executável de 32 bits. Nas caixas menores, marcadas em vermelho, podemos ver também, o modo e a arquitetura.
Examinando as strings do programa
Você pode ver as strings contidas dentro do programa, usando o software que preferir (inclusive com DIE, clicando no botão "Strings", na interface inicial):
Não tem como examinarmos as strings, uma por uma, já que 3,750 strings depois, alguma coisa pode acabar passando batido...
As strings mais interessantes são as que o programador criou. Vamos dar uma olhada no local em que as variáveis globais e estáticas, inicializadas, são guardadas: data segment, ou .data.
Eu utilizei o programa IDA para acessar a seção de dados (.data) mencionada.
O IDA é um programa utilizado para análise estática de binários, ele é muito poderoso, possui várias funcionalidades, não vou falar muito sobre ele aqui, recomendo procurar mais sobre.
O IDA possui uma versão gratuita: https://hex-rays.com/ida-free/
Caso você nunca tenha usado o IDA, e mesmo assim baixou para testar aí, saiba que ele nos mostra várias mensagens ao carregarmos o binário nele, por hora não se preocupe, dê "ok" em tudo até chegar na tela principal. Caso queira se aprofundar, recomendo começar com alguma das muitas playlists de introdução ao IDA, encontradas no Youtube.
- No IDA: SHIFT+F7 -> clique duas vezes em .data -> clique na aba Hex view -> vá descendo e observando a coluna lateral:
Estamos vendo a seção de dados dentro do programa, em hexadecimal, e a sua respectiva representação em ASCII, na coluna da direita (circulada em vermelho).
Dica: Aqui podemos separar as strings em um bloco de notas, e ir testando uma a uma no input do programa, já que é a coisa mais fácil a se fazer.
Se você fez isso, então já sabe a resposta.
Análise Dinâmica
A Análise Dinâmica consiste em observar e analisar o programa durante a execução.
Eu usei o debugger x64dbg aqui, e como de costume, a primeira coisa que faço é procurar pelas referências às strings.
Encontrando a string correta (nesse caso "Wrong Code! Try Again!") eu consigo observar as instruções que ocorrem antes dela ser usada na caixa de diálogo.
Seguindo essa string, eu chego ao momento em que ela é usada, e a partir desse local, eu posso buscar entender qual a lógica por trás da tomada de decisão, que julga se o nosso Input está correto ou não.
Abra o programa no x64dbg (não esqueça que o programa é de 32 bits, e precisa ser aberto no x32dbg.exe) e rode (apertando F9) o programa até ele abrir, e a janela com o input aparecer para você.
Geralmente o x64dbg tem alguns breakpoints iniciais, que só funcionam na primeira vez que o programa é rodado, então se o EIP -Instruction Pointer (ponteiro de instrução), responsável por dizer em qual instrução você está no momento (a setinha verde na lateral esquerda te mostra ele), estiver parado, continue clicando em F9 para rodar o programa até ele ser completamente carregado, e aí ele estará disponível para você usar (observe a barra de tarefas do Windows).
Com o programa já rodando, dentro do x64dbg:
- Clique com o botão direito do mouse, no meio do debugger, na janela CPU
- Vá em: "Pesquisar por" -> "All User Modules" -> "Referências String"
Você será direcionado para está janela. A anterior está ali em cima, em CPU.
Aqui vemos algumas strings que chamam atenção...
Se começamos por esse método, fazemos como anteriormente:
- Pegamos as strings "diferenciadas", e as testamos como input, uma a uma.
Você já deve saber que isso vai dar certo, mas por questão de curiosidade, se isso não tivesse funcionado, prosseguiríamos da seguinte maneira:
- Clique duas vezes na string "Wrong Code! Try Again!"
Você será direcionado para a instrução (onde ela é referida) que carrega ela na função (provavelmente a main), dentro da janela CPU.
- Sete um breakpoint nela, clicando em F2
- Abra a janela do programa e insira um code/input diferente do esperado, um errado qualquer.
- Clicando em "Ok" o programa irá rodar até parar em seu breakpoint setado.
- Agora vamos procurar pelo que decidiu que nossa resposta está errada, em outras palavras, procuramos entender como viemos parar na mensagem de erro.
Vamos fazer a engenharia reversa do programa, até descobrirmos o critério de "resposta certa" e "resposta errada".
Como o Code (input) é processado
Vamos rever os passos até aqui:*
1. Buscamos as referências de strings em todos os User Modules
2. Encontramos a string que aparece na caixa de diálogo ao errar o input
3. Setamos um breakpoint nela
4. Rodamos o programa depois de inserir um input qualquer
Subindo um pouco a partir do breakpoint (setado em "Wrong Code! try Again!"), vemos que há uma instrução test cl, cl
, um pouco antes das instruções que nos mostram a mensagem de erro.
Se você observar a instrução em [004016D7], que é um salto condicional JE (Jump if Equal),
pula para crackme1.4016F6 se o resultado de TEST CL, CL for igual a 0
, ela nos direciona para a outra mensagem: a mensagem de sucesso.
Basta você dar dois cliques na instrução je crackme.4016F6
que o programa te redireciona para esse endereço.
Com isso, sabemos que, se CL for zero, quer dizer que colocamos o input/code certo.
Agora nos fazemos uma pergunta: O que leva CL a ter o valor zero, ou mesmo o valor atual?
Nota: *Se você não entendeu nada desse trecho, leia a parte seguinte, **TEST CL, CL, com calma...
TEST CL, CL
Antes de tudo, o que significa a instrução TEST CL, CL
?
Caso saiba, pule para "Respondendo à pergunta: O que leva CL a ter o valor zero?".
*Instrução TEST: o programa está testando se **CL é zero ou não ("ou não" é literalmente qualquer coisa diferente de zero, como, por exemplo, -1, 1, 90000, 0xABC, 0xFFFF, 6, etc.).
CL é a parte mais à direita do registrador ECX (ver imagem adiante).
Registradores são espaços de armazenamento dentro do processador.
TEST: instrução que compara dois operandos, no nosso caso, compara CL com ele mesmo.
Com "TEST" o programa performa uma operação bit a bit, (bitwise) chamada AND.
A operação AND é uma operação lógica, efetuada com binários.
Caso não saiba nada sobre operações lógicas, veja: https://imasters.com.br/desenvolvimento/conheca-os-operadores-bitwise-bit-bit
Vamos tentar entender essa operação bit a bit, ou bitwise, utilizada aqui.
**Nota:* aqui fica claro a importância de saber sobre as bases numéricas: você tem um dado da realidade, e tem várias maneiras de quantificá-lo. Nós temos razões culturais para usarmos as bases decimais para quantificar a maioria das coisas, por isso, ao nos referirmos, por exemplo, ao número dez, dizendo "temos aqui, dez árvores", sabemos exatamente a quantidade de um determinado dado da realidade; no exemplo, sabemos que temos dez árvores.*
Por razões, também culturais, agora, com a existência dos computadores, nós passamos a usar as bases hexadecimal, octal e binária, nessa área.
Sendo assim, podemos dizer que temos: 10 árvores, em decimal. 0xA árvores em Hexadecimal, 012 árvores, em Octal, 0b1010 árvores, em Binário.
A quantidade na realidade não mudou, apenas a forma de representá-la é que foi alterada.
Os sufixos utilizados aqui são uma convenção, utilizada na linguagem C, sendo ZERO (0) para octal, ZERO XIS (0x) para hexadecimal, ZERO BE (0b) para binário, e NADA DE SUFIXO para decimal.
Cada uma das bases têm sua razão de existir. Recomendo ler:
- https://www.quora.com/Why-are-binary-octal-and-hexadecimal-number-systems-popular-in-computing
- https://www.computerengineeringconcepts.org/2.3-Binary-Octal-and-Hexadecimal
- https://en.wikipedia.org/wiki/Binary_number
- https://en.wikipedia.org/wiki/Octal
- https://en.wikipedia.org/wiki/Hexadecimal
Primeiro, um exemplo da operação bitwise AND, com o número 7, que em binário é 0111:
7 AND 7 ou TEST 7, 7
0b0111
0b0111
------
0b0111
Operação AND: 0b1 AND 0b1 = 0b1
0b1 AND 0b0 = 0b0
Ela só resulta em 0b1 se ambos os operandos forem 0b1.
Se um dos operandos for 0b0, a operação resulta em 0b0.
No windows: abra sua calculadora, escolha o modo programador, e faça as contas.
Nota: O zero à esquerda não diz nada, nem precisaria ali estar. 0111 e 111 dão na mesma.
Você precisa saber sobre algumas convenções de tamanhos, chamadas WORD. DWORD e QWORD.
Observações: 1.essas WORDS são traduzidas como "palavra", eu não gosto dessa tradução, por isso não uso. Mas você pode ver materiais falando sobre "o tamanho da palavra".
2.O tamanho real a que cada WORD se refere pode mudar conforme o sistema operacional.
Leia: https://mentebinaria.gitbook.io/engenharia-reversa/numeros/o-byte
Sobre o zero aqui mostrado: -resumindo- um byte tem oito bits. O número sete, se representado segundo uma convenção, que pede que, todos os números sejam mostrados como BYTES, seria representado assim: 0000 0111. Viu? Oito bits (1 BYTE = 8 bits), em duas colunas, com quatro valores cada.
Por costume, valores que usam quatro bits ou menos, eu os represento como "0000".
Registrador EFLAGS
Nós temos também um registrador especial, chamado EFLAGS, que possui todas as flags usadas pelo sistema, cada uma com um propósito diferente, que por sua vez, serão emitidas em determinadas operações, com o fim de sinalizar alguma coisa (leia o manual da Intel, link abaixo). Não vou me estender na explicação.
Para saber mais sobre EFLAGS, confira essa parte do livro gratuito de Assembly: https://mentebinaria.gitbook.io/assembly/aprofundando-em-assembly/flags-do-processador
Agora nós precisamos saber que, nesse registrador, nós temos uma flag chamada ZeroFlag.
Essa flag, em conjunto com as instruções de comparação, cmp
e test
, e as instruções de salto condicional, serve para controle do fluxo de execução do programa.
Sobre as instruções de salto condicional, veja: http://unixwiz.net/techtips/x86-jumps.html
Nota: Baixe o manual da Intel. Lá tem todas as instruções listadas, com as condições necessárias para a sua execução (o nome é Jcc para todas as condições diferentes de JMP): https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
É o primeiro link do site, apontado pela seta vermelha na imagem acima.
A operação TEST CL, CL resultando em 0, seta a ZeroFlag para 1: ZF=1.
E é isso que a combinação das instruções TEST e JE (Jump if equal) faz:
se(ZF==1) então Pule para a localização, se não, se(ZF==0) não pule
if(ZF==1) then Jump to location else if(ZF==0) don't jump
CL
ECX é um registrador de 32 bits, e podemos usar apenas "uma parte dele", "dividindo-o":
(OBS: ignore o RCX, que é o nome usado para registradores de 64 bits, os coloquei ali para que você saiba da existência dele).
Quando usamos um registrador como ECX, usamos TODO ele, nossos dados de 4 bytes
(4 bytes são 32 bits) ocupam todos os espaços -incluindo os 32 bits de ECX, os 16 bits de CX, os 8 bits de CH, e os 8 bits de CL.
Mantenha em mente que ECX é um corpo completo, e podemos usar uma parte específica dele, obrigando o computador a ler/escrever somente na parte desejada, sendo CX, CH ou CL.
"Curiosidade": antigamente existiam processadores de 16 bits, CH ficava com os 8 bits mais significantes e CL com os 8 bits menos significantes, e hoje em dia ainda é assim, só que os registradores possuem mais espaço; mais espaço, mais divisões.
Para saber mais sobre bits mais significantes e menos significantes (MSB e LSB),
acesse o link: https://www.morningstar.io/post/2016/12/25/midi-msb-and-lsb#:~:text=MSB%20stands%20for%20most%20significant,4%20bits%20would%20be%200011
CH e CL: o "C" é padrão para Counter (mas o registrador é de propósito geral, então pode ser usado para quase "qualquer coisa"), o H significa "High byte" e o L "Low byte"
(o E de ECX é de extended, lembra que era CX -16 bits? Ele foi "extendido" para 32 bits).
Mais sobre: https://en.wikipedia.org/wiki/IA-32
O Processo de Tratamento do Input
Se você já sabe o input/code certo, coloque um breakpoint na instrução test cl, cl
, depois insira o input/code correto, rode o programa, e perceba como CL vai estar com o valor 0 durante o teste; inserindo um valor errado, ele vai estar com valor 1 nesse momento.
Assim temos a prova de que é realmente ali que acontece confirmação final do input inserido.
Mantenha o breakpoint na instrução test cl, cl
, insira um input errado e rode novamente.
O registrado EFLAGS (circulado em vermelho, a direita) nos mostra que a ZeroFlag vai ser modificada na próxima operação através do traço vermelho abaixo de ZF.
Circulado em preto, temos o valor contido em CL, bem como vemos sublinhado em verde na coluna dos registradores.
Foi inserido um input/code errado, e a ZeroFlag será setada para 0, dizendo que não tivemos o resultado 0 na operação TEST. Dê um stepover e veja por si mesmo.
Perceba que antes de test cl, cl
nós temos a instrução pop ecx
.
A instrução pop
pega o valor que está no topo da stack/pilha (visível no registrador ESP), e carrega para dentro do operando, que no nosso caso é o ECX.
Nota: Não se esqueça de que o registrador **ESP* contém o endereço do topo da stack/pilha, e dentro desse endereço há um valor, e é ele que o pop "joga" para dentro de ECX.*
Breakpoint na Instrução pop ecx
, inserimos um input errado, clicamos em "OK":
Nesse momento, o topo da nossa stack/pilha está assim (o input foi "AAA", que é errado):
Como o code/input não é o correto, o topo da stack está com 1.
(Se você já tem o code/input correto, como mencionado anteriormente, insira-o, e veja que, ao chegar nesse ponto, a stack/pilha está com o valor 0 no topo). Preste atenção no endereço do topo da stack/pilha, é ele que, mais tarde, vai nos levar até a função que dita se o input/code está certo ou errado.
Apenas o bit menos significativo de ECX, CL, é usado para a operação seguinte.
Com isso, na operação test cl, cl
(é o mesmo que test 1, 1
) temos o resultado 1, a ZeroFlag não é setada (ZF=0
), então o programa não pula para crackme1.4016F6
.
Vamos rastrear a origem desse valor no topo da stack
Como já vimos que é o valor, zero ou um, que está no topo da pilha, no momento do pop ecx
, que determina o nosso sucesso ou erro, a depender do input, vamos agora tentar encontra a origem desse número.
Os valores que se encontram na stack/pilha, durante o breakpoint em pop ecx
, têm origem em alguma operação da função atual.
As instruções responsáveis por lidar com valores da stack/pilha, são POP e PUSH.
POP: Carrega o valor do topo da pilha para o local especificado pelo operando de destino (ou opcode explícito) e, em seguida, incrementa o ponteiro da stack/pilha (ESP).
PUSH: Decrementa o ponteiro da stack/pilha (ESP) e armazena o operando de origem no topo da pilha.
Não deixe de consultar o manual da Intel para saber mais!
Lembre-se que o valor retornado por uma função é "guardado" no registrador EAX.
Nota: Aqui eu assumo que você já leu o primeiro tutorial, relacionado ao x64dbg, onde eu explico brevemente as funções em Assembly de x86. Novamente colocarei o link aqui: https://dev.to/ryan_gozlyngg/engenharia-reversa-primeiro-contato-parte-1-2gih
Segue o passo a passo efetuado:
- Setamos um breakpoint nas três instruções
call
mais próximas aotest cl, cl
. - Execute o programa novamente, até que ele chegue no breakpoint. Para fazer isso, basta dar um "Run" (atalho: F9), para ele sair dos breakpoints e pedir o input novamente.
- Insira uma mensagem ERRADA no input/code. Clique em "Ok" para continuar.
- Dê um step over com F8 até o EIP passar da instrução call, e verifique registrador EAX.
- Verificamos se o valor contido em EAX é colocado no topo da stack/pilha (geralmente com a instrução
push eax
).
MUITA ATENÇÃO NO 6° PASSO: o endereço do topo da stack/pilha, em ESP, precisa ser 0x19EFDC
imediatamente depois da instrução call
. Isso porque, no momento do pop ecx
em xxxxx, o endereço (da stack/pilha) 0x19EFD8
é que está com o nosso valor (0 ou 1) para o test cl, cl
, e agora estamos assumindo que esse valor foi guardado na stack/pilha via instrução PUSH, a qual decrementa o endereço de ESP, e se você leu a documentação da Intel, sabe que ESP é decrementado por 4. Logo 0x19EFDC - 4 = 0x19EFD8
.
(O manual nos diz: The operand size (16, 32, or 64 bits) determines the amount by which the stack pointer is decremented (2, 4 or 8). No nosso caso, como veremos logo, o operado é EAX, logo 32 bits, decrementado por 4).
Nota: Setando Breakpoint - alguns detalhes:
Como você já sabe, setamos um breakpoint em uma instrução, dentro do x64dbg, com a tecla **F2:
- Selecione a linha desejada, e aperte **F2, a "bolinha" no canto esquerdo ficará **vermelha.
- Se você clicar em cima dessa "bolinha", a "bolinha" ficará com verde, desabilitando o breakpoint.
- Clicando na "bolinha" uma terceira vez, você removerá o breakpoint.
Nota: Ao clicar duas vezes em uma instrução call
, ou dar um **step in* com o EIP nela (lembre da "setinha" na esquerda, que aponta para onde está o EIP), você vai entrar, ser direcionado, até o código dessa função.*
Como fazemos para voltar à instrução anterior? Para isso usamos o seguinte atalho: -
Observando a stack
Ao efetuar os passos acima...
Na imagem, você pode ver que eu inseri "AAA" como code/input, e por estar errado, precisamos ver o "1" no topo da stack/pilha ao passarmos por está call
, no endereço que buscamos.
Observe as instruções. Na imagem, paramos antes da função ser executada.
Executamos um stepover (F8).
Podemos ver que no topo da stack/pilha temos o endereço desejado, e no campo das instruções temos push eax
, que decrementará o nosso ESP, colocando o valor "1" de EAX no topo da stack/pilha.
Bem como estávamos procurando!
Retorno de Função
Um breve exemplo de um programa em C comentando o comportamento do código quando em Assembly de x86, para reforçar que o valor de retorno de uma função, em programas x86, é passado para EAX (na grande maioria dos casos, lembre-se das calling conventions).
// programa em C
/* Função SOMA: soma dois numeros a + b e retorna resultado */
int soma(int a, int b){
int resposta_da_soma;
resposta_da_soma = a + b;
return resposta_da_soma; // esse valor é retornado em EAX
}
/* Função principal Main */
int main(){
int a = 2;
int b = 3;
int resultado;
// chama função com soma de dois valores
// aqui será uma call: call soma.endereço
resultado = soma(a, b).
// Aqui dentro da main, nesse momento teremos em EAX o número 5
}
Observando a Função crackme1.46B828
Antes de entrarmos na função, vamos ver os argumentos que são passados para ela.
Eu não vou explicar como as Calling Conventions funcionam, e é esse conhecimento que vai lhe ajudar a entender como os argumentos são passados para funções. Sim, existe mais de uma maneira de passar argumentos para funções em Assembly.
Para entender melhor sobre elas, acesse os links: https://mentebinaria.gitbook.io/assembly/programando-junto-com-c/convencoes-de-chamada-no-windows
https://en.wikipedia.org/wiki/X86_calling_conventions
Em resumo, as convenções de chamada (calling conventions) ditam como os argumentos de uma função vão ser passados a ela. Aqui nós podemos ver que antes da função ser chamada, o endereço de algo que está em [ebp-10]
é carregado em EDX, e também vemos que o que está no topo da stack é colocado dentro de EAX.
Vamos ver, brevemente, o que essas funções, lea
e pop
estão fazendo:
lea
- Load Efective Address: Carrega o endereço de qualquer coisa que está em [ebp-10]
e guarda em EDX. Para entender [ebp-10]
, segue o link: https://mentebinaria.gitbook.io/assembly/a-base/enderecamento
pop
- Pega oque está no TOPO da stack/pilha e guarda no operando, aqui é guardado em EAX, e em seguida incrementa o topo da stack/pilha.
Os argumentos foram passados através dos registradores EAX e EDX.
Entrando na função isso vai ficar claro.
Encontrando valores no Dump
Vamos ver rapidamente como rastreamos valores no x64dbg.
(Algo como [ebp-10]
é carregado em EDX, basta um step-over para executar a instrução, e aí verificamos oque é que foi carregado em EDX, muito mais simples)...
Vamos ver quais valores se encontram em [ebp-10]
, e no topo da stack nessa hora:
Você já deve saber buscar esse valor no dump. Botão direito do mouse na instrução desejada, etc.(basta fazer como na imagem acima).
Encontre o valor desejado, que no caso é [ebp-10]
.
Agora vamos ao topo da stack/pilha:
Perceba que o topo da stack/pilha (em ESP) é sempre destacado em verde na Janela da Stack.
Vamos ver os valores carregados nos registradores:
Perceba que EDX tem um comentário que nos diz que o valor 0x0019F02C
é um endereço para a string "***vErYeAsY***". Sabemos que é um endereço pelo símbolo "&".
Você pode segui-lo no dump e confirmar isso.
Em EAX temos o valor 0x0019F030
, e para sabermos oque tem lá, podemos segui-lo no dump.
Perceba o valor que se encontra aqui. Parece outro endereço.
Podemos tentar seguir ele no dump também.
Chegamos nesse lugar. Observe que é aqui que se encontra o nosso code/input.
0x41 é o mesmo que o char 'A'. Cada 0x41 é um byte, você tem ali a seguinte string:
"AAA\0"
Os três caracteres 'A', mais o null terminator
\0
, que indica o fim da string.
Confira: https://man7.org/linux/man-pages/man7/ascii.7.html
Então é isso, essa função está recebendo os endereços das duas strings como argumento.
Nos perguntamos: Será que ela vai comparar a nossa string "AAA" com essa string diferente aí??? E então testamos a string "***vErYeAsY***" como code/input.
E então é isso, funcionou!
Entrando na Função crackme1.46B828
Para entrar na função utilize o STEP-INTO (F7).
Dentro da função:
Perceba que aqui dentro, a função pega os argumentos, que são endereços, e os "desreferencía", fazendo com que os valores reais dentro daqueles endereços sejam guardados agora em EAX e EDX. Portanto, depois dessas operações MOV, os nossos registradores conterão os endereços diretos para nossas strings puras (perceba que antes disso nós temos um endereço para um endereço).
Observação: geralmente as funções chamam outras funções, que chamam outras funções, e chega em um nível em que você deve se perguntar: vale mesmo apena entrar em todas essas funções? Será que estou no caminho certo?
O que me fez perceber que estava no caminho certo aqui, foi a instrução que vem depois da call crackme1.460C48
, a instrução setne al
(sobre ela mais a frente).
AL é a parte "mais baixa" de EAX, sabemos que ele contém o retorno da função após a execução dela. E a operação que é feita com AL nos dá o retorno "1" que buscamos.
Por isso achei que valia apena continuar entrando nas funções.
Como aqui o programa já foi quebrado, e o desafio já foi vencido, eu estava motivado apenas por pura curiosidade de entender como o programa funciona, então achei que valia continuar com a busca.
Vamos para essa outra função agora.
Entendendo como chegamos ao resultado
Vou expor o processo de maneira linear (atenção: não vou expor o processo todo devido ao tamanho que o texto ficaria, vou focar nos pontos mais importantes para esse exemplo).
Nota: antes de tudo, perceba que, conforme o tamanho da nossa string digitada no input, o programa faz um caminho diferente dentro dessa função, já que ele compara alguns caracteres por vez. Sendo assim, quanto maior a nossa string, mais testes serão feitos.
No teste abaixo foi utilizado uma string de três caracteres: "AAA".
Acompanhe parte do processo da função 0x00460C48
, começando pela comparação:
Passando do prólogo da função nós temos:
1.cmp eax, edx
Compara os endereços das duas strings.
A instrução CMP faz uma subtração: eax - edx
. A diferença é que aqui o resultado não é salvo, e os operandos permanecem os mesmos.
Perceba que como os endereços estão sendo comparados, a próxima instrução, que é um salto "pule se for igual", não acontece.
2.test esi, esi
Verificando se o code/input não está vazio, caso esteja, ele pula para o fim da função e termina retornando 1.
3.test edi, edi
Verificando se a string com o code/input correto, passada pelo programador, não está vazia.
4.mov eax, dword ptr ds:[esi+4]
mov edx, dword ptr ds:[edi+4]
EAX recebe o tamanho da string que passamos para o code/input
edx
recebe o tamanho da string que o programador passou para a função.
O tamanho das strings foi calculado em outra função.
5.sub eax, edx
subtrai EDX de EAX. A string correta tem o tamanho de 14 bytes, em hexa são 0xE.
A conta:
COM O INPUT "AAA": 3 - 14 = -11 ou 0xFFFFFFF5
COM O INPUT CORRETO: 14 - 14 = 0
O resultado é guardado em EAX.
Se ambos estiverem com o valor de 14 bytes, sendo a string correta ou não, a operação sub eax, edx
resultaria em ZeroFlag = 1, e CarryFlag = 0.
Lembre-se que se uma operação resultar em zero, a ZeroFlag é setada, recebendo o valor "1".
6.ja crackme1.460C6B
JA - Jump if above, pula se as flags CF e ZF forem ambas iguais a 0.
Em outras palavras, o salto só será executado caso a nossa string seja ==maior== que a string do programador.
Com a nossa string sendo maior que a string do programador, a CarryFlag não é setada, porque não temos um resultado negativo, e a ZeroFlag não é setada, porque a operação não resulta em zero.
CF ou Carray Flag: Caso a operação aritmética precise utilizar o bit mais significante (MSB - que é o bit mais à esquerda) para guardar o resultado, a CF é setada para "1".
O bit mais a esquerda é usado quando precisamos representar um número com sinal, um número negativo.
Como o resultado da operação com a string menor "AAA" seta a Carry Flag, o programa não executa o salto. Já com a string correta, ele executa o salto.
Resumindo:
Com Uma ==String Menor==: [CarryFlag é setada e o salto não é executado]
Com Uma ==String de 14 bytes==: [ZeroFlag é setada e o salto não é executado]
Todo esse processo foi para ver se o code/input passado por nós é maior que a string do programador.
Caso o salto não seja executado, as seguintes instruções são usadas
7.add edx, eax
COM UMA STRING MENOR
Usei a string "AAA".
eax: 0xFFFFFFF5 - no caso de "AAA" (ou qualquer string de três bytes)
edx: 0xE
Cálculo: 0xE + 0xFFFFFFF5 = 00000003 (Resultado vem com o MSB setado: 100000003)
COM UMA STRING DE 14 BYTES
eax: 0 - resultado da subtração de 14 - 14
edx: 0xE - tamanho da string em hexadecimal (0xE = 14)
Cálculo: 0xE + 0 = 0xE
Aqui estamos restaurando o número de bytes da nossa string para EDX.
O resultado é guardado em EDX.
E em seguida o resultado é guardado na stack com push edx
.
8.shr edx, 2
Aqui o programa usa a instrução SHR, com EDX e o número 2.
SHR é usada para mover os bits de EDX duas casas para a direita.
Para cada bit movido para a direita, um zero é adicionado à esquerda.
EXEMPLO COM UMA STRING DE 14 BYTES:
EDX está com o valor 0xE ou 14.
14 em binário é: 1110
Agora vamos mover os bits para a direita duas vezes:1110
0111 -> 1.movemos um bit para a direita e adicionamos um zero à esquerda
0011 -> 2.movemos um bit para a direita e adicionamos um zero à esquerdaAgora nós ficamos com o resultado 0011 que é 3 (três é representado da mesma forma em hexadecimal e em decimal).
Ao terminar a instrução, aqui temos EDX = 3.
EXEMPLO COM UMA STRING DE 3 BYTES:
EDX está com o valor 3.
3 em binário é: 0011
Agora vamos mover os bits para a direita duas vezes:0011
0001 -> 1.movemos um bit para a direita e adicionamos um zero à esquerda
0000 -> 2.movemos um bit para a direita e adicionamos um zero à esquerdaAgora nós ficamos com o resultado 0000 que é 0 (zero é representado da mesma forma em hexadecimal e em decimal).
Ao terminar a instrução, aqui temos EDX = 0.
9.je
se shr edx, 2 == 0
(Testando para ver se a operação resultou em zero)
a operação serve para o programa decidir qual caminho tomar baseado no tamanho da string que nós passamos para ele.
Se for uma muito pequena, ele vai pular para uma comparação de um único byte, o mais a direita.
Se a nossa string tiver um tamanho parecido com a correta, ele compara os primeiros 4 bytes,
e assim o programa prossegue, sempre se baseando no tamanho da nossa string.
Aqui segue os resultados para strings de 0 bytes até as de 14 bytes:
Perceba que as strings de 14, 13 e 12 bytes resultam em 3, forçando a função a ir pelo mesmo caminho, até detectar a divergência de tamanho.
Resumindo a lógica da função
Em resumo, você pode entender 100% da lógica sendo usada em qualquer função, mas se você não quer gastar todo o seu tempo com isso, pode começar a pegar padrões de comportamento para todos os tipos de dados processadas, no nosso caso, as strings.
Aqui não vou mostrar o que cada uma das instruções seguintes faz, isso vai deixar esse tutorial mais maçante e longo ainda...
Mas para quem quer saber oque se passa depois do já mencionado, dentro da função:
Perceba a série de saltos (jmp e jcc
) e comparações (test e cmp
).
A função está percorrendo ambas as strings e comparando os bytes.
A ordem da comparação varia conforme o tamanho da string enviada.
Bom, sabemos que a função vai retornar um valor dentro de EAX.
Com nossa string de três caracteres, ao fim da função, perceba que o valor em EAX é quase o mesmo que temos após a instrução sub eax, edx
do passo cinco desse tutorial na parte intitulada "Entendendo como chegamos ao resultado".
Que tipo de alteração ele sofreu?
Vamos até a instrução mencionada (sub eax, edx
), e a partir dela, seguimos com a atenção fixada em EAX, uma instrução por vez, com o step-over.
Se você fez isso, percebeu que o valor se mantém o mesmo em EAX até chegar em uma instrução que soma EAX com ele mesmo desde então.
(Mas lembre-se que o percurso depende do tamanho da string que usamos no code/input).
Após isso, o programa pula direto para a parte final da função com um salto obrigatório jmp
.
EAX não sofre mais nenhuma alteração, e voltamos a função anterior.
Saindo da função e obtendo o resultado definitivo
Agora que você já tem uma noção de como o resultado retornado é formado, vamos ver oque é que nos dá o resultado definitivo para a comparação final.
Sabemos que a comparação final é, ou com o valor "1" ou com o valor "0".
A função em que entramos é a seguinte crackme1.46B828
:
Já vimos oque acontece na função chamada em call crackme1.460C48
, e retornamos na instrução setne al
.
SETNE - Instrução que gera o resultado
setne - Set byte if not equal (ZF=0)
: essa instrução vai mudar o operando (no nosso caso o AL) para 0 ou 1, dependendo do status da ZeroFlag.
Como vimos antes, o caminho tomado pela instrução anterior, que retorna nosso valor em EAX, depende do tamanho da string enviada. Basta seguir o fluxo da função até ela tomar um salto para o final da instrução, e depois voltar a última instrução de comparação.
Para voltar para trás, e seguir esse fluxo, basta usar o atalho -
(traço).
Assim vemos onde é que a ZeroFlag é setada.
Vamos seguir o caso da string "AAA":
A função nos faz saltar para o final, antes da instrução de retorno RET.
Usamos o atalho até voltarmos ao local com uma instrução de comparação.
Voltando, vemos o local do salto, e em cima dele, a última instrução de comparação, que está comparando o byte mais à direita da nossa string com a do programador.
Como o resultado é diferente, nós não temos a ZeroFlag setada.
Sabendo que a ZeroFlag não foi setada, nós também sabemos que a instrução setne al
irá colocar o valor "1" na parte mais baixa (mais a direita) do nosso registrador EAX.
Após a execução de setne al
, EAX fica com o seguinte valor: 0xFFFFFF01
, e para termos apenas o valor setado por setne
em EAX, a instrução and eax, 1
é performada.
Como "1" é representado como 0x00000001
e EAX é 0xFFFFFF01
, somente o primeiro byte, mais a direita vai ter o valo "1", caso não seja "0", e qualquer outro byte vai ser mudado para zero. Lembra, operação AND só resulta em "1" se ambos os bits forem "1", e é por isso que, com a string correta, AL é setado para zero, e essa operação AND resulta em zero.
Considerações Finais
Espero que você tenha conseguido seguir e entender até aqui.
Espero que tenha pegado o "espirito" da coisa, e se for o caso, que tenha aumentado a sua curiosidade e interesse por esse tipo de conhecimento.
Para iniciar de verdade na engenharia reversa, eu recomendo começar pela seguinte playlist:
CERO - Curso de Engenharia Reversa Online por Mente Binária:
Curso de engenharia reversa em português 100% gratuito.
https://youtube.com/playlist?list=PLIfZMtpPYFP6zLKlnyAeWY1I85VpyshAA&si=3wYZb0E7iHaAFaMm
Top comments (0)