Autores: Ph.D. Chernov A.V. ( monsieur_cher ) e Ph.D. Troshina K.N.Como, usando as suposições mais gerais baseadas no conhecimento das arquiteturas modernas de processador, você pode restaurar a estrutura do programa a partir de uma imagem binária de uma arquitetura desconhecida e, em seguida, restaurar algoritmos e muito mais?Neste artigo, falaremos sobre uma tarefa interessante que nos foi apresentada vários anos atrás. O cliente pediu para lidar com o firmware binário do dispositivo que estava gerenciando um determinado processo físico. Ele precisava de um algoritmo de controle na forma de um programa C compilado, além de fórmulas com uma explicação de como eles funcionam e por quê. Segundo o Cliente, isso era necessário para garantir a compatibilidade com o equipamento "antigo" no novo sistema. O modo como lidamos com a física, no âmbito desta série de artigos, omitimos, mas consideraremos o processo de restauração do algoritmo em detalhes.
O uso quase onipresente de microcontroladores programáveis em dispositivos de massa (IOT ou SmartHome Internet of Things) requer atenção à análise binária do código incorporado ou, em outras palavras, análise binária do firmware do dispositivo.
Uma análise binária do firmware do dispositivo pode ter os seguintes objetivos:
- Análise do código de vulnerabilidades que permite obter acesso não autorizado ao dispositivo ou aos dados transmitidos ou processados por este dispositivo.
- Análise de código para recursos não documentados, levando, por exemplo, a vazamento de informações.
- Análise de código para restaurar protocolos e interfaces de interação com dispositivos para garantir a compatibilidade deste dispositivo com outros.
A tarefa de análise de código binário colocada acima pode ser considerada como um caso especial da tarefa de análise binária para garantir a compatibilidade do dispositivo.
Análise do formato de arquivo binário
Se no mundo dos sistemas operacionais "reais", os formatos de arquivo executáveis são padronizados, no mundo dos programas incorporados, cada fornecedor pode usar sua solução proprietária. Portanto, a análise do arquivo de firmware binário deve começar com a análise do formato do arquivo binário.
No início do trabalho, a situação para nós era a seguinte: recebemos um determinado arquivo com o firmware sem nenhuma documentação anexa. Não havia informações sobre o formato do arquivo de firmware nem sobre a arquitetura do microcontrolador.
O arquivo do firmware acabou sendo um arquivo de texto. Continha linhas do seguinte formato:
:04013000260F970CF8 :10020000004D000B043F000B34AD010C002FFE4D30 :02023000FD0BC1 :1004000018001A0000001E0008005E000200190052
Depois de analisar cuidadosamente o conjunto dessas linhas, percebemos que este é um formato Intel HEX completamente padrão para microcontroladores. O arquivo consiste em registros, cada um dos quais indica seu tipo, localização da memória, dados e soma de verificação. Por si só, o uso do formato Intel Hex implica que o arquivo provavelmente não está criptografado e é uma imagem de um programa que reside na memória.
Embora o formato Intel Hex suporte para endereçamento de memória de até 32 bits, havia apenas endereços de memória de 16 bits em nosso arquivo. Portanto, é fácil criar um arquivo binário de uma imagem de memória a partir de um arquivo de texto no qual os registros do arquivo de teste original já serão colocados nos endereços especificados. É mais conveniente inspecionar um arquivo binário usando os utilitários de análise de arquivo binário, e é mais fácil escrever seus próprios utilitários para ele do que para o Intel HEX. O arquivo de memória de imagem binária confirmou que o arquivo não foi criptografado, pois várias linhas significativas foram encontradas espalhadas em diferentes locais do código.
No entanto, isso não respondeu à pergunta para qual arquitetura esse arquivo é.

