Autores: Ph.D. Chernov A.V. ( monsieur_cher ) y Ph.D. Troshina K.N.¿Cómo, utilizando los supuestos más generales basados en el conocimiento de las arquitecturas de procesador modernas, puede restaurar la estructura del programa a partir de una imagen binaria de una arquitectura desconocida, y luego restaurar algoritmos y mucho más?En este artículo hablaremos sobre una tarea interesante que se nos planteó hace varios años. El cliente solicitó lidiar con el firmware binario del dispositivo que administraba cierto proceso físico. Necesitaba un algoritmo de control en forma de un programa C compilado, así como fórmulas con una explicación de cómo funcionan y por qué. Según el Cliente, esto era necesario para garantizar la compatibilidad con el equipo "antiguo" en el nuevo sistema. La forma en que finalmente tratamos la física, en el marco de esta serie de artículos, la omitimos, pero consideraremos en detalle el proceso de restauración del algoritmo.
El uso casi omnipresente de microcontroladores programables en dispositivos masivos (IOT o SmartHome Internet of Things) requiere prestar atención al análisis binario del código incrustado o, en otras palabras, al análisis binario del firmware del dispositivo.
Un análisis binario del firmware del dispositivo puede tener los siguientes objetivos:
- Análisis del código en busca de vulnerabilidades que permitan obtener acceso no autorizado al dispositivo o a los datos transmitidos o procesados por este dispositivo.
- Análisis de código para características no documentadas, que conducen, por ejemplo, a la fuga de información.
- Análisis de código para restaurar protocolos e interfaces de interacción con dispositivos para garantizar la compatibilidad de este dispositivo con otros.
La tarea anterior de analizar un código binario se puede considerar como un caso especial de la tarea de analizar un binario para garantizar la compatibilidad del dispositivo.
Análisis del formato de archivo binario.
Si en el mundo de los sistemas operativos "reales", los formatos de archivos ejecutables están estandarizados, entonces, en el mundo de los programas integrados, cada proveedor puede usar su solución patentada. Por lo tanto, el análisis del archivo de firmware binario debe comenzar con el análisis del formato de archivo binario.
Al comienzo del trabajo, la situación para nosotros era la siguiente: recibimos un cierto archivo con el firmware sin ninguna documentación que lo acompañara. No hubo información sobre el formato del archivo de firmware, ni sobre la arquitectura del microcontrolador.
El archivo de firmware resultó ser un archivo de texto. Contenía líneas de la siguiente forma:
:04013000260F970CF8 :10020000004D000B043F000B34AD010C002FFE4D30 :02023000FD0BC1 :1004000018001A0000001E0008005E000200190052
Después de analizar cuidadosamente el conjunto de estas líneas, nos dimos cuenta de que este es un formato Intel HEX completamente estándar para microcontroladores. El archivo consta de registros, cada uno de los cuales indica su tipo, ubicación de memoria, datos y suma de verificación. Por sí solo, el uso del formato Intel Hex implica que el archivo probablemente no está encriptado y es una imagen de un programa que reside en la memoria.
Aunque el formato Intel Hex admite direcciones de memoria de hasta 32 bits, solo había direcciones de memoria de 16 bits en nuestro archivo. Por lo tanto, es fácil crear un archivo binario de una imagen de memoria a partir de un archivo de texto en el que los registros del archivo de prueba original ya se colocarán en las direcciones especificadas. Es más conveniente inspeccionar dicho archivo binario utilizando las utilidades de análisis de archivos binarios, y es más fácil escribir sus propias utilidades para él que para Intel HEX. El archivo de memoria de imagen binaria confirmó que el archivo no estaba encriptado, ya que se encontraron varias líneas significativas dispersas en diferentes lugares del código.
Sin embargo, esto no respondió la pregunta para qué arquitectura es este archivo.

