Random IRC quote :      <lain> no busco guarrillas por la red

Problemas del análisis de código

Aupa,

En este post os voy a hablar un poco de como he escrito un motor de análisis de código para x86 y x86_64, de los problemas que me he encontrado y de como he intentado solucionarlos (los que he arreglado, vamos…).

Hace ya algún tiempo que comencé con un proyecto llamado Pyew (similar al todo poderoso radare) el cual está pensado, principalmente, para análisis de baterías de malware. Se podría utilizar el IDA para ello pero, en ocasiones, esta herramienta es un poco pesada y, además, requiere una licencia (no muy barata, por cierto) que no todo el mundo tiene.

Comienzo

Al lío. Para hacer un reemplazo ligero del IDA para analizar malware se necesitan como mínimo 3 cosas:

  1. Un cargador de formatos de ficheros: Para PE, ELF, Mach-O o lo que sea que te interese. En mi caso he utilizado Python PEFile para PEs y VTrace para ELFs.
  2. Un desensamblador para el procesador: En mi caso, elegí Distorm64 (la versión 2) ya que tiene buen soporte para Python y soporta código x86 de 16, 32 y 64 bits.
  3. Un motor de análisis de código: Esta es la pieza clave que no pude ‘coger de por ahí’ fácilmente, sino que me la tendría que escribir desde «0» (bueno, la verdad es que se podría utilizar radare, pero preferí escribirlo por mi cuenta y aprender algo de paso).

Análisis de código

Análisis de código es el proceso por el cual descubrimos las funciones, los bloques básicos de las que se componen así como las relaciones entre las mismas. Vale, ¿Y qué es un bloque básico? Un conjunto de instrucciones seguidas hasta un salto condicional (no es exactamente así, pero de momento nos quedamos con esta idea). Explicado esto ¿Cómo se realiza análisis de código de un binario para descubrir funciones, bloques básicos, etc…? Lo primero, hay que saber desde donde empezar a buscar código, desensamblar linealmente desde cada uno de los puntos de entrada hasta encontrar un salto condicional, el cual demarcará cual es el final del bloque básico, y seguir analizando todos los caminos posibles hasta que todas las rutas de código se agoten. Pongamos un ejemplo:

 

0x0000a950 TEST RDI, RDI
0x0000a953 PUSH RBX
0x0000a954 MOV RBX, RDI
0x0000a957 JZ 0x0000a9c3 ; 1
0x0000a957 ———————————————————————-
0x0000a959 MOV ESI, 0x2f
0x0000a95e CALL 0x00002410 ; 2
(…hace sus cositas…)
0x0000a9c3 POP RBX
0x0000a9c4 RET
0x0000a9c4 ———————————————————————-

En este caso, la función que estamos analizando, comienza en la instrucción TEST, en 0xa950 y el bloque básico acaba en el primer salto condicional, en la instrucción JZ (en 0xa957). Ahora tenemos 2 posibles caminos que tomar: en caso de que la condición se cumpla (saltando a 0xa9c3) y el caso contrario, en el cual continuaría con la siguiente instrucción. ¿Cuándo terminaríamos de analizar esta función? Cuando llegamos a la instrucción RET, en 0xa9c4, que es donde acaba el último basic block y ya la función (porque no hay más caminos posibles que seguir).

Grafo de la sencilla función comentada

Mientras vamos siguiendo el flujo del programa descubriendo nuevas rutas de código, guardamos las llamadas a función, las ponemos en una lista y, al acabar con la función actual, se continúa con las funciones (direcciones) que se hayan encontrado. Hasta aquí todo muy bien y muy fácil. ¿Y con esto se descubren todas las funciones y todos los bloques básicos de una función? Ni mucho menos.

Bloques básicos

Antes había dicho que un bloque básico de una función es un conjunto de instrucciones seguidas hasta un salto condicional, no? Bueno, pues no exactamente. Pongamos un ejemplo fácil:

0x000003c5 MOV EBP, ESP
0x000003c7 SUB ESP, 0x28
0x000003ca MOV DWORD [EBP-0xc], 0x0
0x000003d1 JMP 0x000003ef ; 1
0x000003d1 ———————————————————————-
0x000003d3 ADD DWORD [EBP-0xc], 0x1
0x000003d7 MOV EAX, 0x80484d0
0x000003dc MOV EDX, [EBP-0xc]
0x000003df MOV [ESP+0x4], EDX
0x000003e3 MOV [ESP], EAX
0x000003e6 CALL 0x000002f4 ; 2 j_printf
0x000003eb ADD DWORD [EBP-0xc], 0x1
0x000003ef ———————————————————————-
0x000003ef CMP DWORD [EBP-0xc], 0x9
0x000003f3 JLE 0x000003d3 ; 3
0x000003f3 ———————————————————————-
0x000003f5 LEAVE
0x000003f6 RET

