Na hora de visualizar o código desmontado de um programa, é comum ter a sensação de estar se deparando com um conjunto de instruções sem estrutura ou significado aparente. No entanto, ao analisar de uma forma mais profunda, essas estruturas começam a surgir e o código passa a fazer sentido. Portanto, conhecer e saber como identificar esses códigos pode ser uma grande ajuda para acelerar e facilitar nossas tarefas de engenharia reversa em programas compilados para uma determinada arquitetura.

Neste post, mostraremos como algumas estruturas de controle de fluxo presentes na maioria das linguagens de alto nível são geralmente observadas na linguagem Assembly. Para os exemplos, usaremos sintaxe C++, instruções Assembly x86-64 e compilador GCC.

If

Os condicionais são fortemente baseados nas instruções de comparação (CMP) e pulo (Jxx). A ideia por trás disso é aplicar uma comparação entre dois elementos e, dependendo do resultado dessa comparação, pular para outro ponto no código Assembly ou prosseguir para a próxima instrução. Então, dependendo do resultado de uma comparação, as instruções de pulo usadas serão as instruções de pulo condicional e a instrução usada dependerá do tipo de comparação presente na condição do if.

Imagem 1. Exemplo de comparação entre um If em C++ e em Assembly. À direita, você pode ver a comparação CMP, o pulo condicional JNE (Jump Not Equal) e os possíveis cursos de execução.

While/for

Os ciclos são semelhantes aos ifs, pois contêm uma condição que determinará por quanto tempo é possível permanecer no ciclo. Enquanto essa condição não for atendida (negação do salvamento), o pulo condicional não será realizado e a execução continuará simplesmente na próxima instrução. Se a condição for atendida, o pulo condicional será ativado e apontará para uma instrução fora do ciclo. A única coisa que falta é a instrução que permite que a execução seja realmente um ciclo. Isso será realizado colocando uma instrução de pulo JMP não condicional no final do código do corpo do ciclo que aponta para a primeira instrução no ciclo, ou seja, a condição inicial.

Imagem 2. Exemplo de comparação entre um While em C++ e em Assembly. À direita, você pode ver a comparação CMP, o pulo condicional JG (Jump Greater) para sair do ciclo se o salvamento não for válido e o pulo incondicional JMP para retornar à instrução de comparação.

Switch

Assim como os ciclos e os ifs, os switches também usarão a combinação de instruções CMP e um pulo condicional Jxx. No entanto, ele fará uso muito mais intensivo, pois intuitivamente um switch pode ser visto como uma série de ifs aninhados. Dessa forma, haverá muitas comparações e pulos condicionais seguidos, no qual cada instrução de pulo levará ao seu rótulo correspondente e código será executado se a condição for atendida. No final do código para cada caso, haverá um pulo JMP não condicional que apontará para a próxima instrução após o switch para retomar o fluxo de execução.

Imagem 3. Exemplo de comparação entre um Switch em C++ e em Assembly. À direita, você pode ver as comparações de CMP para cada caso do switch, o pulo condicional correspondente a cada um deles e os pulos incondicionais que levam ao final do switch para sair dele.

Recursos

Embora isso possa variar de acordo com a linguagem e o compilador, as funções geralmente têm uma estrutura específica bem definida. Isso geralmente começa com duas instruções cujo objetivo é criar o Stack Frame para o recurso. Posteriormente, pode haver instruções com o objetivo de reservar espaço no stack para localizar o valor das variáveis ​​locais do recurso. A seguir, as instruções correspondentes às tarefas executadas pelo recurso estarão presentes e, finalmente, terminarão com uma instrução RET. Dada essa estrutura, é muito fácil identificar o início e o fim de um recurso.

Figura 4. Exemplo de comparação entre uma função em C++ e em Assembly. À direita, você pode ver a montagem e desmontagem do stack frame, a inicialização das variáveis no stack e o return.

Observações gerais

É importante ter em conta que, embora os compiladores tendam a respeitar estruturas semelhantes às mostradas aqui, eles podem variar ou ter modificações. Isso ocorre porque cada compilador pode funcionar de maneira diferente ou aplicar diferentes tipos de otimizações no código para que ele tenha melhor desempenho (por exemplo, loop unrolling de ciclos). Portanto, se você pretende saber a forma pontual pela qual um compilador específico compila uma determinada estrutura, pode usar ferramentas como o Compiler Explorer ou até mesmo executar os testes por conta própria, compilando e desmontando o código até encontrá-lo. De qualquer forma, embora as instruções usadas ou a ordem delas possam variar, a ideia por trás de cada estrutura é geralmente mantida.