Tenemos un archivo con la imagen de memoria de un microcontrolador de 16 u 8 bits. Y qué tipo de microcontrolador es no está claro. Tomamos IDA Pro e intentamos desmontar el archivo con todas las variantes posibles de procesadores compatibles. Y nada Ninguno de los procesadores IDA Pro admitidos apareció: la lista no se generó o contenía tonterías obvias. Puede haber sido un programa para uno de los procesadores IDA Pro compatibles, pero hicimos algo mal. Por ejemplo, solo necesitaba un procesamiento adicional del archivo de imagen. En cualquier caso, aquí fue posible suspender el trabajo y solicitar información adicional sobre el archivo binario.
Todos los procesadores son casi iguales.
Pero nos resultó interesante y lo que podemos entender del programa binario, incluso si se desconoce el procesador para el que está compilado. La respuesta es "nada", sin interés, incluso si podemos entender un poco, es mejor que nada. Obviamente, las cadenas de texto pueden proporcionar información sobre el programa, pero nuestro objetivo es más: comprender algo de la estructura del programa.
Varias arquitecturas de procesador: un gran número. La evolución de la informática ha generado incluso las arquitecturas más inusuales, como las computadoras ternarias. Sin embargo, los microprocesadores y microcontroladores que existen actualmente, al menos los masivos, son notablemente similares entre sí.
A continuación, enumeramos los principios básicos comunes a los microprocesadores modernos.
Ejecución consistente de instrucciones. El procesador ejecuta instrucciones en secuencia en la memoria. Hay instrucciones especiales para salto y llamada condicional e incondicional desde la subrutina, que le permiten interrumpir la selección secuencial de instrucciones de la memoria y proceder a la ejecución de otra instrucción. Sin embargo, el resto de las instrucciones asumen una ejecución secuencial y, por lo tanto, no contienen la dirección de la siguiente instrucción.
Codificación binaria. Además del hecho de que el procesador procesa los datos en forma binaria, las instrucciones del procesador que componen el programa ejecutable están codificadas en formato binario, es decir, los campos en los que se almacenan los parámetros de la instrucción, por ejemplo, compensaciones o números de registro, ocupan un número entero de bits. Teóricamente, uno puede imaginar que, a pesar de la codificación binaria de datos y programas, serán procesados en el procesador en algún otro sistema de números, pero no somos conscientes de tal exotismo.
Los siguientes principios, en general, no son principios arquitectónicos básicos, pero se implementan de manera prácticamente universal, especialmente para el código de máquina que no está escrito manualmente en lenguaje ensamblador, sino que es generado por un compilador de lenguaje de alto nivel.
Programación procesal. El programa se divide en unidades estructurales, que se pueden llamar de manera diferente: procedimientos, funciones, subprogramas, etc. Los subprogramas pueden llamar a otros subprogramas, pasarles parámetros y recuperar el resultado de la ejecución. Es importante que el subprograma tenga un punto de entrada, es decir, todos los subprogramas que invocan el dado van a la misma dirección del punto de entrada.
Típicamente, las rutinas tienen un punto de salida que devuelve el control al punto de llamada, pero esto es menos significativo que requerir un punto de entrada para cada rutina. Tal código generalmente se obtiene compilando un programa. El optimizador de tiempo de enlace puede destruir parcialmente esta estructura para reducir el tamaño del programa, y el tamaño del programa es crítico para los sistemas integrados. Además, esta estructura puede ser destruida por el ofuscador de código.
La anidación de llamadas de subrutina se puede organizar utilizando la pila, que todavía se puede usar para pasar argumentos a la subrutina y almacenar variables locales, pero en el nivel actual de desarrollo de la arquitectura esta información es prematura.
¿Cómo se pueden aplicar estos principios al análisis inicial del código binario?
Suponemos que hay una instrucción RET (retorno de una subrutina) en el sistema de comando del procesador. Esta instrucción tiene algún tipo de representación binaria fija, que buscaremos. Si RET no es el único, como en x86, donde RET tiene un argumento: el tamaño del área de parámetros de la subrutina, o si RET es un efecto secundario de una operación más complicada, como en ARMv7, donde el valor de PC se puede obtener de la pila simultáneamente con los valores de otros registros (ldmfd sp! , {fp, pc}), entonces, muy probablemente, nuestra búsqueda heurística no arrojará resultados.
También necesitamos hacer suposiciones razonables de inmediato sobre el principio de codificar las instrucciones del procesador en estudio. Los procesadores existentes utilizan varios principios para codificar instrucciones:
- Una secuencia de bytes a partir de la cual se generan las instrucciones, y diferentes instrucciones se codifican con un número diferente de bytes. En esta categoría, el representante más famoso es la familia x86, desde los primeros procesadores 8080 hasta los procesadores de 64 bits más modernos. Una instrucción de procesador x86_64 puede codificarse en una secuencia de 1 a 16 bytes. La misma familia de procesadores con longitudes de instrucción variables incluye 8051, que se utiliza en microcontroladores.
- Una secuencia de valores de 16 bits. Además, cada instrucción tiene un tamaño fijo: 16 bits.
- Una secuencia de valores de 16 bits, mientras que las instrucciones son de tamaño variable. Uno de los representantes de esta familia es la arquitectura PDP-11, en la cual el comando mismo ocupa los primeros 16 bits, y puede ser seguido por valores directos o direcciones de memoria para direccionamiento directo. Esto incluye la codificación THUMB en la arquitectura ARM.
- Una secuencia de valores de 32 bits, cada instrucción tiene un tamaño fijo de 32 bits. Estos son la mayoría de los procesadores RISC de 32 y 64 bits: ARMv7, ARMv8, MIPS.
Elegir entre una secuencia de bytes de longitud variable y una secuencia de palabras de 16 bits ayudará a ver la imagen de la memoria "a simple vista". No importa cómo se codifiquen las instrucciones del procesador, en un programa de longitud suficiente, inevitablemente se repetirán. Por ejemplo, en la instrucción x86
add
que agrega los valores de los registros eax y ebx y pone el resultado en eax, se codifica en dos bytes:
01 d8.
En la instrucción ARMv7
add r0, r0, r1
que agrega los valores de los registros r0 y r1 y pone el resultado en r0, está codificado por el valor de 32 bits e0800001.
En un programa suficientemente grande, tales instrucciones se repetirán más de una vez. Si se produce una secuencia de bytes de interés para nosotros (por ejemplo, 01 d8) en una dirección arbitraria desalineada, podemos suponer que las instrucciones del procesador están codificadas por una secuencia de bytes de tamaño variable. Si el valor, por ejemplo, e0800001 se encuentra solo en direcciones que son múltiplos de 4, podemos suponer un tamaño fijo de las instrucciones del procesador. Por supuesto, aquí hay un error de que tomamos bytes de datos para una instrucción, o sucedió por casualidad que alguna instrucción siempre resultó estar alineada. Sin embargo, el impacto de tal "ruido" en un programa de tamaño suficiente será pequeño.
Cuando observamos el firmware analizado desde este ángulo, quedó claro que lo más probable es que las instrucciones para el procesador en cuestión estén codificadas con valores de 16 bits.
Entonces, en base al supuesto de que la codificación de la instrucción RET es un valor fijo de 16 bits, intentemos encontrarlo. Encontramos en la imagen del programa los valores de 16 bits más comunes. En nuestro caso, sucedió lo siguiente:
(hex) 0b01 854 5.1
Buscaremos la instrucción RET entre estos valores de 16 bits que se encuentran con mayor frecuencia en el código. Inmediatamente buscaremos la instrucción CALL - emparejada con la instrucción RET. La instrucción CALL tiene al menos un parámetro: la dirección de salto, por lo que los valores fijos son indispensables.
Suponemos que en muchos casos, inmediatamente después del final de un subprograma, es decir, después de la instrucción RET, comienza otro subprograma, y la instrucción CALL llama a este subprograma desde otro punto del programa. Una gran cantidad de saltos a la dirección inmediatamente después de RET será una de las características de la instrucción CALL. Por supuesto, esta regla no es universal, ya que en algunas plataformas, en particular, ARMv7, inmediatamente después de la finalización de la subrutina, generalmente se encuentra un grupo constante. En este caso, podemos considerar un rango razonable de direcciones inmediatamente después de RET como puntos de transición de la instrucción RET.
En el caso de la instrucción CALL, puede haber muchas opciones para codificarla en la subrutina. En primer lugar, el procesador puede usar un orden de bytes diferente en la palabra: little-endian, como en la mayoría de los procesadores modernos, cuando un entero multibyte se escribe en la memoria, comenzando con el byte bajo, y big-endian, cuando un entero multibyte se escribe en la memoria, comenzando de alto byte. Casi todos los procesadores modernos funcionan en modo little-endian, pero no debe descartar otras posibles órdenes de bytes en una palabra.
En segundo lugar, la instrucción CALL puede usar el direccionamiento absoluto del punto de salto o el direccionamiento relativo a la dirección actual. En el caso del direccionamiento absoluto, la instrucción codificada contiene la dirección a la que desea ir en algunos bits de la instrucción codificada. Para garantizar que la subrutina se llame desde cualquier punto en el espacio de direcciones de 16 bits a cualquier otro punto en la dirección absoluta de la palabra de 16 bits, la instrucción codificada no es suficiente, ya que además de la dirección de transición, los bits del código de operación deben almacenarse en otro lugar. Por lo tanto, tiene sentido considerar dos palabras de 16 bits seguidas y probar diferentes opciones para dividir la dirección de transición entre estas palabras.
Una alternativa a la codificación absoluta de una dirección de rutina es la codificación relativa. En la instrucción codificada, registramos la diferencia entre la dirección del subprograma y el punto actual. La codificación relativa generalmente es preferible a absoluta, porque, en primer lugar, un programa con transiciones relativas es posicionalmente independiente, es decir, puede ubicarse en la memoria desde cualquier dirección sin ningún cambio en el código binario. En segundo lugar, para codificar el desplazamiento, se pueden reservar menos bits que la dimensión del espacio de direcciones, debido al hecho de que en muchos casos la rutina llamada no está tan lejos de la que llama. Sin embargo, si el desplazamiento de la llamada está fuera del rango de valores representables, deberá insertar instrucciones especiales: "saltos".
La codificación relativa de una dirección de subprograma se puede realizar con algunas variaciones: en primer lugar, la dirección del punto actual del programa se puede tomar como la dirección de la instrucción actual, o como la dirección de la siguiente instrucción, como en procesadores x86, o la dirección de alguna otra instrucción cerca del punto actual. Por ejemplo, en los procesadores ARM, el punto de referencia es la dirección de la instrucción actual +8 (es decir, no la dirección de la instrucción que sigue a la LLAMADA, sino la dirección de la instrucción que sigue a la siguiente). Además, dado que en nuestro caso el programa se escribe como un flujo de palabras de 16 bits, es lógico esperar que el desplazamiento se exprese en palabras. Es decir, para obtener la dirección de la rutina llamada, el desplazamiento deberá multiplicarse por 2.
Teniendo en cuenta todo lo anterior, obtenemos el siguiente espacio de enumeración para buscar un par CALL / RET en código binario.
Primero, tomamos palabras de 16 bits de la lista de los valores más comunes en el código como candidatos para la instrucción RET. A continuación, buscamos a través de la instrucción CALL:
- Orden de bytes de palabras big-endian y little-endian
- Codificación absoluta y relativa de la dirección de rutina en la instrucción.
Para la codificación absoluta, consideramos dos valores de 16 bits en una fila, es decir, clasificamos varias opciones para colocar un campo de bits que almacena una dirección absoluta en una palabra de 32 bits, y para la codificación relativa consideramos valores de 16 bits y dos palabras de 16 bits en una fila. . A continuación, clasificamos las diferentes opciones para colocar un campo de bits que almacena las compensaciones. Verificamos si el desplazamiento se expresa en bytes o en palabras de 16 bits, es decir, si es necesario multiplicar el desplazamiento por 2, verificamos diferentes opciones para el punto de referencia: la dirección de la instrucción actual, la dirección de la siguiente instrucción.
Para cada una de las opciones en el espacio de búsqueda descrito anteriormente, calculamos estadísticas:
- Cuántas supuestas direcciones del comienzo de los subprogramas no son obviamente correctas, es decir, están ubicadas donde no hay nada o donde los datos (filas explícitas o tablas explícitas de valores) están obviamente ubicadas. Incluso para el valor correspondiente a la codificación correcta de la instrucción CALL, es bastante posible que sea posible un pequeño número de direcciones incorrectas del comienzo del subprograma si el valor correspondiente a la instrucción CALL ocurre accidentalmente en los datos.
- Cuántas direcciones de inicio de rutina putativas hay inmediatamente después de la instrucción RET putativa.
- Cuántas direcciones de inicio hipotéticas de rutinas se usan más de una vez.
Si nuestras suposiciones sobre un par de instrucciones CALL / RET son correctas, entonces debería estar en el espacio de enumeración descrito. Pero también puede haber falsos positivos. Bueno, comenzamos la búsqueda.
¡Y encontramos solo una opción posible!
Trying 8c0d as RET After-ret-addr-set-size: 430 Matching call opcodes for 1, ff00ff00, 1: 000b003d: total: 1275, hits: 843 (66
Por lo tanto, la palabra 8c0d de 16 bits es adecuada como candidata para la instrucción RET. En total, el firmware contiene 430 posiciones de direcciones de programa inmediatamente después de esta instrucción. Consideramos valores de 32 bits (dos palabras de 16 bits seguidas), con un valor de máscara de dirección de ff 00 ff 00, se encontró una instrucción con el código 00 0b 00 3d. Hay 1275 instrucciones de este tipo, de las cuales 843 (es decir, 66%) transfieren el control al punto que sigue inmediatamente al candidato para RET. Por lo tanto, se han identificado dos instrucciones:
- RET: 8c0d (Little-Endian de 16 bits)
- CALL HHLL: 0bHH 3dLL (2 Little-Endian de 16 bits)
La instrucción CALL usa direccionamiento absoluto, y al escribir la dirección de salto, el byte alto se escribe primero, luego se escribe el byte bajo. Es posible que en realidad se trate de dos instrucciones de procesador, cada una de las cuales carga la mitad de la dirección de transición, pero desde el punto de vista del análisis del programa, esto no es importante. Conociendo las instrucciones CALL y RET, podemos marcar con mayor precisión las áreas de código y los datos del programa, que serán importantes para un análisis posterior.
Continuará ...
Además, diremos cómo se restauraron las transiciones condicionales e incondicionales y algunas operaciones aritméticas y lógicas.