Ayer por la noche estaba intentando depurar un demonio en red bastante viejo que teníamos corriendo en un servidor. Llevaba un par de horas perdido por un cierre inesperado cada vez que le enviaba una cadena de texto un poco más larga de lo normal por el socket. El clásico Segmentation Fault. Y ahí estaba yo, mirando un archivo core dump enorme con mi fiel GDB. Fue entonces cuando, viendo los registros desparramados por la pantalla, recordé la belleza caótica de los desbordamientos de búfer. No es solo un fallo tonto del programador; es una ventana abierta de par en par a las entrañas mismas del procesador.

¿Qué es exactamente un buffer overflow? Hablamos mucho de esto, especialmente cuando saltan las alarmas en listas de correo como Bugtraq o Full Disclosure, pero a veces perdemos la perspectiva de lo ridículamente mecánicos que son estos fallos a bajo nivel. Básicamente, ocurre cuando metemos más datos en un bloque de memoria (el búfer) del que puede soportar físicamente. Como en C no hay comprobación automática de límites, el programa simplemente sigue escribiendo en las direcciones de memoria adyacentes, aplastando todo lo que pilla por medio.

Si recuerdas lo que comenté en el artículo sobre la pila de llamadas x86 en ensamblador, sabrás que las variables locales de una función viven juntas en la pila, justo al lado de cosas vitales para el sistema como la dirección de retorno (el EIP).

Imagina un código en C tan inocente (y tan peligroso) como este pequeño ejemplo práctico:

#include <stdio.h>
#include <string.h>

void funcion_vulnerable(char *entrada) {
    char buffer[64];
    /* ¡Peligro! strcpy no comprueba la longitud de 'entrada' */
    strcpy(buffer, entrada);
    printf("Procesado correctamente: %s\n", buffer);
}

int main(int argc, char *argv[]) {
    if (argc > 1) {
        funcion_vulnerable(argv[1]);
    }
    return 0;
}

Si le pasamos a este programa una cadena de 40 o 50 caracteres desde la línea de comandos, la función strcpy no se va a quejar. Todo funcionará rápido y sin problemas. Empezará a copiar nuestra cadena en buffer[64]. Pero cuando llegue al límite y nos pasemos, seguirá escribiendo ciegamente sobre lo que haya detrás. Y lo que hay justo detrás, en el marco de la pila, es el puntero base (EBP) guardado y, a continuación, la sacrosanta dirección de retorno de la función.

Escribiendo nuestro exploit casero

Si calculamos exactamente la distancia entre el inicio de nuestro búfer y la posición en memoria de esa dirección de retorno, podemos poner ahí un valor hexadecimal cuidadosamente elegido. ¿Qué valor? La dirección en memoria donde hemos inyectado nuestro propio código (nuestra shellcode).

Para verlo en acción con nuestro viejo amigo el compilador gcc (y asumiendo que no tienes activado ningún mecanismo esotérico de protección de memoria de esos que algunos parches experimentales del kernel están empezando a traer):

# Compilamos el código vulnerable
gcc vulnerable.c -o vulnerable

# Si le pasamos 64 caracteres, todo va bien
./vulnerable $(perl -e 'print "A"x64')

# Si nos pasamos de la raya y tocamos la dirección de retorno...
./vulnerable $(perl -e 'print "A"x80')
Violación de segmento (core dumped)

En este punto exacto, si enganchamos el ejecutable a un depurador, veremos que el registro EIP (Instruction Pointer) ha intentado saltar a la dirección 0x41414141 (que es el valor en hexadecimal de cuatro letras 'A'). Acabamos de secuestrar el flujo de ejecución del programa. El procesador está bajo nuestras órdenes.

Cambiar esas últimas 'A' por la dirección de memoria de nuestra shellcode, envuelta en una buena cama de NOPs (instrucciones que no hacen absolutamente nada, código 0x90, para tener un buen margen de error al saltar), es el siguiente paso lógico de cualquier hacker. Es fascinante ver cómo un pequeño descuido humano en el manejo de variables, puede derivar en que alguien gane una shell de superusuario remoto en nuestra máquina.

Perspectiva de futuro

A estas alturas de 2006, programar en C para servicios de red expuestos a internet es casi un deporte de riesgo. Dependemos completamente de la disciplina férrea del desarrollador para usar versiones más seguras de las funciones, como strncpy o snprintf, y comprobar los límites manualmente.

Creo sinceramente que la industria informática no puede seguir soportando este ritmo de vulnerabilidades críticas. Tarde o temprano, los propios sistemas operativos y los compiladores van a tener que asumir esta carga técnica. Ya se oye hablar de algunas protecciones interesantes que aleatorizan el espacio de memoria o que marcan la pila como no ejecutable a nivel de hardware, pero aún queda muchísimo para que eso sea un estándar real. Hasta que eso ocurra en las distros comerciales, o hasta que adoptemos masivamente otro tipo de lenguajes que manejen la memoria por nosotros de forma nativa, el buffer overflow seguirá siendo el rey indiscutible de los exploits. Al final, entenderlo a fondo te convierte en un programador mucho más consciente, riguroso y respetuoso con la memoria.