wandres.dev
CENTINELA · Seguridad y hardening

Las defensas del compilador

El arsenal que convierte un desbordamiento en un aborto limpio en lugar de un exploit: `stack canary`, `_FORTIFY_SOURCE`, NX, PIE con ASLR y RELRO. Qué eslabón del ataque corta cada una, por qué ninguna basta sola y cómo se apilan en una defensa en profundidad.

⏱ 17 min

En la lección anterior desmontaste el desbordamiento hasta su último byte. Ahora armamos la respuesta. Las mitigaciones modernas no arreglan tus bugs —siguen ahí— sino que rompen la cadena que va del bug al exploit por puntos distintos: una detecta la sobrescritura antes del ret, otra hace inútil el shellcode inyectado, otra le esconde al atacante el mapa de memoria que necesita. Ninguna es perfecta sola; el atacante que quiera ganar tiene que vencerlas todas a la vez, y ese es exactamente el objetivo del hardening.

🎯 Al terminar esta lección sabrás
  • Entender cómo un stack canary detecta la sobrescritura de la pila antes del retorno.
  • Distinguir qué protege _FORTIFY_SOURCE y qué logra NX marcando la pila no ejecutable.
  • Comprender por qué PIE, ASLR y RELRO le quitan al atacante direcciones fiables.
  • Componer el conjunto completo de flags y razonar qué eslabón corta cada uno.

El canario: detectar antes de retornar

La observación clave del desbordamiento clásico es que, para pisar la dirección de retorno, hay que pisar todo lo que hay entre ella y el búfer. El stack canary explota esa geometría: el compilador coloca un valor secreto y aleatorio —el canario— justo antes de la dirección de retorno, y en el epílogo, antes de ejecutar ret, comprueba que sigue intacto.

void protegida(const char *entrada) {
    long __canario = __stack_chk_guard;   // insertado por el compilador
    char buf[16];
    strcpy(buf, entrada);
    if (__canario != __stack_chk_guard)   // el overflow lo habria pisado
        __stack_chk_fail();               // aborta: "stack smashing detected"
    // ret solo se alcanza si el canario sobrevivio
}

Un desbordamiento lineal que llegue hasta la dirección de retorno tiene que atravesar el canario primero, y como su valor es secreto e impredecible, el atacante no puede reconstruirlo para dejarlo intacto. Si el valor no coincide al salir, el programa aborta antes de que ret toque el rip corrompido. Actívalo con -fstack-protector-strong, que cubre toda función con arrays locales o direcciones de locales tomadas. Su límite es honesto: no detiene una escritura no lineal que salte directamente a la dirección de retorno sin tocar el canario, ni una fuga de información que revele su valor. Detecta, no previene.

FORTIFY_SOURCE y NX: menos funciones peligrosas, pila muerta

Dos defensas atacan momentos distintos de la cadena. _FORTIFY_SOURCE interviene antes, en el momento de la copia; NX interviene después, en el momento en que el shellcode intentaría ejecutarse.

gcc -std=c23 -O2 -D_FORTIFY_SOURCE=3 -Wl,-z,noexecstack prog.c -o prog

_FORTIFY_SOURCE reemplaza funciones peligrosas como memcpy, strcpy o sprintf por variantes que, cuando el compilador conoce el tamaño del destino, comprueban que la copia cabe y abortan si no. El nivel 3 amplía esas comprobaciones a casos que el compilador antes no podía razonar. Es gratis en rendimiento cuando el tamaño se conoce en compilación, y no hace nada cuando no —por eso complementa, no sustituye, a la validación manual.

NX —No eXecute, la pila no ejecutable— ataca el shellcode clásico de raíz. Marca las páginas de la pila y el heap como escribibles pero no ejecutables, de modo que aunque el atacante inyecte instrucciones máquina en su búfer y logre saltar a ellas, la CPU se niega a ejecutarlas y el proceso muere. Es la defensa que mató el exploit de los noventa y forzó la invención de ret2libc y el ROP, que precisamente reutilizan código ya ejecutable en lugar de inyectar el suyo.

La distinción entre “escribible” y “ejecutable” es el principio W xor X: ninguna página de memoria debería ser ambas cosas a la vez. Una región escribible —donde entran los datos del atacante— no ejecuta; una región ejecutable —el código del programa— no se escribe. Antiguamente la pila era ambas por comodidad, y esa comodidad fue la puerta del shellcode. Hoy -Wl,-z,noexecstack es el valor por defecto de cualquier toolchain seria, y ver un binario con pila ejecutable es señal casi segura de un error de construcción o de código heredado que hay que revisar.

PIE, ASLR y RELRO: quitarle el mapa al atacante

ret2libc y ROP no inyectan código: saltan a código que ya existe. Pero para saltar a una dirección hay que conocerla. Esta última familia de defensas convierte esas direcciones en una incógnita.

🎲

ASLR

El sistema operativo carga la pila, el heap y las bibliotecas en direcciones aleatorias en cada ejecución. El atacante ya no sabe dónde está system ni sus gadgets.

📍

PIE

-fPIE -pie: hace que también el propio ejecutable se cargue en una dirección aleatoria. Sin él, el binario está siempre en el mismo sitio y ASLR queda a medias.

🔒

RELRO completo

-Wl,-z,relro,-z,now: resuelve todos los símbolos al arrancar y luego marca la GOT como solo lectura, cerrando la sobrescritura de punteros que redirigen llamadas.

