Buffer Overflows y protección del kernel: práctica (I)

Tras el pequeño rollo teórico que vimos ayer, hoy toca entrar de lleno en las cuestiones prácticas, que espero que aclaren todas las dudas que ayer pudieran surgir. Veamos el siguiente trozo de código en C de 4 líneas. La primera instrucción asigna el valor 9 a la variable X, la segunda llama a una función, la tercera le asigna a X el valor 1 y la siguiente línea mostrará el valor de X:

x = 9;
funct(5,6,7);
x = 1;
printf("El valor de X es: %d\n", x);

La función lo que va a hacer es obtener la dirección donde se guarda el EIP (dirección de la siguiente
línea a ejecutar), de tal forma que modificaremos su contenido para que en vez de saltar a la
siguiente línea que sería “x = 1″, salte al “printf» mostrando que x es igual a 9, y no 1 que sería lo esperado.

Antes de mostrar el código completo recordar un par de cosas:

  • En relación con los punteros, recordar que si yo declaro un puntero “int *p;», para modificar la dirección donde apunta debo hacer “p = dirección;», y a su vez, para modificar el valor (el contenido) del dato donde apunta el puntero se realiza “*p = dato;».
  • La segunda es que la memoria se numera por bytes (8 bits) y está agrupada por palabras, siendo una palabra 32 bits es decir, 4 bytes. Por tanto, un registro que guarde una dirección de memoria ocupa una palabra, es decir 4 bytes (32 bits). Por último, recordar que un char ocupa 1 byte, y en una palabra caben 4 chars.

Con esto, pasemos al código:

#include <stdio.h>
void funct(int a, int b, int c){
     int i, j;
     char buffer[4];
     char *ret;
     buffer[0] = 'A';
     buffer[1] = 'B';
     buffer[2] = 'C';
     buffer[3] = 'D';
     for(i = 0; i < (9*4); i += 4){
          ret = buffer + i;
          if( i == 0){
               printf("Vector:\n");
               for( j=0; j < 4; j++){
                    printf("Valor: 0x%X PosMem: 0x%X\n", *ret, ret);
                    ret += 1;
               }
               printf("-----------\n");
          }
          else{
               printf("Valor: 0x%X PosMem: 0x%X\n", *ret, ret);
          }
     }
     // Aquí es donde esta la fiesta
     // Retrocedemos hasta donde esta el EIP

     ret -= 12;

     // Modificamos la dirección de vuelta y le sumamos 7 
     // que es la siguiente instrucción

     *ret += 7;
     printf("Premio: Valor: 0x%X PosMem: 0x%X\n", *ret, ret);
}

int main(void){
     int x;
     x = 9;
     funct(4,5,6);
     x = 1;
     printf("El valor de X es: %d\n", x);
     return 0;
}

Recapitulemos. Como vemos, asignamos 9 a la variable X. La siguiente instrucción llama a la función pasándole los números 4, 5 y 6 como parámetros. Por tanto, apilará el valor 6, 5 y 4. A continuación apila el valor del EIP que es el valor de la siguiente instrucción a ejecutar, que en nuestro caso es la instrucción «x = 1″. Encima del EIP apilara el valor del EBP de la pila actual, y sobre éste las variables locales de la función.

Dentro de la función apilamos variables locales para los bucles así como un vector de 4 caracteres “ABCD» o lo que es lo mismo en hexadecimal “0x41 0x42 0x43 0x44″, el cual ocupa una palabra (4 x char (1 byte) = 4 bytes = 32 bits).

Una vez realizadas estas operaciones, se muestra el contenido de la pila de la función seguido de la posición de memoria donde se guardan el dato, que corresponde con lo explicado en teoría. Ya por último se mueve el puntero «ret» a la posición donde se guarda el EIP (próxima linea a ejecutar) y se modifica con un valor de tal forma que cambia la posición de la siguiente instrucción a ejecutar, que seria «x = 1», por la instrucción «printf». Compilamos y ejecutamos el programa:

user1@base:~/Desktop> gcc -ggdb --no-stack-protector prueba.c -o prueba
user1@base:~/Desktop> ./prueba
Vector:
Valor: 0x41 PosMem: 0xBF869098
Valor: 0x42 PosMem: 0xBF869099
Valor: 0x43 PosMem: 0xBF86909A
Valor: 0x44 PosMem: 0xBF86909B
--------
Valor: 0x4 PosMem: 0xBF86909C
Valor: 0x4 PosMem: 0xBF8690A0
Valor: 0xFFFFFFA4 - PosMem: 0xBF8690A4
Valor: 0xFFFFFFD8 - PosMem: 0xBF8690A8
Valor: 0x22 - PosMem: 0xBF8690AC
Valor: 0x4 PosMem: 0xBF8690B0
Valor: 0x5 PosMem: 0xBF8690B4
Valor: 0x6 PosMem: 0xBF8690B8
Premio: Valor: 0x29 PosMem: 0xBF8690AC
El valor de X es: 9

Analicemos el resultado de la ejecución. Comienza mostrando la cima de la pila que es el vector de caracteres (char) de nombre «buffer», donde podemos ver que las posiciones son consecutivas (claro, un char ocupa un byte). Posteriormente muestra los datos de 4 en 4 bytes (una palabra), puesto que lo que nos interesa son los registros que guardan direcciones de memoria que ocupan 32 bits (4 bytes, una palabra). Se muestra un
registro sin importancia (0xBF8690A4) que se emplea en la ejecución de los bucles de la función, y luego hay dos registros en la posición 0xBF8690A8 y 0xBF8690AC (nos interesa el último). A continuación vienen 3 registros que son el valor 4, 5 y 6 que son los valores que hemos pasado a la función en su llamada.

