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 --> JLinkers, 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 deputscon la dirección desystemhace que el próximoputs(x)ejecutesystem(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 cortanstrcpy/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.
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:
- 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; donegenera crashes y reconexiones en volumen—. - 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.
- 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).
- 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 unjmp/call esp. La familia completa esFF E0..E7(jmp a eax/ecx/edx/ebx/esp/ebp/esi/edi) yFF 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. - El VDSO
linux-gate.so.1. Es un virtual dynamically linked shared object mapeado consistentemente en0xffffe000(kernels 2.6.17–2.6.19), usado para syscalls virtuales (SYSENTER/SYSEXIT, más rápido queint 0x80). Al ser estático, sirve de trampolín: se busca unjmp espadentro con GDB o volcándolo, y se lo usa como return pointer. Es dependiente de kernel: en 2.6.20 eljmp espfue 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).
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 amprotect/VirtualProtectque 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/gets→strncpy/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 unSIGABRTde__stack_chk_fail; un pico de core dumps oSIGABRTrepetidos 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/execveen 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#
red-infra/sans660-exploiting-linux/cap-01— ROP moderno + linkers/loaders (GOT/PLT → ret2plt, gadgets, JOP).red-infra/sans660-exploiting-linux/cap-02— Derrotar la protección de pila: tipos de canary y reparación.red-infra/sans660-exploiting-linux/cap-03— Bypass de ASLR multi-técnica y explotación x64.- SANS SEC660.4 Exploiting Linux for Penetration Testers (Stephen Sims, 2019) — fuente de los tres caps.
- Hovav Shacham, The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (2007) — el paper fundacional de ROP.
- MITRE ATT&CK — T1203 Exploitation for Client Execution y T1055 Process Injection (para la fase post-explotación).