wandres.dev
CENTINELA · Seguridad y hardening

Anatomía de un desbordamiento de pila

El exploit más antiguo del manual, desmontado hasta el hueso: por qué un búfer local y la dirección de retorno comparten el mismo marco, cómo un solo `strcpy` sin límite pisa esa dirección, y por qué controlar `rip` es controlar el programa. Solo se defiende bien quien entiende el ataque.

⏱ 16 min

El desbordamiento de pila es el exploit fundacional: el que enseñó al mundo que un bug de memoria no es un cuelgue, es una puerta. Su mecánica cabe en un párrafo y sin embargo sostiene tres décadas de carrera armamentística entre atacantes y compiladores. Este es el ataque clásico contado sin folklore, paso a paso, porque cada defensa que verás en las próximas lecciones ataca un eslabón concreto de esta cadena, y no puedes apreciar una barrera si no sabes qué fluido intenta contener.

🎯 Al terminar esta lección sabrás
  • Entender por qué un búfer local y la dirección de retorno viven en el mismo marco.
  • Reconstruir paso a paso cómo un strcpy sin límite pisa la dirección de retorno.
  • Distinguir entre corromper la pila y tomar el control del flujo de ejecución.
  • Ver por qué cada mitigación del nivel ataca un punto distinto de esta secuencia.

Un búfer y un retorno en la misma pila

Recuerda el marco de pila: cuando una función se ejecuta, el prólogo reserva espacio para sus locales bajando el puntero de pila, y por encima de ese espacio, ya apilada por la instrucción call, está la dirección de retorno: el lugar exacto al que ret saltará cuando la función termine. El problema es puramente topológico. Un array declarado como local vive en ese mismo marco, a pocos bytes de la dirección de retorno, y la escritura en el array crece hacia ella.

flowchart TD
A[Direcciones bajas: char buf de 16 bytes] --> B[Saved rbp del llamador]
B --> C[Direccion de retorno apilada por call]
C --> D[Direcciones altas: marco del llamador]
E[strcpy escribe de bajas a altas] --> A
style A fill:#89b4fa,color:#11111b
style C fill:#f38ba8,color:#11111b

La disciplina de la pila crece hacia direcciones bajas, pero una copia de bytes escribe hacia direcciones altas: empieza en el primer byte del búfer y avanza. Si copias más bytes de los que caben, no te detienes en el borde del array: sigues escribiendo sobre el rbp guardado y, un poco más allá, sobre la dirección de retorno. Nadie comprueba nada. En C, un array no conoce su tamaño y strcpy copia hasta encontrar un NUL, venga de donde venga ese dato.

#include <string.h>

void vulnerable(const char *entrada) {
    char buf[16];
    strcpy(buf, entrada);   // sin limite: copia hasta el NUL de entrada
    /* usar buf... */
}

Si entrada mide 15 caracteres o menos, todo funciona. Si mide 40, los bytes 17 en adelante caen fuera de buf y sobre el resto del marco. El programa no da ningún aviso en ejecución: el hardware permite la escritura porque toda esa memoria es pila válida y escribible. El bug es invisible hasta el instante del ret.

El momento exacto de la sobrescritura

Sigamos el rastro de una entrada de 40 bytes. Los primeros 16 llenan buf. Los siguientes 8 pisan el rbp guardado. Los 8 posteriores caen exactamente sobre la dirección de retorno. A partir de ahí, la pila contiene lo que el atacante escribió, no lo que el llamador dejó.

// gcc -std=c23 -O0 -fno-stack-protector -z execstack vuln.c
#include <stdio.h>
#include <string.h>

void saludar(void) {
    char nombre[16];
    gets(nombre);                  // sin limite alguno: el pecado original
    printf("Hola, %s\n", nombre);
}                                  // aqui ret salta a lo que haya en la pila

Mientras saludar ejecuta, la dirección de retorno apunta a la instrucción siguiente a la llamada en el llamador. Pero gets no tiene ni la noción de un límite: leerá una línea entera desde la entrada estándar, tenga la longitud que tenga. Cuando el epílogo ejecuta ret, la CPU desapila los ocho bytes que ahora ocupan esa ranura y los carga en rip. Si esos ocho bytes son basura, el programa salta a una dirección inválida y muere con SIGSEGV. Pero si el atacante los eligió con cuidado, rip salta a donde el atacante quiera.

Conviene ver la aritmética con los números delante, porque es toda la matemática que hay en el ataque. Con un búfer de 16 bytes y el rbp guardado justo encima, el mapa de una entrada de 40 caracteres queda así:

bytes  0 .. 15   ->  llenan nombre[16]
bytes 16 .. 23   ->  pisan el rbp guardado del llamador
bytes 24 .. 31   ->  pisan la direccion de retorno  <-- el objetivo
bytes 32 .. 39   ->  siguen sobre el marco del llamador

El único número que hay que descubrir es 24: la distancia desde el inicio del búfer hasta la ranura de retorno. El compilador, sin defensas, coloca las locales y el rbp guardado en posiciones fijas y contiguas, de modo que ese offset es constante entre ejecuciones y trivial de medir con un depurador. Que un array de usuario y la dirección de retorno terminen a solo 24 bytes de distancia no es un descuido de nadie: es la disposición natural de un marco que no lleva ninguna barrera entre sus inquilinos.

Ese es el corazón del ataque, y merece decirse con precisión: el desbordamiento no ejecuta código por sí mismo. Lo único que hace es escribir un valor concreto en la ranura de la dirección de retorno. Toda la potencia del exploit se libera un instante después, cuando ret confía ciegamente en ese valor y lo trata como un destino legítimo.