En este caso, en el primer bloque básico llegamos a un salto incondicional (JMP) y, siguiendo ese salto, encontramos después un salto condicional (JLE) a la dirección 0x3d3.  Las instrucciones desde 0x3c5 hasta 0x3d1 y 0x3ef a 0x3f3 no forman un único bloque básico, por una razón: un bloque básico solo puede tener un único punto de entrada. Así pues, lo que inicialmente creíamos que es un bloque básico hay que separarlo en piezas más pequeñas, de modo que cada bloque tenga solo un único punto de entrada (no así de salida). El grafo de flujo para esta función quedaría como sigue:

Grafo de la función con bloques básicos que no cumplen la lóǵica explicada inicialmente.

Ahora ya sabemos como tiene que ser un bloque básico correctamente y, teniendo en cuenta esto, ¿Tendríamos ya un motor de análisis de código funcional? Ni mucho menos.

Punteros a función

Los punteros a función son un quebradero de cabeza a la hora de analizar código estáticamente para encontrar funciones. ¿Porqué? Pongamos un ejemplo con el siguiente código en C:

int func(int i)
{
      printf("Number of arguments: %d\n", i);
}

void foo(int (*f)(int), int val)
{
      if ( f != NULL )
              f(val);
}

int main(int argc, char **argv)
{
      foo(&amp;func, argc);
}

Si compilamos este código (con GCC en mi caso) se generará el siguiente código ensamblador:

0x000003c4 ; FUNCTION func
0x000003c4 (01) PUSH EBP
0x000003c5 (02) MOV EBP, ESP
0x000003c7 (03) SUB ESP, 0x18
0x000003ca (05) MOV EAX, 0x80484e0 ; ‘Number of arguments: %d\n’
0x000003cf (03) MOV EDX, [EBP+0x8]
0x000003d2 (04) MOV [ESP+0x4], EDX
0x000003d6 (03) MOV [ESP], EAX
0x000003d9 (05) CALL 0x000002f4 ; 1 j_printf
0x000003de (01) LEAVE
0x000003df (01) RET
()
0x000003f9 ; FUNCTION main
0x000003f9 (01) PUSH EBP
0x000003fa (02) MOV EBP, ESP
0x000003fc (03) AND ESP, -0x10
0x000003ff (03) SUB ESP, 0x10
0x00000402 (03) MOV EAX, [EBP+0x8]
0x00000405 (04) MOV [ESP+0x4], EAX
0x00000409 (07) MOV DWORD [ESP], 0x80483c4 ; func
0x00000410 (05) CALL 0x000003e0 ; 1 foo
0x00000415 (01) LEAVE
0x00000416 (01) RET
()
0x000003e0 ; FUNCTION foo
0x000003e0 (01) PUSH EBP
0x000003e1 (02) MOV EBP, ESP
0x000003e3 (03) SUB ESP, 0x18
0x000003e6 (04) CMP DWORD [EBP+0x8], 0x0
0x000003ea (02) JZ 0x000003f7 ; 1
0x000003ea ———————————————————————-
0x000003ec (03) MOV EAX, [EBP+0xc]
0x000003ef (03) MOV [ESP], EAX
0x000003f2 (03) MOV EAX, [EBP+0x8]
0x000003f5 (02) CALL EAX
0x000003f7 (01) LEAVE
0x000003f8 (01) RET

Realmente, no tenemos ninguna ruta de código a esa función así que, siguiendo la lógica actual de nuestro motor, no encontraremos la función «func». ¿Soluciones? Varias, una de ellas, la más compleja, es trackear los argumentos y sus valores (haciendo una especie de mini-emulación). Si una dirección es pasada como argumento a una función y después se ejecuta esa dirección, pues sabemos que es un puntero a función, así que esa dirección la metemos en la cola de direcciones a analizar y listo.

Sin embargo, esta solución tampoco es 100% correcta. ¿Porqué? Imaginemos que la función que se encarga de ejecutar el puntero que le pasamos está en una librería, no en el binario que estamos ejecutando: como no tenemos el código de la librería (no se encuentra en el binario que estamos analizando) no sabemos que hará con esa dirección que se le pasa. Mi solución para esto es la siguiente: cada  vez que se referencia una dirección a un segmento ejecutable, esta dirección la pongo en la cola para posterior análisis. De este modo, muchas funciones de este tipo se podrían descubrir.

Aún así, tenemos otro problema más: ¿Y si el puntero se calcula en ejecución? Pues estamos jodidos, básicamente. Podemos intentar otras soluciones, como la siguiente.

Prólogos de función

El modo más típico de búsqueda de funciones es la búsqueda de prólogos de función. Por ejemplo, para x86 (32 bits) el siguiente es un prólogo típico:

