Random IRC quote :      <@matalaz> toma mi cuerpo, pero no mi nombre

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:

  1. Prólogos y epílogos de función.
  2. Cómo se crean variables locales, de heap y globales (cómo se reserva memoria para las variables locales, etc…).
  3. 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…).
  4. Cómo se construyen las sentencias condicionales (IF && ELSE).
  5. Cómo se construyen los switches (al menos los más típicos).
  6. 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:

void foo(char *arg)
{
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:

               .globl foo
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

               move    $sp, $fp
                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:

void foo(int *arg1)
{
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:

void foo(char *arg1)
{
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:

                .globl foo
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:

void foo(char *arg_0)
{
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:

int foo(char *arg, int size)
{
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:

               .globl foo
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:

void foo(char *arg0, int arg4)
{
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:

int foo(char *arg0, int arg4)
{
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!

7 Comentarios para “REconstruyendo código C en plataformas no x86 (MIPS, Parte I)”

  1. Comment por vierito5 | 02/25/10 at 5:21 pm

    Muy bien explicado!

  2. Comment por Ruben | 02/25/10 at 7:28 pm

    En el caso de no tener a priori una definición de la función que asigna $v0 (en este caso strlen que sabemos que devuelve un int) qué maneras habría para identificar el tipo? heurística? uno genérico y pun?

    Mola mucho el tema, enhorabuena matalaz 🙂

  3. Comment por matalaz | 02/26/10 at 2:15 am

    @Ruben: Si sabes que retorna algo pero no conoces su tipo, haz lo mismo que con las variables, tipo int.

  4. Comment por Dreg | 02/28/10 at 6:09 pm

    de puta madre 🙂

  5. Comment por Knyghte | 03/03/10 at 3:17 pm

    Me gustó el detalle en «REconstruyendo», si es que fué adrede. 🙂

  6. Comment por matalaz | 03/03/10 at 6:56 pm

    @Knyghte Sí, estaba hecho adrede 😉

  7. Comment por amdres | 05/14/10 at 4:31 pm

    hola,espero me ayuden, quisiera saber como paso de estos codigos ensamblador a lenguaje makina para pasarlo al simulador von neuwman. a MIPS ..estoy buscando recien es mi 3era sesion de claces, porfavor ayudenme.
    cmp cx,ax ; if (cx > ax)
    jle fin_si
    dec cx ; then dec cx
    fin_si:

    ————————————
    cmp bx,ax ; If (bx 100)
    jle Repite1
    ——————————————
    while1: cmp bx,200 ; While (bx<200)
    jge fin_while1
    inc bx
    add ax,cx
    jmp while1 ; end_while
    fin_while1:
    —————————-
    Si (CX ¹ 0)
    Dec CX
    Salta a objetivo
    Sino
    Siguiente instrucción

    ————————–
    mov cx,20
    do1:
    add ax,bx
    inc bx
    loop do1

Se han cerrado los comentarios