Esta técnica tiene nombre propio desde 1996, cuando el artículo “Smashing the Stack for Fun and Profit” la documentó paso a paso y la convirtió en conocimiento público. La expresión smashing the stack —machacar la pila— describe con precisión lo que ocurre: no se corrompe un dato cualquiera, se machaca la estructura de control que decide a dónde vuelve el programa. Treinta años después el mensaje que ves al fallar un canario, “stack smashing detected”, cita literalmente aquel título. Todo el nivel que estás cursando es, en el fondo, la respuesta acumulada de la industria a un artículo de doce páginas.

De pisar la pila a ejecutar código

Controlar rip es controlar el programa, pero convertir ese control en código útil es un arte que ha evolucionado a la par que las defensas.

💉

Shellcode en la pila

El clásico de los noventa: colocar instrucciones máquina dentro del propio búfer y apuntar la dirección de retorno a ellas. Muerto en cuanto la pila dejó de ser ejecutable con NX.

🔁

ret2libc

Sin pila ejecutable, se apunta el retorno a una función que ya existe, como system, con sus argumentos colocados en la pila. No se inyecta código: se reutiliza el que hay.

🧩

ROP

Return-oriented programming: encadenar pequeños fragmentos que terminan en ret (gadgets) para componer cualquier cálculo a partir de trozos del binario existente.

🎲

El muro de ASLR

Todo lo anterior necesita saber a qué dirección saltar. Aleatorizar la disposición de memoria obliga al atacante a filtrar direcciones antes de poder usarlas.

La progresión no es anecdótica: es la historia entera del hardening contada como escalada. Cada técnica del atacante nació para sortear una defensa concreta. El shellcode en pila funcionaba hasta que la pila dejó de ser ejecutable; entonces apareció ret2libc. La aleatorización de bibliotecas complicó ret2libc; entonces se refinó el ROP y las técnicas de fuga de información para derrotar a ASLR. Entender esta danza es entender que ninguna mitigación es definitiva: cada una eleva el coste del ataque, y la seguridad real emerge de apilarlas todas.

La forma de una carga útil

Una entrada de exploit no es ruido: tiene una estructura tan deliberada como la del marco que ataca. Se compone de tres regiones, y verlas por separado desmitifica del todo el mecanismo.

La primera es el relleno: los bytes que llenan el búfer y todo lo que hay entre él y la dirección de retorno, incluido el rbp guardado y el canario si lo hubiera. Su contenido es irrelevante mientras tenga la longitud exacta: el atacante calcula el offset, la distancia en bytes desde el inicio del búfer hasta la ranura de retorno, y la rellena con lo que sea. La segunda región es la dirección: los ocho bytes que caerán justo sobre la ranura y que ret cargará en rip. La tercera, en el ataque clásico, era el código o sus argumentos, colocados donde la dirección apunte.

// esquema conceptual de la carga contra buf[16]
// offset = 16 (buffer) + 8 (rbp guardado) = 24 bytes hasta el retorno
uint8_t carga[24 + 8];
memset(carga, 'A', 24);            // relleno: irrelevante, solo longitud
memcpy(carga + 24, &destino, 8);   // los 8 bytes que ret cargara en rip

El offset es el único número que el atacante necesita descubrir con precisión, y por eso las herramientas de explotación empiezan enviando patrones cíclicos —cada cuatro bytes distintos— para leer, en el rip corrupto del cuelgue, exactamente qué porción del patrón aterrizó en la ranura. En los exploits de shellcode se anteponía además un NOP sled: una tira de instrucciones nop que servía de red, de modo que si la dirección de salto caía en cualquier punto de la tira, la ejecución se deslizaba hasta el código útil. Contra un binario endurecido, este esqueleto se complica —hay que sortear el canario y filtrar direcciones para ASLR— pero su columna vertebral, relleno más dirección más destino, sigue siendo la misma.

El exploit no rompe una regla: aprovecha la ausencia de una

Lo perturbador del desbordamiento de pila no es que viole el modelo de C, sino que lo respeta al pie de la letra. El hardware ejecuta cada instrucción como debe; strcpy copia bytes como promete; ret salta a la dirección que encuentra, como manda su definición. No hay ni un solo paso ilegal en la secuencia. El ataque existe porque C tomó, por diseño, una decisión de una honestidad brutal: un array no lleva su tamaño consigo, y nadie comprueba los límites por ti. La misma decisión que hace a C rápido —sin metadatos, sin comprobaciones, una copia es una copia— es la que permite que la dirección de retorno y un búfer de usuario compartan pila sin barrera entre ellos. El desbordamiento no es un fallo del lenguaje: es su filosofía de confianza total llevada hasta su conclusión hostil. Por eso las defensas que verás no cambian el lenguaje; añaden capas alrededor de esa confianza para que, cuando tú la traiciones por accidente, un tercero no pueda cobrársela. Y por eso el primer paso para escribir C seguro no es memorizar flags: es interiorizar que cada strcpy, cada gets, cada bucle que escribe sin conocer su techo es una ranura de retorno esperando a un desconocido.

⚔️ Reproduce el ataque en un entorno controlado
  1. Compila el ejemplo de gets con -O0 -fno-stack-protector y aliméntalo con entradas de 8, 16 y 40 caracteres; observa en cuál aparece el SIGSEGV.
  2. Bajo gdb, inspecciona la pila con x/24xg $rsp justo antes del ret y localiza los bytes que has pisado sobre la dirección de retorno.
  3. Recompila con -fstack-protector-strong y repite: comprueba que ahora aborta con “stack smashing detected” antes de llegar al ret.
  4. Sustituye gets por fgets(nombre, sizeof nombre, stdin) y demuestra que la misma entrada de 40 bytes ya no desborda. Explica qué eslabón de la cadena has cortado.