🧱

Defensa en capas

Juntas obligan a filtrar direcciones antes de atacar. Una fuga de información se vuelve prerrequisito, no un lujo: el coste del exploit se dispara.

Aquí está el conjunto completo, cada flag rotulado con el eslabón que corta:

gcc -std=c23 -O2 \
    -fstack-protector-strong \       # canario: detecta el overflow antes del ret
    -D_FORTIFY_SOURCE=3 \            # comprueba tamanos en memcpy, strcpy, sprintf
    -fstack-clash-protection \       # contra el salto sobre la guard page
    -fPIE -pie \                     # el ejecutable tambien se aleatoriza
    -Wl,-z,relro,-z,now \           # GOT de solo lectura tras el arranque
    -Wl,-z,noexecstack \            # NX: la pila no ejecuta codigo inyectado
    prog.c -o prog

El diagrama muestra la lógica: cada defensa se planta ante una técnica de ataque distinta, y el atacante tiene que derribarlas en secuencia.

flowchart TD
A[Desbordamiento de un buffer local] --> B[Canario: aborta antes del ret]
A --> C[Shellcode inyectado en pila] --> D[NX: la pila no ejecuta]
A --> E[ret2libc o ROP a codigo existente] --> F[ASLR y PIE: direcciones desconocidas]
A --> G[Sobrescribir la GOT] --> H[RELRO: GOT de solo lectura]
style B fill:#a6e3a1,color:#11111b
style D fill:#a6e3a1,color:#11111b
style F fill:#a6e3a1,color:#11111b
style H fill:#a6e3a1,color:#11111b

Lo que ninguna cubre, y la frontera de hardware

Antes de dar el arsenal por completo, conviene ser honesto sobre sus grietas, porque creerlo infalible es la forma más rápida de bajar la guardia. Ninguna de estas mitigaciones arregla un bug: todas asumen que el fallo ya está en el código y solo intentan contener sus consecuencias. Y cada una tiene un flanco.

El canario cae ante una fuga de información que revele su valor, o ante una escritura no lineal que salte por encima de él. ASLR cae ante cualquier lectura fuera de límites que filtre una sola dirección: con un puntero conocido, el atacante recalcula todo el mapa. RELRO completo cierra la GOT, pero no protege otros punteros a función que vivan en memoria escribible. _FORTIFY_SOURCE solo actúa cuando el compilador conoce el tamaño del destino en compilación; ante un tamaño dinámico, no hace nada. La conclusión no es rendirse, sino recordar que estas capas compran tiempo y coste, no inmunidad: son la red bajo el trapecista, no una razón para soltarse.

La frontera actual del hardening ha bajado al hardware, precisamente para cerrar el hueco que el ROP abrió al reutilizar código existente:

🛡️

Shadow stack (CET)

El hardware guarda una copia protegida de cada dirección de retorno y aborta si la de la pila no coincide. Neutraliza el ROP en su raíz.

🎯

Control-Flow Integrity

-fcf-protection y CFI restringen los saltos indirectos a destinos legítimos, cerrando el uso de gadgets y punteros a función corruptos.

Estas defensas de flujo de control, desplegadas en procesadores y compiladores recientes, son la respuesta directa a las técnicas de reutilización de código. Actívalas cuando tu plataforma las soporte: son la capa que convierte el ROP, hoy la técnica dominante, en un ejercicio mucho más caro.

La seguridad no es una barrera: es un impuesto compuesto

La lección estratégica de este arsenal es que ninguna de sus piezas pretende ser invencible, y sin embargo el conjunto lo es casi. El stack canary no previene el desbordamiento, solo lo detecta —y una fuga de información lo derrota. NX no arregla el bug, solo prohíbe ejecutar la pila —y el ROP lo esquiva. ASLR no cierra ninguna vulnerabilidad, solo esconde direcciones —y una lectura fuera de límites las revela. Tomada de una en una, cada defensa parece derrotable, y lo es. La genialidad está en la composición: para escribir un exploit fiable contra un binario con las seis mitigaciones activas, el atacante necesita encadenar una fuga de información que derrote a ASLR, un primitivo de escritura que sortee el canario, y una cadena de gadgets que funcione bajo NX y RELRO, todo en el mismo bug o en una combinación de ellos. Cada capa no suma dificultad: la multiplica, porque cada una convierte en prerrequisito lo que sin ella era gratis. Ese es el sentido profundo de la defensa en profundidad, y es una lección que trasciende a C: no confíes en una muralla perfecta, apila murallas imperfectas hasta que el coste de atravesarlas todas supere el valor de lo que protegen. Actívalas todas, por defecto, en cualquier binario que toque datos no confiables. No cuestan casi nada y convierten tus bugs futuros —que los habrá— de catástrofes en cuelgues.

⚔️ Audita y endurece un binario
  1. Compila un programa vulnerable sin ninguna protección y confirma con checksec (o readelf -a) que no tiene canario, NX ni PIE.
  2. Recompílalo con el conjunto completo de flags y vuelve a pasar checksec: comprueba que ahora todas las mitigaciones aparecen activas.
  3. Provoca un desbordamiento y observa que el canario aborta con “stack smashing detected” en lugar de saltar a una dirección corrupta.
  4. Desactiva PIE con -no-pie y ejecuta el binario dos veces bajo gdb: comprueba que las direcciones del ejecutable ya no cambian entre ejecuciones. Explica qué le has regalado al atacante.