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:
- 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.
- 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.
- 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
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).
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:
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:
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:
{
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(&func, argc);
}
Si compilamos este código (con GCC en mi caso) se generará el siguiente código ensamblador:
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:
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:
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Ã:
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:
Si miramos el bloque básico que tiene varias salidas nos encontraremos con el siguiente código ensamblador:
.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):
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 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!