Secreto de firmware

Autores: Ph.D. Chernov A.V. ( monsieur_cher ) y Ph.D. Troshina K.N.

驴C贸mo, utilizando los supuestos m谩s generales basados 鈥嬧媏n 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 鈥嬧媝or 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 鈥嬧媏n 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 鈥嬧媏l 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 %ebx,%eax 

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% 0800 473 2.8% 8c0d 432 2.6% 2b00 401 2.4% 4e1c 365 2.2% 0801 277 1.6% 

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 鈥嬧媗a 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%), misses: 432 (33%), coverage: 76% 

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.

Source: https://habr.com/ru/post/442262/


All Articles