Panorama#

El desbordamiento de pila clásico —sobrescribir el return pointer con la dirección de un shellcode inyectado— dejó de funcionar solo hace más de una década. Tres mitigaciones lo cerraron en secuencia: NX/DEP marca la pila como no ejecutable, el stack canary interpone un valor centinela entre el buffer y el return pointer, y ASLR aleatoriza las direcciones de modo que ninguna dirección fija sea confiable. La idea central de este capítulo es que esas mitigaciones no cierran la explotación: la mueven. Cada control obliga a una técnica nueva —NX empuja hacia ret2libc y ROP, el canary hacia el leak o la reparación, ASLR hacia el information disclosure o los valores estáticos— y el trabajo del exploit dev moderno es encadenar esos bypass hasta recuperar el control del flujo.

Este capítulo extiende la base de 3.6 · Stack overflows y shellcode y de 3.7 · Format strings, ret2libc y ROP con la profundidad que exige el Linux moderno: el internals del linking dinámico que habilita ret2plt y la sobrescritura de la Global Offset Table, el ROP a nivel de mecanismo, las técnicas concretas para derrotar el canary y ASLR, y las diferencias de la explotación de 64 bits. La contracara en Windows —SEH, jmp esp con Mona, patch diffing— se trata en 3.8 · Exploit-dev en Windows.

flowchart TD
  A["Buffer overflow\ncontrol del return pointer"] --> B{"¿NX / DEP?"}
  B -->|"no"| S["Shellcode en la pila\n(ver 3.6)"]
  B -->|"sí"| C["ret2libc / ret2plt / ROP\nno se ejecuta código inyectado"]
  C --> D{"¿Stack canary?"}
  D -->|"terminator"| E["Reparar el canary\n(valor fijo conocido)"]
  D -->|"random"| F["Leak (format string)\no brute-force por fork"]
  E --> G{"¿ASLR?"}
  F --> G
  G -->|"no / x86 baja entropía"| H["Dirección estática\no brute-force + NOP sled"]
  G -->|"sí"| I["Info-leak\no valor estático (PLT, code seg, VDSO)"]
  H --> J["Control del flujo\n→ shell"]
  I --> J

Linkers, loaders y la GOT/PLT: dónde nace la primitiva#

Antes de romper nada conviene entender cómo un binario dinámico encuentra sus funciones en tiempo de ejecución, porque ahí vive una de las primitivas más útiles. El linker resuelve el nombre simbólico de una función a su dirección real; el loader trae el programa de disco a memoria. En un ELF dinámico esa resolución se difiere mediante dos tablas: la Procedure Linkage Table (PLT), de solo lectura, y la Global Offset Table (GOT), un segmento escribible que el linker dinámico rellena en la primera llamada a cada función. Ese mecanismo se llama lazy linking: el símbolo no se resuelve hasta que se lo usa por primera vez, para ahorrar recursos.

El recorrido concreto se observa con objdump y GDB sobre cualquier binario. Una llamada a puts() en el código apunta primero al stub de la PLT (call 0x8048374 <puts@plt>); ese stub hace un salto indirecto a través de un puntero guardado en la GOT (jmp *0x80497f0). La primera vez, ese puntero apunta de vuelta a la PLT, forzando al linker dinámico a resolver el símbolo y escribir la dirección real en la GOT; a partir de ahí, toda llamada la usa directo.

objdump -R ./binario # DYNAMIC RELOCATION RECORDS: las entradas GOT (R_386_JUMP_SLOT puts …) objdump -j .got.plt -d ./binario | grep <offset> # el puntero GOT antes de resolver

Que la GOT sea escribible es lo que la convierte en objetivo. Dos técnicas nacen de ahí:

  • GOT overwrite. Con una primitiva de escritura arbitraria —una vulnerabilidad de format string con %n, un overflow que alcance la dirección de la entrada, o cualquier write-what-where— se reemplaza el destino de una llamada futura. Sobrescribir la entrada GOT de puts con la dirección de system hace que el próximo puts(x) ejecute system(x).
  • ret2plt. Se retorna al stub PLT de una función ya importada por el binario, invocándola sin conocer la base aleatorizada de libc — porque la PLT vive en el propio ejecutable, no en la librería. Es también uno de los caminos para sortear ASLR, como se ve más abajo.

ROP: encadenar código que ya existe#

Cuando NX/DEP marca la pila como no ejecutable, inyectar shellcode deja de servir. La respuesta es el Return-Oriented Programming (ROP), sucesor del ret2libc que Hovav Shacham demostró Turing-completo. En vez de código propio, se encadenan gadgets: secuencias cortas de instrucciones que ya están en la imagen o sus librerías, cada una terminada en un ret que salta al siguiente. Como usa código legítimamente ejecutable, ROP derrota NX; y como los gadgets pueden estar en direcciones estables, también ayuda contra ASLR.