Como podemos imaginar, el dato que hay en la posición de memoria 0xBF8690AC cuyo valor es 0x22 corresponde con el EIP (dirección de la siguiente instrucción a ejecutar una vez termine la función). Por tanto lo que hacemos es ir a dicha posición de memoria, y modificarla incrementando su valor en 7 para que salte a la posición de memoria donde nosotros queremos: la instrucción «printf». ¿Pero, cómo sabía que tenia que sumarle 7? Veamos:

user1@base:~/Desktop> gdb -q prueba
Using host libthread_db library "/lib/libthread_db.so.1".
(gdb) disassemble main
0x080484ee <main+0>: lea 0x4(%esp),%ecx
...
0x080484ff <main+17>: movl $0x9,0x8(%ebp)
...
0x0804851d <main+47>: call 0x8048404 <funct>
0x08048522 <main+52>: movl $0x1,-0x8(%ebp)
0x08048529 <main+59>: mov -0x8(%ebp),%eax
...

¡No salgais corriendo! Fijaos bien… hay una llamada (main+17) que mueve al registro el valor 9 ($0x9), posteriormente llama a la función (main+47) y la siguiente línea de código es la misma que la de (main+17), pero esta vez le asigna el valor 1 ($0x1). Por tanto, claramente (main+17) es x = 9 y (main+52) es x = 1.

Por lo que si la instrucción «x = 1» se encuentra en la posición de memoria 0x08048522, la siguiente línea de ejecución 0x08048529 será el printf. Si restamos las posiciones de memoria obtenemos el 7 empleado en el programa. Pero es más, fijaros bien, ¿en qué terminan las posiciones de memoria de estas dos líneas de código? En 22 y en 29 ¿Os suenan? Fijaos lo que mostraba el programa en su ejecución:

...
Valor: 0x22 PosMem: 0xBF8690AC
...
Premio: Valor: 0x29 PosMem: 0xBF8690AC

En la próxima entrada, una vez vistos los detalles técnicos y teóricos de los buffer overflow, pasaremos a comentar brevemente una de las principales protecciones del kernel de Linux frente a este tipo de ataques. Hasta entonces, buen fin de semana a todos.

Comments

  1. No termino de pillarle aun a esto de los overflows, pero con esta explicación por lo menos ya he conseguido hacerme una idea de por donde van los tiros.
    Gracias por los articulos, y me quedo a la espera de la proxima entrada
    un saludo

  2. Vale, hemos visto q X=9 pero… en un ejemplo real q hacen xa conseguir una consola de administracion??

  3. María, dado el carácter eminentemente didáctico de este blog, comprenderás que todos aquellos ejercicios orientados más al aprovechamiento de las vulnerabilidades que a su comprensión, se dejan como ejercicio para el lector.

  4. Hola Maria, como bien comenta mi compañero Manuel Benet hay una limitación más que lógica en este articulo.

    Pero te aclare la duda de forma teórica y simplista. El buffer overflow trata de modificar el retorno de la pila como hemos visto… por ejemplo al no comprobar el tamaño del dato que le paso. Imagínese que en el ejemplo el vector buffer donde se le reserva cuatro caracteres de espacio(32bits) en vez de pasarlo de forma estática se lo pasara en la llamada a la función.

    Si el programa tuviera un fallo y no comprueba el tamaño del dato que le pasamos, imagínese que yo le paso esos cuatro caracteres y además tantos como me permitan sobreescribir la pila hasta llegar al EPI y como últimos cuatro caracteres le pusiera el valor de retorno que yo quiera… podríamos saltar donde nosotros quisiéramos. ¿Verdad?.

    Ahora veamos, imagínese que el programa fuera un servicio y yo le paso una ristra de números enormes en el vector y éste sobreescribiera toda la pila y la zona de memoria que hay después de la pila. ¿Que ocurre? pues que el sistema operativo matara (detendrá) el proceso por intentar acceder a una posición de memoria que no tiene permisos, ni para leer ni escribir, ya que no es un espacio reservado al proceso. ¿Que hemos conseguido? una denegación del servicio ya que obligamos al sistema operativo a detener el proceso.

    Otra opción, si yo modifico la posición de vuelta de la función y siguiendo el ejemplo del articulo, si en vez de un x = 1 fuera un if( password != «micontraseñasupersecreta» ) { exit 1; } estaría consiguiendo saltarme la comprobación de seguridad y aunque no tuviera la contraseña podría estar ejecutando algo como si fuera el usuario legítimo.

    Y por último y lo que usted pregunta, si yo en el «chorizo» que yo introduzco para el desbordamiento tengo en hexadecimal las ordenes de ensamblador necesarias que permitan la ejecución de una shell (véase una orden execve de un /bin/sh desensamblado tal y como se vio en el ejemplo del artículo con nuestro programa) y hago que salte en la vuelta de la función a la posición de memoria donde comienza dicha shell, ¿que es lo que me ejecuta?¿Y con que permisos? :).

    Nos leemos

  5. Muchas Gracias Gosu – Ximo, ahora ya veo realmente la potencia del ataque.