REconstruyendo código C en plataformas no x86 (MIPS, Parte I)
Uno de los trabajos, en mi opinión, más coñazos es la recuperación/reconstrucción de código C a partir de un binario dado. Para x86 se tiene una muy buena opción como es Hex-Rays, pero no todo es x86, en muchos casos puede que querramos reconstruir un código C creado para una procesador MIPS para entender mejor un algoritmo, copiarlo, extenderlo, etc… En esos casos en los que no tenemos un decompilador maravilloso que dando F5 «nos lo hace todo» auto-mágicamente no nos queda más remedio que hacerlo a manopla y el siguiente post se va a referir a estos casos. Yo me voy a basar en MIPS pero es perfectamente aplicable a cualquier otra plataforma de procesador, como por ejemplo ARM.
Introducción
Y ahora vamos al lío. Cuando voy a reconstruir código de un procesador nuevo estos son los pasos que yo sigo:
- Obtener los manuales oficiales del fabricante del procesador para aprender como funciona la arquitectura.
- Crearme u obtener un compilador cruzado (GCC) para dicha arquitectura.
- Crear códigos en C muy básicos para empezar a averiguar como se genera el código ensamblador, es decir, las construcciones más habituales.
El tema de crear un compilador cruzado no es totalmente necesario pero si que ayuda mucho, porque de este modo sabemos exactamente como debe de quedar el resultado final y aprender a ajustarlo/corregirlo, especialmente si tenemos acceso al mismo compilador que se ha utilizado para compilar el binario a recuperar 😉 Sea como sea, lo que debemos de identificar y comprender como funciona son los siguientes aspectos y, en mi opinión, por este orden:
- Prólogos y epílogos de función.
- Cómo se crean variables locales, de heap y globales (cómo se reserva memoria para las variables locales, etc…).
- Cómo se llama a funciones y como se pasan los argumentos a dichas funciones (si existen diferentes convenciones de llamadas, como funciona cada una de ellas, como y cuando se arregla la pila en caso de ser utilizada, etc…).
- Cómo se construyen las sentencias condicionales (IF && ELSE).
- Cómo se construyen los switches (al menos los más típicos).
- Cómo se construyen los bucles (FOR y WHILE).
Muy breve introducción a la arquitectura MIPS
Los procesadores MIPS son RISC (Reduced Instruction Set Computer) y existen versiones de 32 y 64 bits. Los registros que se utilizan son los siguientes (extraído del artículo en wikipedia):
Name | Number | Use | Callee must preserve? |
---|---|---|---|
$zero | $0 | constant 0 | N/A |
$at | $1 | assembler temporary | No |
$v0–$v1 | $2–$3 | values for function returns and expression evaluation | No |
$a0–$a3 | $4–$7 | function arguments | No |
$t0–$t7 | $8–$15 | temporaries | No |
$s0–$s7 | $16–$23 | saved temporaries | Yes |
$t8–$t9 | $24–$25 | temporaries | No |
$k0–$k1 | $26–$27 | reserved for OS kernel | No |
$gp | $28 | global pointer | Yes |
$sp | $29 | stack pointer | Yes |
$fp | $30 | frame pointer | Yes |
$ra | $31 | return address | N/A |
De esta tabla podemos aprender que los argumentos a funciones se suelen pasar por registro ($a0-$a3), que el stack pointer se representa por $sp, el frame pointer como $fp y la dirección de retorno como $ra. Además sabemos que registros del procesador son preservados cuando se llama a una función. Veamos ahora ya un código C básico y como queda su correspondiente código ensamblador:
{
char buf[20];
strcpy(buf, arg);
}
int main(int argc, char **argv)
{
if (argc > 1) foo(argv[1]);
}
Y el siguiente es el código ensamblador generado por un compilador cruzado para MIPS Big Endian de 32 bits:
foo: # CODE XREF: main+44^Yp
var_20 = -0x20
var_8 = -8
var_4 = -4
arg_0 = 0
addiu $sp, -0x30
; Reservamos 0x30 bytes para variables locales (stack)
sw $ra, 0x30+var_4($sp)
; Guardamos la dirección de retorno en -4($sp)
sw $fp, 0x30+var_8($sp)
; Guardamos el frame pointer en -8($sp)
move $fp, $sp
; $sp = $fp
sw $a0, 0x30+arg_0($fp)
; $a0 = arg0; // Primer argumento a función
addiu $v0, $fp, 0x30+var_20
; Cargamos una variable estática local (addiu -> variables locales de stack)
move $a0, $v0
; arg0 = $v0; // El primer argumento a función ahora será la variable local
lw $a1, 0x30+arg_0($fp)
; arg1 = func_arg1; // El segundo argumento a función es el argumento recibido a función
jal strcpy
; strcpy(arg0, arg1);
nop
lw $ra, 0x30+var_4($sp)
; Restauramos la dirección de retorno
lw $fp, 0x30+var_8($sp)
; Restauramos el frame pointer
addiu $sp, 0x30
; Arreglamos el stack
jr $ra
; Y saltamos a la dirección de retorno
nop
# End of function foo
Con este código tan básico ya hemos aprendido lo siguiente:
- Cómo se pasan los argumentos a una función (registros $a0, $a1, etc…).
- Cómo se reserva espacio para variables locales (addiu $sp, -0x30) y cuanto espacio se reserva para variables locales, por lo cual, podemos saber el tamaño de las variables locales. Excluyendo el espacio utilizado para guardar la dirección de retorno ($ra) y el framepointer ($fp).
El caso es que lo que se necesita identificar siempre, indiferentemente del procesador, es lo siguiente:
- Prólogo de la función para saber cuanto espacio se va a reservar para variables locales. También para saber que código hay que ignorar (el que no nos vale para reconstruir el código C).
- Epílogo de la función (para saber hasta donde hay que decompilar, osea, que otra parte hay que ignorar).
- Cómo se pasan los argumentos a función, como se llaman a las funciones y como se devuelve el valor retornado por la función llamada.
Empecemos a generar código C para esta función. Lo primero, sabemos que los argumentos se reciben y se pasan vía $an. También sabemos que para llamar a una función se utiliza la instrución JAL (Jump And Link) y que las variables de stack se reservan con ADDIU (Add Immediate Unsigned). El tamaño reservado para la única variable local es de 0x20 tal y como nos lo muestra el IDA. De todos modos, en MIPS, se alinea todo a 8 bytes y, si lo necesitásemos, podemos sacar el espacio real reservado para variables restando 16 (decimal) al tamaño total que se reserva (0x30-0x10 = 0x20). Bueno, ya sabemos todo lo que necesitamos para descompilar a manopla esta función tan simple, que queda como sigue:
{
int var_20[0x20];
strcpy(var_20, arg1);
}
Este código que hemos creado, aunque perfectamente válido, no es el mismo código que ha escrito el programador ya que, por ejemplo, los tipos no coinciden. Viendo la función a la que se llama podemos sacar los tipos así que, corregimos la función:
{
char var_20[0x20];
strcpy(var_20, arg1);
}
Ahora es un poco más acertado el código 😉 De hecho, quitando los nombres de variables, el código es exactamente el mismo excepto por otro leve detalle: El tamaño de la variable de stack no coincide. El motivo es que todo se alinea a 8 bytes y 20 (decimal) no es un tamaño divisible por 8 por lo que el compilador lo alinea, quedando el tamaño de la variable a otro diferente del que ha puesto el programador.
Vamos al siguiente ejemplo. En el anterior caso habíamos visto como se reservaba espacio para una variable de stack con un programita de juguete que tenía un overflow como una casa. Ahora vamos a ver ese mismo programa calcado pero con un puntero:
foo: # CODE XREF: main+44p
var_10 = -0x10
var_8 = -8
var_4 = -4
arg_0 = 0
addiu $sp, -0x20
; Reservamos 0x20 bytes en la pila
sw $ra, 0x20+var_4($sp)
sw $fp, 0x20+var_8($sp)
move $fp, $sp
; Hasta aquí el prólogo, ya lo tenemos cazado 😉
sw $a0, 0x20+arg_0($fp)
lw $a0, 0x20+var_10($fp)
; Argumento 0 para llamada a función
lw $a1, 0x20+arg_0($fp)
; Argumento 1 para llamada a función
jal strcpy
; Llamamos a strcpy(var_10, arg_0);
nop
; Desde aquí el epílogo, siguiente parte a ignorar por lo general
move $sp, $fp
lw $ra, 0x20+var_4($sp)
lw $fp, 0x20+var_8($sp)
addiu $sp, 0x20
jr $ra
nop
# End of function foo
Para reconstruir código, por lo general, tanto el prólogo como el epílogo nos dan un poco igual (nos interesa para saber cuanto espacio se reserva para variables locales y poco más), así que, ignoramos el prólogo y empezamos donde está el código que escribió el programador. En este caso, a diferencia del anterior, se carga la dirección de la variable «var_10» en el registro «$a0» (primer argumento a función) en vez de utilizar la instrucción ADDI («add immediate») como en el caso anterior, la instrucción LW es la utilizada para esta acción, lo cual nos indica que es un puntero. Después de esto, se establece arg_0 como el 2º argumento a función y se hace un «JUMP AND LINK» a la función strcpy. Con esto ya podemos reconstruir un poquito de código:
{
char *var_10;
strcpy(var_10, arg_0);
}
Ala! Así de difícil ha sido esta vez. De momento ya sabemos identificar prólogos y epílogos, como se pasan argumentos a función así como identificar variables de stack y punteros. Lo que todavía no tenemos claro es como se retornan valores y demás. Vamos a ello:
{
char buf[20];
strncpy(buf, arg, size);
return strlen(buf);
}
En esta función en C, copiamos un buffer con un tamaño máximo y devolvemos el tamaño de la cadena. Su código ensamblador MIPS sería el siguiente:
foo: # CODE XREF: main+68p
var_20 = -0x20
var_8 = -8
var_4 = -4
arg_0 = 0
arg_4 = 4
addiu $sp, -0x30
; Reserva de 0x30 Bytes en la pila
sw $ra, 0x30+var_4($sp)
sw $fp, 0x30+var_8($sp)
move $fp, $sp
; Fin del prólogo
sw $a0, 0x30+arg_0($fp)
sw $a1, 0x30+arg_4($fp)
lw $v1, 0x30+arg_4($fp)
; $v1 -> argumento a función 4
addiu $v0, $fp, 0x30+var_20
; $v0 -> variable de stack var_20
move $a0, $v0
; Ponemos var_20 como primer argumento a función
lw $a1, 0x30+arg_0($fp)
; El segundo argumento a función es un puntero arg_0
move $a2, $v1
; $v1 se pone como tercer argumento a función
jal strncpy
; Llamamos a strncpy(var_20, arg_0, arg_4);
nop
addiu $v0, $fp, 0x30+var_20
; $v0 = var_20
move $a0, $v0
; Ponemos como primer argumento a función var_20
jal strlen
; Llamamos a strlen(var_20);
nop
; Desde aquí el epílogo, podemos ignorarlo
move $sp, $fp
lw $ra, 0x30+var_4($sp)
lw $fp, 0x30+var_8($sp)
addiu $sp, 0x30
jr $ra
nop
# End of function foo
Ahora que vamos viendo como funciona esto podemos ver que, aunque hay más código, la diferencia no es para echar cohetes: Es más de lo mismo. Se reservan 0x20 bytes de espacio para una variable de stack, se llama a la función strncpy pasándole 3 argumentos que son, respectivamente, la variable local, el primer argumento a función y el segundo argumento a función. Después, se llama a otra función (strlen) pasándole la variable de stack que habíamos creado antes. Después, simplemente restaura la pila y se pira de la función. Empecemos a crear pseudo-código aplicando ya los tipos que las funciones utilizadas nos indican que tienen que tener las variables y argumentos:
{
char var20[0x20];
strncpy(var20, arg0, arg4);
strlen(var20);
}
Este código es «casi» el que escribió el programador. ¿Qué es lo que falta aquí? ¿Qué es lo que no cuadra? Que se está llamando a una función (strlen) y no se hace nada con su valor de retorno. ¿O si se hace? Si volvemos a la tabla de registros que he puesto en la introducción del artículo vemos que el valor de retorno es guardado en $v0 así que, en $v0, es donde se deja el valor de retorno de una función. Pero, ¿Y dónde se está poniendo ese valor de retorno en nuestra función? Respuesta: En ninguna parte, es en la función strlen donde se está poniendo (de hecho, en los ejemplos anteriores, el valor de retorno es el de la última a llamada a una función). Sabido esto, ya podemos cambiar nuestro código C:
{
char var20[0x20];
strncpy(var20, arg0, arg4);
return(strlen(var20));
}
Este código si que es «casi» exactemente el que escribió el programador, exceptuando temas cosméticos como indentaciones y nombres de variables, claro está.
Bueno, pues hasta aquí la parte I que ya se hace demasiado largo el post. En el siguiente, sentencias condicionales, bucles y switches. Espero que os haya gustado!