La densidad del set x86 es lo que hace viable la técnica: las instrucciones no tienen longitud fija, así que apuntar al medio de una instrucción válida produce otra instrucción — el mismo principio que leer “trap” dentro de “contraption”. Un ejemplo real: la instrucción intencionada 7C8016CC 8B45 20 MOV EAX,[EBP+20], leída un byte más adelante en 0x7C8016CD, se desensambla como INC EBP / AND [EBX],BH / RETN — un gadget listo para usar, con un ret (0xC3) que nunca fue pensado como retorno. Encadenando estos fragmentos se replican operaciones como las de system() sin llamar nunca a system().

Las defensas que buscan secuencias con muchos ret o violaciones del orden LIFO de la pila se sortean con ROP sin returns (un pop reg seguido de jmp *reg cumple el mismo papel que un ret) y con JOP (Jump-Oriented Programming), que usa un gadget dispatcher para evitar la dependencia de la pila. Herramientas como ROPgadget, ropper o el !mona rop del ecosistema Windows automatizan la búsqueda y el armado de la cadena.

Derrotar el stack canary#

El canary es un valor de 4 bytes (8 en x64) colocado entre el buffer y el return pointer; si al retornar la función no coincide con el original, un handler aborta el proceso con *** stack smashing detected ***. En Linux lo implementa el Stack Smashing Protector (SSP, antes ProPolice, integrado en GCC), que además reordena las variables locales. Hay tres tipos de canary, y la vía de bypass depende de cuál sea:

  • Terminator canary (0x00000aff): valor fijo y conocido, con bytes nulo/CR que cortan strcpy/gets. Es el más atacable justamente porque su valor no es secreto.
  • Random canary: 4 bytes de urandom, verificados por XOR al retornar. Secreto e impredecible.
  • Null canary (0x00000000): el más débil.

La técnica de reparación aprovecha que el terminator canary es conocido. Si la función vulnerable copia a varios buffers con strcpy()/gets(), cada copia termina la cadena con un \x00; escribiendo el valor del canary repartido entre esas copias se lo reconstruye byte a byte —incluidos los nulos, gratis— mientras el overflow sigue avanzando hasta el return pointer. La señal de éxito es que el fallo pasa de stack smashing detected a un SIGSEGV normal con el return pointer bajo control (EIP en 0x41414141): el canary quedó intacto y el chequeo no se disparó.

break *0x804848d run AAAAAAAA BBBBBBBB CCCCCCCC x/16x $esp # localizar el canary, p.ej. 0xff0a0000 (little-endian: \x00\x00\x0a\xff)

El random canary no se puede reparar por ser secreto: exige un leak (una vulnerabilidad de format string que imprima el valor desde la pila) o un brute-force por fork — en un daemon que forkea hijos, el canary se hereda constante en cada hijo, así que se lo prueba byte a byte sin que el proceso padre reinicie. El caso real de ProFTPD 1.3.0 combinó ambas cosas: un terminator canary reparado y ASLR derrotado, con exploits local y remoto publicados.

La reparación del canary depende de una condición concreta: varias operaciones de copia sobre buffers distintos dentro de la misma función. No es universal — un canary random de fuente fuerte, o una única copia, la bloquean. Como toda técnica de esta familia, es condicional al binario.

Bypass de ASLR#