PUSH EBP
MOV  EBP, ESP

Podríamos, simplemente, buscar los opcodes de este prólogo de función en los segmentos con permisos de ejecución (no necesariamente, ya que para malware, puede ser que se encuentre código en segmentos que no tienen permisos de ejecución y que luego se copien a una zona de memoria donde sí se tengan privilegios)  y, si esa dirección no corresponde con ninguna función ya analizada, poner dichas direcciones en la cola de análisis. Así se pueden encontrar muchas funciones que de otro modo, probablemente, no encontraríamos.

Pero, como siempre con el análisis de código, esta técnica tiene la oxtia de problemas: ¿Y si los opcodes que he encontrado corresponden a ese prólogo pero realmente no es una función? Este problema no es tan fácil de solucionar, la verdad. Mi solución es seguir desensamblando, buscando si el código tiene sentido (encuentro rutas de código que no se van a casa krixto, encuentro bloques básicos bien formados, etc…). Aún así, esta solución es solo parcial: ¿Qué ocurre si, por casualidad, resulta que encuentro una zona que parece código correcto y no lo es? La posible solución (que en Pyew no la he puesto aún) es asegurarse que el epílogo de función coincida. Esto es, si el prólogo de función típico de x86 es PUSH EBP & MOV EBP, ESP; habrá que buscar su epílogo correspondiente en los bloques básicos de salida, que es LEAVE & RET. Aún así, esto tampoco sería correcto, un ejemplo:

PUSH EBP
MOV  EBP, ESP
MOV  EAX, EBP+4
JMP  [EAX+8]

Esto podría ser un código perfectamente válido. La función recibe un argumento y salta inconicionalmente (que no llama) a la dirección pasada más un offset (una VTable, por ejemplo). La función a la que salta es la que, realmente, se encarga de arreglar la pila antes de retornar, es decir, es en esa zona de memoria donde se encuentra el epílogo de función.

De todos modos, vamos a suponer que ‘mágicamente’ tenemos solucionado también este problema. ¿Se podría decir que el motor está finalizado? Ni mucho menos: la búsqueda de prólogos de función es un coñazo porque hay múltiples prólogos de función. Por ejemplo, si desensamblamos ejecutables de Microsoft (librerías de Windows, el notepad, lo que sea) nos encontraremos que los prólogos a función no son como había puesto antes, sino así:

MOV  EDI, EDI
PUSH EBP
MOV  EBP, ESP

Así que nuestro motor de análisis de código no estaría encontrando el principio de la función, si no que estaríamos saltándonos una instrucción. La solución es buscar más prólogos: en vez de buscar solo PUSH EBP & MOV EBP, ESP, buscamos también con MOV EDI, EDI antes. Pero aún así, no vamos a encontrar todas las funciones del binario. ¿Porqué? Por la convención de llamadas de cada función, entre otras n-mil cosas.

Convenciones de llamada

Cada compilador, arquitectura, etc… tiene una serie de convenciones de llamadas, siendo las más típicas (x86) CDECL y STDCALL. La diferencia principal entre estas 2 convenciones de llamadas es la siguiente: en CDECL es el que llama a la función el que se encarga de restaurar la pila, mientras que en STDCALL es la función llamada la que tiene que restaurar la pila. En ambos casos, el prólogo de función cambiará, así que estamos jodidos.

Por si esto fuera poco, estas 2 no son las únicas convenciones de llamadas existentes. Tenemos otras más, como por ejemplo FASTCALL (la cual, por cierto, es implementada por cada fabricante como se le pone de los huevos), PASCAL, THISCALL o incluso podemos tener convenciones de llamada no estándar que el compilador ha decidido meter «así» porque resultaban más eficientes o usaban menos espacio.

Pero este no es el final de los problemas, aún tenemos otros todavía más gordos…

El coño de la Bernarda (también llamado contrucciones «switch»)

Un switch es una sentencia condicional que en función del valor comprobado tomará una ruta u otra o, incluso, varias. ¿Cómo se imlementa un switch en ensamblador? Cada compilador lo hace como se le pone de ahí. El modo más típico es el siguiente: tener una tabla de offsets a función y llamar a un registro (donde está la dirección de esa tabla) «+» un offset, que sería el índice dentro de la tabla. Este es el modo más típico para un switch medio/grande. Veamos un ejemplo con el siguiente grafo visto en IDA:

Ejemplo de switch sencillo (GCC, 32bits)

Si miramos el bloque básico que tiene varias salidas nos encontraremos con el siguiente código ensamblador:

 

