Esta es la forma más pura del bug: el atacante entrega *las instrucciones*, no
solo la dirección de instrucciones que ya existen. No se necesitan offsets de
libc, así que en principio funciona contra un objetivo estáticamente enlazado,
totalmente randomizado.
`make verify` ensambla `shellcode.S` y lo compara con el array de bytes embebido
en `fooc.c`, para que no puedan divergir.
**Defensa:** **NX** (también llamado W^X, "no execute"). Marcar la pila como no
ejecutable hace que el hardware se niegue a buscar instrucciones en ella, y el
`ret` aterriza en una página que no puede ejecutarse. Por eso `make food` pasa
`-z execstack`: una pila Linux normal es `rw-p`, no `rwx`, y la técnica muere
con SIGSEGV en `RIP = la dirección del payload`. La lección más importante del
laboratorio es que cada uno de estos bytes funciona solo porque se le dijo al
compilador que dejara la pila ejecutable. Ese flag está activado para bien de
nadie.
### También incluido
| Modo | Qué hace |
|---|---|
| `-t leak` | se conecta, imprime fugas, no envía nada |
| `-t demo` | envía `rip_off + 8` bytes de `0x41`, así que `RIP` se vuelve `0x4141...` y el demonio muere. Prueba el bug sin ningún conocimiento de direcciones |
| `-t sled` | un ret-sled, conservado deliberadamente como ejemplo **fallido**. Sin una fuga, harías fuerza bruta a ASLR llenando el búfer con la dirección de un `ret`. No puede funcionar aquí: `food` acepta 512 bytes, así que el sled tiene ~53 ranuras frente a ~28 bits de entropía. Implementado para que puedas verlo fallar y confirmar que el mecanismo es de verdad "la CPU sigue una cadena de rets" |
---
## La tabla de mitigaciones
Esta es la parte que hay que recordar. Cada fila es una defensa real, y la
columna derecha muestra qué hace realmente con la cadena de eventos.
| **Limita read** | `n = read(fd, buf, sizeof buf - 1);` | **Todo.** El bug no existe, así que nada aguas abajo importa | Nada — es el único fix completo |
| **Canary de pila** | `-fstack-protector-strong` (por defecto en gcc) | El `ret`: la canary se comprueba al final de la función, así que la destrucción se detecta y el proceso aborta antes de que se haga pop de `RIP` | Un error en una función *sin* array (nada que proteger); un desbordamiento que se mantiene por debajo de la canary; todo lo que no retorna normalmente |
| **NX / W^X** | `-z noexecstack` (el valor por defecto) | Shellcode. Las instrucciones del payload no pueden buscarse | ret2win y ret2libc por completo. Son *la razón* de que exista ROP |
| **PIE + ASLR** | `-fPIE` + ASLR=2 (ambos por defecto) | Las direcciones hardcodeadas de ret2win. Todo se mueve en cada ejecución | Todo donde el atacante tenga una fuga. ASLR sube el precio de un exploit; no es un fix. Nota que la pila, el heap y mmap se randomizan, pero el *contenido* del binario principal no — eso es lo que usan las cadenas ROP |
| **No fugues** | ningún `printf("%p")` a clientes; inicializa antes de imprimir | La fuga de información que hace que ASLR sea "gratis" en vez de "caro" | — |
| **No uses `printf(user_data)`** | `printf("%s", buf)` en lugar de `printf(buf)` | Errores de cadena de formato: lecturas de pila `%x`, escrituras arbitrarias `%n` — un *otro* camino a RCE | — |
| **No uses rutas no confiables** | valida y `openat()` bajo un directorio fijo | Path traversal ([CWE-22](https://cwe.mitre.org/data/definitions/22.html)) | — |
| **CET / shadow stack** | `-fcf-protection=full`, soporte de kernel y CPU | El `ret` en sí: la shadow stack recuerda la *verdadera* dirección de retorno y falla ante un desajuste. Atrapa cadenas ROP que usan el `ret` de hardware | Ataques que nunca `ret` (call-oriented, o sobrescribir el objetivo de un puntero de función con una cadena de gadgets que no necesita retorno) |
| **Lenguajes seguros** | Rust, Go, C# para código nuevo | Toda la clase. Las comprobaciones de límites se imponen en ejecución, no se esperan en la revisión | — |
### Compruébalo por ti mismo
```sh
make run # demonio vulnerable
make test # las tres técnicas funcionan
make test-hardened # el mismo código fuente, mitigaciones activadas
```
`test-hardened` compila `food_hardened` con `-fstack-protector-strong -fPIE
-pie -z noexecstack`, lo intercambia, vuelve a ejecutar las tres y luego
restaura el vulnerable. Verás:
```
### stack segment: 'rw-p' (NOT executable) is what you want to see
--- ret2win was stopped by the mitigations (as expected)
--- ret2libc was stopped by the mitigations (as expected)
--- shellcode was stopped by the mitigations (as expected)
```
Y en el log del demonio endurecido, la canary que se dispara:
```
*** stack smashing detected ***: terminated
```
Léelo con cuidado, porque es la línea más importante de todo el laboratorio:
**la canary atrapó a ret2win, no PIE.** Las tres técnicas mueren en la canary,
porque las tres pasan por el mismo `read()` y destruyen el mismo marco. NX solo
detiene además el *código* del shellcode; PIE solo rompe además la dirección
hardcodeada. Actívalas una a una, y descubrirás que la mayoría de las
mitigaciones individuales te dejan expuesto a algo.
---
## Archivos
| Archivo | Propósito |
|---|---|
| `food.c` | el demonio vulnerable. 6 errores numerados, cada uno con su fix en el comentario |
| `fooc.c` | el exploit. Reconocimiento de offset basado en objdump, reconocimiento de libc basado en `/proc`, 4 constructores de payload |
| `shellcode.S` | los 23 bytes de shellcode como assembly, para que sean legibles y verificables. `fooc` los lleva en línea y no lo necesita en ejecución |
| `Makefile` | compila, prueba y la comparación endurecida |
| `tests/pty_test.c` | conduce a `fooc` a través de un pseudo-terminal y comprueba salida real de shell |
| `tests/sock_test.c` | verificador independiente sobre un socket crudo, para que el resultado no dependa de `fooc` |
| `food.log` | el log del demonio. Tu prueba de lo que ocurrió |
---
## Dos errores de este laboratorio que merece la pena entender
No son los errores del programa objetivo. Son errores del exploit y de su
harness de prueba, y ambos produjeron mentiras convincentes. Están
documentados en la fuente donde viven; están aquí porque los patrones de fallo
son instructivos.
### Alineación de pila: el crash que no es una desreferencia NULL
**Síntoma.** La toma de control aterriza correctamente — `gdb` te muestra
dentro de `win()` — y entonces muere lo primero que hace `win()`, un
`dprintf()`. El handler de SIGSEGV informa de `RIP` profundo dentro del
formateador de glibc y una dirección de error de `(nil)`, lo que parece
exactamente un puntero corrupto.
**Causa.** La ABI System V AMD64 exige una alineación de pila de 16 bytes. Un
`ret` normal restaura `%rsp` exactamente como el `call` correspondiente lo
guardó, así que la invariante se preserva gratis. Nuestro `ret` desnudo no:
después de él, `%rsp = buf + rip_off`. Aquí, `buf` está alineado a 16 bytes y
`rip_off` es 88, así que la callee recibe una pila de 8 mod 16. glibc está
compilada con SSE2, y `movaps`**falla** ante un operando mal alineado. En x86
eso levanta `#GP`, no `#PF`, así que el kernel no tiene dirección de error e
informa `si_addr = 0`. Ese NULL es la pista: un error de alineación disfrazado
de desreferencia NULL.
**Fix.** Un gadget `ret`*en el offset `rip_off`*, que desplaza el objetivo
real 8 bytes, porque cada `ret` añade exactamente 8 a `%rsp`. El orden es
crítico: una versión anterior pegaba el `ret`*después* del objetivo y
producía `[ padding | target | ret ]`, donde el `ret` final nunca se alcanza y
el fix no hace nada en silencio. Un `ret` perdido que parece un error es casi
siempre intencional.
### Un socket, dos lectores: el byte que desapareció
**Síntoma.** El shellcode se reportó como funcionando. Luego se endureció el
harness pty (apagar `ECHO`, para que la terminal dejara de ecoar su propia
línea de comandos hacia sí misma), y la técnica empezó a fallar. Más
profundo, cada técnica perdía exactamente un byte del inicio de cada trozo de
salida: `uid=1000(hanez)` se imprimía como `id=1000(hanez)`, `PWNED-OK` como
`WNED-OK`, `Linux 7.2.7` como `inux 7.2.7`.
**Causa.** `fooc` solía hacer `dup2()` del socket sobre su propio stdin/stdout
y `execv()` de un `/bin/sh`*local*, mientras un hijo relay forkado también leía
el mismo socket para mover la salida al terminal. Al kernel le da igual que los
dos cooperen. Un socket de stream tiene **un** cursor de lectura, y cada lector
lo mueve, así que los bytes se reparten entre ellos de forma impredecible. El
shell local — un shell de login interactivo — leía exactamente un byte y lo
desechaba, cada vez. `strace -f` lo mostró de inmediato:
```
read(0, "u", 1) <- el shell local, comiéndose un byte