ASLR aleatoriza las direcciones de code, stack, heap y objetos compartidos vía mmap(). Su control es /proc/sys/kernel/randomize_va_space (0 off, 1 on, 2 además aleatoriza brk()). No es infalible: para que stack y heap no colisionen, los bits más significativos quedan fijos, y mmap() solo mueve 16 bits, con lo que en x86 quedan en la práctica unos 20 bits de entropía — poco más de un millón de posiciones, un espacio finito. Hay cinco familias de bypass:

  1. Entropía / brute-force. Con 2²⁰ posibilidades, forzar es viable si el proceso no muere: un daemon que forkea mantiene el mismo mapeo. Un NOP sled grande y una dirección de retorno constante mejoran la chance de aterrizar. Es ruidoso —while true; do ./objetivo; sleep 1; done genera crashes y reconexiones en volumen—.
  2. Data leakage. Una vulnerabilidad de format string permite leer memoria del proceso e imprimir la ubicación de una variable o instrucción, revelando las direcciones necesarias sin adivinar.
  3. Valores estáticos y ret2plt. No todo se aleatoriza: el code segment de un binario no-PIE suele mapearse siempre igual, y la PLT/tablas de relocación no se aleatorizan (habilitando ret2plt).
  4. Trampolines (ret-to-registro). Al desarmarse la función, un registro suele apuntar a la zona controlada. jmp esp (0xffe4) / call esp (0xffd4): se coloca el shellcode después del return pointer y se sobrescribe este con la dirección de un jmp/call esp. La familia completa es FF E0..E7 (jmp a eax/ecx/edx/ebx/esp/ebp/esi/edi) y FF D0..D7 (call), más las variantes ret-to-EAX, ret-to-ret y ret-to-ptr. El opcode buscado no tiene por qué ser una instrucción real del programa: basta con que esos bytes existan en memoria estática.
  5. El VDSO linux-gate.so.1. Es un virtual dynamically linked shared object mapeado consistentemente en 0xffffe000 (kernels 2.6.17–2.6.19), usado para syscalls virtuales (SYSENTER/SYSEXIT, más rápido que int 0x80). Al ser estático, sirve de trampolín: se busca un jmp esp adentro con GDB o volcándolo, y se lo usa como return pointer. Es dependiente de kernel: en 2.6.20 el jmp esp fue removido y en 2.6.24 el propio VDSO se aleatoriza.
ldd ./objetivo # linux-gate.so.1 => (0xffffe000) permanece estático mientras libc cambia # volcar el VDSO y buscar el trampolín dd if=/proc/self/mem of=/tmp/vdso.bin bs=4096 skip=1048574 count=1 # skip = 2^32/4096 - 2 xxd -g1 /tmp/vdso.bin | grep "ff e4" # jmp esp; sumar la base 0xffffe000 al offset

Explotación x64#

La arquitectura de 64 bits sube el costo del bypass. Tiene 16 registros de propósito general de 64 bits (frente a 8 de 32 en x86), lo que eleva la entropía de la pila de 2²⁰ a 2³² y vuelve el brute-force irreal en tiempo razonable: hay que apoyarse en instrucciones estáticas o information disclosure. El resto del mecanismo es el mismo, con registros y direcciones de 8 bytes (RBP: 0x4141414141414141). Como los primeros argumentos viajan en registros, los gadgets clave son pop rdi; ret y pop rsi; ret; las extensiones modernas —ret2csu para poblar rdx, one-gadget para un execve("/bin/sh") de un solo salto— derivan de la misma mecánica.

Para depurar conviene PEDA (Python Exploit Development Assistance), un plugin de GDB al estilo de Mona que, al llegar a un breakpoint, imprime registros, pila y desensamblado, y aporta comandos como pattern_create (para calcular el offset al return pointer) y jmpcall (para buscar trampolines).

Trampa habitual: GDB desactiva ASLR por defecto dentro del debugger (show disable-randomization = on). Un exploit que funciona en GDB puede fallar fuera de él por direcciones distintas. Hay que ejecutar set disable-randomization off para que el entorno de depuración coincida con el real.

Defensa y detección#

El endurecimiento correcto cierra cada vía en compilación, y la telemetría de crash delata los intentos de bypass:

  • Full RELRO (-Wl,-z,relro,-z,now) resuelve todas las entradas al inicio y marca la GOT de solo lectura, eliminando el GOT overwrite y ret2plt como opciones. PIE + máxima entropía de ASLR (randomize_va_space=2, kernel moderno donde el VDSO ya se aleatoriza) obligan a un leak previo. NX/DEP fuerza el uso de ROP, cuya cadena de punteros y las llamadas a mprotect/VirtualProtect que marcan una región RWX son señales anómalas.
  • Canary random de fuente fuerte (no terminator), FORTIFY_SOURCE, y reemplazar funciones sin límite (strcpy/getsstrncpy/fgets) matan la reparación del canary. Las protecciones de flujo por hardware (Intel CET shadow stack, CFG en Windows) rompen ROP/JOP al validar los destinos de retorno y salto.
  • En detección: el abort *** stack smashing detected *** es un SIGABRT de __stack_chk_fail; un pico de core dumps o SIGABRT repetidos sobre un binario de red delata un brute-force del canary. El brute-force de ASLR es igual de ruidoso — un servicio que se reinicia o crashea en bucle, y volumen de reconexiones cuando es remoto. Esa telemetría de creación de proceso y crash se correlaciona con la detección de ejecución anómala del lado blue (Event ID 4688 / Sysmon 1 en Windows, auditd/execve en Linux) y con la detección de scanning por volumen cuando el intento llega por red. El parcheo rápido por catálogo KEV cierra la ventana de los 1-day (ver el patch diffing de 3.8 · Exploit-dev en Windows).

Referencias#