.text:08048414
.text:08048414                 public main
.text:08048414 main            proc near               ; DATA XREF: _start+17o
.text:08048414
.text:08048414 arg_0           = dword ptr  8
.text:08048414 arg_4           = dword ptr  0Ch
.text:08048414
.text:08048414                 push    ebp
.text:08048415                 mov     ebp, esp
.text:08048417                 and     esp, 0FFFFFFF0h
.text:0804841A                 push    ebx
.text:0804841B                 sub     esp, 1Ch
.text:0804841E                 cmp     [ebp+arg_0], 7  ; switch 8 cases
.text:08048422                 ja      loc_80484D7
.text:08048428                 mov     eax, [ebp+arg_0]
.text:0804842B                 shl     eax, 2
.text:0804842E                 mov     eax, ds : off_8048670[eax]
.text:08048434                 jmp     eax             ; switch jump
.text:08048436 ; —————————————————————————
.text:08048436

IDA ha encontrado que la dirección 0x8048670 es una tabla de offsets (direcciones) a los bloques básicos que ha unido después. Es decir, ha encontrado un switch. ¿Cómo lo ha hecho IDA? Analizando el código que se encuentra en ese offset que ha encontrado que luego se accede al mismo por índice (0x0804842E) y que después hay un salto incondicional al valor que haya obtenido. Así que, si nosotros hacemos lo mismo en nuestro motor ¿Ya tendríamos soporte para sentencias switch? Ni de lejos… Cada compilador y cada arquitectura tiene varios tipos diferentes de dialecto de switch. Unos crean tablas como la que aquí se muestra. Otros, lo hacen accediendo a zonas de memoria relativa obteniendo la posición en ejecución. Un ejemplo (escrito a mano, que ahora mismo no encuentro ningún binario):

PUSH EBP
MOV  EBP, ESP
CALL +5
POP  EAX
ADD  EAX, [0xSOMEVALUE, EDX, 1]
JMP  EAX

En este ejemplo, el compilador ha decidido que era mejor (por algún motivo) coger la dirección relativa de la tabla de funciones obteniendo la dirección de la función actual (de ahí el CALL +5 & POP EAX, para saber la dirección de la instrucción que se está ejecutando en ese momento).

Otra cosa que estoy obviando: estoy suponiendo que la tabla donde se apunta a las direcciones de los bloques básicos son contiguas y que son completas, osea, que estará del modo siguiente:

.rodata:08048670 off_8048670     dd offset loc_8048436   ; DATA XREF: main+1Ar
.rodata:08048670                 dd offset loc_8048447   ; jump table for switch statement
.rodata:08048670                 dd offset loc_8048458
.rodata:08048670                 dd offset loc_804846B
.rodata:08048670                 dd offset loc_804846B
.rodata:08048670                 dd offset loc_80484AD
.rodata:08048670                 dd offset loc_80484BB
.rodata:08048670                 dd offset loc_80484C9

Sin embargo, esto no tiene porque ser así. En muchos casos me he encontrado que lo que realmente hay son direcciones relativas. Es decir, en vez de la dirección virtual completa nos encontramos simplemente el último WORD de la dirección. Por ejemplo, en vez de encontrarte una dirección como 0x08048436, te encontrarías con la dirección 0x8436, a la cual luego se le sumaría la base 0x08040000. O, aún peor, que lo que hay son las diferencias entre direcciones, es decir, el resultado de restar la dirección anterior de la actual. Y así podría seguir hasta aburriros y no acabaría en la puta vida.

Bueno, creo que el post ya es bastante largo así que lo dejo aquí. Espero que os haya gustado!

 

5 Comentarios para “Problemas del análisis de código”

  1. :D
    Comment por :D | 01/19/12 at 1:49 pm
  2. Comment por txalin | 01/19/12 at 2:47 pm

    Mas o menos lo he entendido, y eso tiene mérito ya que yo de desensamblar entiendo lo mismo que de realizar la matanza del cerdo. Me quedo esperando a la segunda parte y de paso pregunto, si me quiero meter en esto de desensamblar…. ¿ por donde deberia empezar (libros, documentacion y tal) ?

  3. Comment por Bruno | 01/19/12 at 8:06 pm

    Muy muy interesante! Felicidades, seguiré esperando la continuación!

  4. Comment por matalaz | 01/19/12 at 11:23 pm

    @txalin: Si quieres libros, te recomiendo este: http://www.amazon.com/Reversing-Secrets-Engineering-Eldad-Eilam/dp/0764574817

    @Bruno: Muchas gracias!

  5. Comment por Iván Portilla | 07/29/13 at 5:14 am

    Realmente es excelente la entrada, es difícil que la gente vaya y sea lea algo completo pero es tan interesante que no hay forma de no hacerlo.

    Y has tomado en cuenta bastantes cosas, esperando la siguiente parte.

    Saludos.

Se han cerrado los comentarios