Temos um arquivo com a imagem de memória de um microcontrolador de 16 ou 8 bits. E que tipo de microcontrolador é não está claro. Pegamos o IDA Pro e tentamos desmontar o arquivo com todas as variantes possíveis dos processadores suportados. E nada. Nenhum dos processadores suportados do IDA Pro surgiu: a listagem não foi gerada ou continha um absurdo óbvio. Pode ter sido um programa para um dos processadores IDA Pro suportados, mas fizemos algo errado. Por exemplo, você só precisava de processamento adicional do arquivo de imagem. De qualquer forma, aqui foi possível suspender o trabalho e solicitar informações adicionais sobre o arquivo binário.
Todos os processadores são praticamente os mesmos.
Mas tornou-se interessante para nós, e o que podemos entender do programa binário, mesmo que o processador para o qual é compilado seja desconhecido. A resposta é "nada" - desinteressante, mesmo que possamos entender um pouco, é melhor que nada. Obviamente, as strings de texto podem fornecer informações sobre o programa, mas pretendemos obter mais informações - para entender algo da estrutura do programa.
Várias arquiteturas de processador - um grande número. A evolução da computação gerou até as arquiteturas mais incomuns, como computadores ternários. No entanto, os microprocessadores e microcontroladores que existem atualmente, pelo menos os de massa, são notavelmente semelhantes entre si.
Abaixo listamos os princípios básicos comuns aos microprocessadores modernos.
Execução consistente de instruções. O processador executa instruções em sequência na memória. Existem instruções especiais para salto condicional e incondicional, chamada e retorno da sub-rotina, que permitem interromper a seleção seqüencial de instruções da memória e prosseguir para outra instrução. No entanto, o restante das instruções assume a execução sequencial e, portanto, não contém o endereço da próxima instrução.
Codificação binária. Além do fato de o processador processar os dados em formato binário, as instruções do processador que compõem o programa executável são codificadas em formato binário, ou seja, os campos nos quais os parâmetros de instrução são armazenados, por exemplo, deslocamentos ou números de registro, ocupam um número inteiro de bits. Teoricamente, pode-se imaginar que, apesar da codificação binária de dados e programas, eles serão processados no processador em algum outro sistema numérico, mas não temos conhecimento de tais informações exóticas.
Os princípios a seguir, de um modo geral, não são princípios arquiteturais básicos, mas são praticamente universalmente implementados, especialmente para código de máquina que não é escrito manualmente na linguagem assembly, mas é gerado por um compilador de linguagem de alto nível.
Programação processual. O programa é dividido em unidades estruturais, que podem ser chamadas de maneira diferente: procedimentos, funções, subprogramas, etc. Os subprogramas podem chamar outros subprogramas, passando parâmetros para eles e recuperando o resultado da execução. É importante que o subprograma tenha um ponto de entrada, ou seja, todos os subprogramas que invocam o determinado vão para o mesmo endereço do ponto de entrada.
Normalmente, as rotinas têm um ponto de saída que retorna o controle ao ponto de chamada, mas isso é menos significativo do que exigir um ponto de entrada para cada rotina. Esse código geralmente é obtido através da compilação de um programa. O otimizador de tempo de link pode destruir parcialmente essa estrutura para reduzir o tamanho do programa, e o tamanho do programa é crítico para sistemas incorporados. Além disso, essa estrutura pode ser destruída pelo ofuscador de código.
O aninhamento de chamadas de sub-rotina pode ser organizado usando a pilha, que ainda pode ser usada para passar argumentos para a sub-rotina e armazenar variáveis locais, mas, no nível atual de desenvolvimento da arquitetura, essas informações são prematuras.
Como esses princípios podem ser aplicados à análise inicial do código binário?
Assumimos que existe uma instrução RET (retorno de uma sub-rotina) no sistema de comando do processador. Esta instrução possui algum tipo de representação binária fixa, a qual procuraremos. Se o RET não for o único, como em x86, onde o RET tem um argumento - o tamanho da área de parâmetro da sub-rotina ou se o RET é um efeito colateral de uma operação mais complicada, como no ARMv7, onde o valor do PC pode ser buscado da pilha simultaneamente com os valores de outros registradores (ldmfd sp! , {fp, pc}), provavelmente, nossa pesquisa heurística não produzirá resultados.
Também precisamos fazer suposições razoáveis sobre o princípio das instruções de codificação do processador em estudo. Os processadores existentes usam vários princípios para instruções de codificação:
- Um fluxo de bytes a partir do qual as instruções são geradas e instruções diferentes são codificadas com um número diferente de bytes. Nesta categoria, o representante mais famoso é a família x86, dos primeiros processadores 8080 aos mais modernos processadores de 64 bits. Uma instrução do processador x86_64 pode ser codificada em uma sequência de 1 a 16 bytes. A mesma família de processadores com comprimentos de instrução variáveis inclui o 8051, que é usado em microcontroladores.
- Um fluxo de valores de 16 bits. Além disso, cada instrução tem um tamanho fixo - 16 bits.
- Um fluxo de valores de 16 bits, enquanto as instruções são de tamanho variável. Um dos representantes dessa família é a arquitetura PDP-11, na qual o próprio comando ocupa os primeiros 16 bits e pode ser seguido por valores diretos ou endereços de memória para endereçamento direto. Isso inclui a codificação THUMB na arquitetura ARM.
- Um fluxo de valores de 32 bits, cada instrução tem um tamanho fixo de 32 bits. Estes são a maioria dos processadores RISC de 32 e 64 bits: ARMv7, ARMv8, MIPS.
A escolha entre um fluxo de bytes de comprimento variável e um fluxo de palavras de 16 bits ajudará a visualizar a imagem da memória "a olho nu". Não importa como as instruções do processador sejam codificadas, em um programa de duração suficiente elas serão inevitavelmente repetidas. Por exemplo, na instrução x86
add
que adiciona os valores dos registros eax e ebx e coloca o resultado em eax, é codificado em dois bytes:
01 d8.
Na instrução ARMv7
add r0, r0, r1
que adiciona os valores dos registradores r0 e r1 e coloca o resultado em r0, é codificado pelo valor de 32 bits e0800001.
Em um programa suficientemente grande, essas instruções serão repetidas mais de uma vez. Se uma sequência de bytes de seu interesse (por exemplo, 01 d8) ocorrer em um endereço arbitrário e desalinhado, podemos assumir que as instruções do processador são codificadas por um fluxo de bytes de tamanho variável. Se o valor, por exemplo, e0800001 for encontrado apenas em endereços com múltiplos de 4, podemos assumir um tamanho fixo das instruções do processador. Obviamente, há um erro aqui: pegamos bytes de dados para uma instrução, ou aconteceu por acaso que alguma instrução sempre se mostrou alinhada. No entanto, o impacto desse "ruído" em um programa de tamanho suficiente será pequeno.
Quando analisamos o firmware analisado desse ângulo, ficou claro que muito provavelmente as instruções para o processador em questão estão codificadas com valores de 16 bits.
Portanto, com base no pressuposto de que a codificação da instrução RET é um valor fixo de 16 bits, vamos tentar encontrá-lo. Encontramos na imagem do programa os valores mais comuns de 16 bits. No nosso caso, aconteceu o seguinte:
(hex) 0b01 854 5.1
Procuraremos a instrução RET entre esses valores de 16 bits mais frequentemente encontrados no código. Imediatamente, procuraremos a instrução CALL - emparelhada com a instrução RET. A instrução CALL possui pelo menos um parâmetro - o endereço de salto, portanto, valores fixos são indispensáveis.
Assumimos que, em muitos casos, imediatamente após o final de um subprograma, ou seja, após a instrução RET, outro subprograma é iniciado e esse subprograma é chamado pela instrução CALL de outro ponto do programa. Um grande número de saltos para o endereço imediatamente após RET será uma das marcas registradas da instrução CALL. Obviamente, essa regra não é universal, pois em algumas plataformas, em particular no ARMv7, imediatamente após a conclusão da sub-rotina, geralmente é localizado um pool constante. Nesse caso, podemos considerar um intervalo razoável de endereços imediatamente após o RET como pontos de transição da instrução RET.
No caso da instrução CALL, pode haver muitas opções para codificá-la na sub-rotina. Em primeiro lugar, o processador pode usar uma ordem de bytes diferente na palavra: little-endian, como na maioria dos processadores modernos, quando um número inteiro multibyte é gravado na memória, começando com o byte baixo, e big-endian, quando um número inteiro multibyte é gravado na memória, iniciando de byte alto. Quase todos os processadores modernos operam no modo little-endian, mas você não deve descartar outros possíveis pedidos de bytes em uma palavra.
Em segundo lugar, a instrução CALL pode usar o endereço absoluto do ponto de salto ou o endereço relativo ao endereço atual. No caso de endereçamento absoluto, a instrução codificada contém o endereço para o qual você deseja ir em alguns bits da instrução codificada. Para garantir que a sub-rotina seja chamada de qualquer ponto no espaço de endereço de 16 bits para qualquer outro ponto no endereço absoluto da palavra de 16 bits, a instrução codificada não é suficiente, pois, além do endereço de transição, os bits do código de operação precisam ser armazenados em outro lugar. Portanto, faz sentido considerar duas palavras de 16 bits seguidas e tentar opções diferentes para dividir o endereço de transição entre essas palavras.
Uma alternativa à codificação absoluta de um endereço de rotina é a codificação relativa. Na instrução codificada, registramos a diferença entre o endereço do subprograma e o ponto atual. A codificação relativa é geralmente preferível à absoluta, porque, em primeiro lugar, um programa com transições relativas é posicionalmente independente, ou seja, pode ser localizado na memória a partir de qualquer endereço sem alterações no código binário. Em segundo lugar, para codificar o deslocamento, menos bits podem ser reservados que a dimensão do espaço de endereço, com base no fato de que, em muitos casos, a rotina chamada não está tão distante da chamada. No entanto, se o deslocamento da chamada estiver fora do intervalo de valores representáveis, você precisará inserir instruções especiais - "saltos".
A codificação relativa de um endereço de subprograma pode ser realizada com algumas variações: primeiro, o endereço do ponto atual do programa pode ser usado como o endereço da instrução atual ou como o endereço da próxima instrução, como nos processadores x86, ou o endereço de alguma outra instrução próxima ao ponto atual. Por exemplo, nos processadores ARM, o ponto de referência é o endereço da instrução atual +8 (ou seja, não o endereço da instrução após a CHAMADA, mas o endereço da instrução após a próxima). Além disso, como no nosso caso o programa é escrito como um fluxo de palavras de 16 bits, é lógico esperar que o deslocamento seja expresso em palavras. Ou seja, para obter o endereço da rotina chamada, o deslocamento precisará ser multiplicado por 2.
Levando em conta tudo acima, obtemos o seguinte espaço de enumeração para procurar um par CALL / RET no código binário.
Primeiro, usamos palavras de 16 bits da lista dos valores mais comuns no código como candidatos à instrução RET. Em seguida, pesquisamos a instrução CALL:
- Ordem de bytes de palavra big endian e little endian
- Codificação absoluta e relativa do endereço de rotina na instrução.
Para codificação absoluta, consideramos dois valores de 16 bits em uma linha, ou seja, classificamos várias opções para colocar um campo de bits que armazena um endereço absoluto em uma palavra de 32 bits e, para codificação relativa, consideramos valores de 16 bits e duas palavras de 16 bits em uma linha . Em seguida, classificamos as várias opções para colocar um campo de bits que armazena compensações. Verificamos se o deslocamento é expresso em bytes ou em palavras de 16 bits, ou seja, se é necessário multiplicar o deslocamento por 2, verificamos diferentes opções para o ponto de referência: o endereço da instrução atual, o endereço da próxima instrução.
Para cada uma das opções no espaço de pesquisa descrito acima, calculamos as estatísticas:
- Quantos endereços supostos do início dos subprogramas não estão obviamente corretos, ou seja, eles estão localizados onde não há nada ou onde os dados (cadeias explícitas ou tabelas de valores explícitos) estão localizados. Mesmo para o valor correspondente à codificação correta da instrução CALL, é bem possível que um pequeno número de endereços incorretos do início do subprograma seja possível se o valor correspondente à instrução CALL ocorrer acidentalmente nos dados.
- Quantos endereços iniciais de rotina putativos estão imediatamente após a instrução RET putativa.
- Quantos endereços hipotéticos de início de rotinas são usados mais de uma vez.
Se nossas suposições sobre um par de instruções CALL / RET estiverem corretas, deverá estar no espaço de enumeração descrito. Mas também pode haver falsos positivos. Bem, começamos a busca.
E encontramos apenas uma opção possível!
Trying 8c0d as RET After-ret-addr-set-size: 430 Matching call opcodes for 1, ff00ff00, 1: 000b003d: total: 1275, hits: 843 (66
Portanto, a palavra 8c0d de 16 bits é adequada como candidata à instrução RET. No total, o firmware contém 430 posições de endereços de programas imediatamente após esta instrução. Foram considerados valores de 32 bits (duas palavras de 16 bits seguidas), com um valor de máscara de endereço de ff 00 ff 00, uma instrução com o código 00 0b 00 3d foi encontrada. Existem 1275 dessas instruções, das quais 843 (ou seja, 66%) transferem o controle para o ponto imediatamente após o candidato ao RET. Assim, duas instruções foram identificadas:
- RET: 8c0d (Little-Endian de 16 bits)
- CHAMADA HHLL: 0bHH 3dLL (2 Little-Endian de 16 bits)
A instrução CALL usa endereçamento absoluto e, ao escrever o endereço de salto, o byte alto é gravado primeiro e o byte baixo é gravado. É possível que, na realidade, estas sejam duas instruções do processador, cada uma carregando metade do endereço de transição, mas do ponto de vista da análise do programa, isso não é importante. Conhecendo as instruções CALL e RET, podemos marcar com mais precisão áreas de código e dados do programa, o que será importante para análises posteriores.
Para continuar ...
Além disso, mostraremos como as transições condicionais e incondicionais e algumas operações aritméticas e lógicas foram restauradas.