Initial commit

This commit is contained in:
Johannes Findeisen 2026-09-29 09:39:24 +02:00
commit 394e3be54d
41 changed files with 16315 additions and 0 deletions

426
README.ES.md Normal file
View file

@ -0,0 +1,426 @@
# food / fooc — un desbordamiento de búfer de pila, desde ambos lados
Un laboratorio de seguridad en C99 en dos mitades:
- **`food.c`** — un demonio TCP deliberadamente vulnerable. Tiene un
desbordamiento de búfer de pila real, de libro de texto (CWE-120), más un par
de errores de propina.
- **`fooc.c`** — un exploit contra él. Calcula el offset del desbordamiento
desensamblando el programa objetivo en tiempo de ejecución, lee las fugas de
direcciones del demonio y consigue un shell en la "víctima" sobrescribiendo
una dirección de retorno guardada.
El punto no es el shell. El punto es que puedas seguir de principio a fin cómo
un error de seguridad de memoria se convierte en ejecución de código arbitrario
— y después ver exactamente qué mitigaciones detienen cada eslabón de esa
cadena. Cada línea de ambos programas está comentada, porque el mecanismo es la
lección.
```
tu terminal
|
./fooc (exploit)
|
TCP 127.0.0.1:2342
|
./food (demonio vulnerable)
|
fork() -> vulnerable_handler() -> overflow -> ret -> tu código
```
---
## ⚠️ Lee esto primero
**`food` es un servicio de red deliberadamente roto. Solo se enlaza a
`127.0.0.1`, y ese valor por defecto es deliberado — déjalo así.**
- **No** lo ejecutes en una máquina que te importe, ni en nada que contenga
datos.
- **No** lo enlaces a `0.0.0.0` ni a una interfaz de red real. Está
deliberadamente diseñado para ser explotable de forma remota.
- Apuntar `fooc` a un host que no posees o para el que no tienes permiso
escrito de prueba es un delito informático en la mayoría de las
jurisdicciones — también bajo la UK Computer Misuse Act y la US Computer
Fraud and Abuse Act.
- Se enlaza a un puerto no privilegiado (>1024), así que no necesitas root. No
lo "mejores" añadiendo capabilities o ejecutándolo como servicio del sistema.
- Cada conexión se gestiona en un hijo `fork()`, y `food` hace reap de él, así
que los crashes no se acumulan. Si luego encuentras docenas de `sh`
sueltos, `pkill -x sh` es la limpieza.
En caso de duda: este laboratorio es para una máquina virtual o un contenedor,
en una red que tú controlas, en una máquina sin nada que echaras de menos.
---
## Inicio rápido
```sh
make # compila food, fooc y los harness de prueba
make run # arranca food en 127.0.0.1:2342, desacoplado en segundo plano
make test # ejecuta las tres técnicas de exploit
make stop # detiene el demonio
```
Después, a mano:
```sh
./fooc -t leak # mira las fugas de direcciones que food revela
./fooc -t demo -v # envía basura; ve morir a food con SIGSEGV
./fooc -t ret2win -i # salta a una función que ya existe -> shell
```
### Requisitos
| Herramienta | Para qué | Notas |
|---|---|---|
| `gcc` (o clang) | compilar | C99. Probado con gcc 16.2 |
| `objdump` | `fooc` | binutils. `fooc` lo invoca en tiempo de ejecución |
| `nasm` | `make verify` | solo para contrastar el shellcode; se omite si falta |
| `gdb` | `make debug` | opcional |
| Linux, x86-64 | ambos | el payload y la caza de gadgets dependen de la arquitectura |
`fooc` también necesita `-ldl` para `dlsym()`; el Makefile lo gestiona.
---
## El bug
Una línea en `food.c` es toda la superficie de ataque:
```c
char buf[FOOD_BUFSZ]; /* 64 bytes */
n = read(fd, buf, FOOD_READMAX); /* hasta 512 bytes de la red */
```
64 bytes de destino, 512 aceptados. El atacante sobrescribe 448 bytes más allá
del final del búfer, y como la pila crece hacia abajo, "más allá del final"
significa "dentro del marco superior" — y ahí es exactamente donde están el
puntero de marco guardado y la **dirección de retorno guardada**.
En una función x86-64 compilada a `-O0`:
```
direcciones altas
+------------------------+ rbp + 16 : locales de la llamadora
| ... |
+------------------------+ rbp + 8 : DIRECCIÓN DE RETORNO GUARDADA <-- se vuelve RIP
| saved rbp (8 bytes) |
+------------------------+ rbp : nuestro puntero de marco
| line[128] |
| buf[64] | <- rsp: lo que read() llena
+------------------------+
direcciones bajas
```
Cuando la función retorna, `leave; ret` hace pop de los 8 bytes en `RIP`, y la
CPU salta donde el atacante ha decidido. Todo lo demás en este laboratorio es
aritmética sobre hacia dónde apuntar.
Para esta compilación, los números son: `buf` mide 64 bytes, el `rbp` guardado
mide 8, así que la dirección de retorno está en el offset **88** desde el
inicio de `buf`. `fooc` no hardcodea eso — desensambla `food` y encuentra el
`lea -0x50(%rbp)` delante de `call read@plt`, así que sigue funcionando si
cambias `FOOD_BUFSZ`.
> gcc ya te lo dice. Compilar `food` imprime:
> `warning: 'read' writing 512 bytes into a region of size 64 overflows the
> destination [-Wstringop-overflow=]`. Nunca silencies esa advertencia en
> código real. Es seguridad gratuita.
---
## Las tres técnicas
`fooc -t <technique>`. Están en el orden en que un atacante real trabajaría en
ellas, porque cada una necesita lo que la anterior te enseñó.
### 1. `ret2win` — controla el puntero de instrucción
```
[ 88 bytes de basura ][ la dirección del win() de food ]
^ saved rbp
^ se vuelve RIP
```
`win()` es una función del programa objetivo que hace exec de `/bin/sh`.
Sobrescribir la dirección de retorno con su dirección es todo el exploit.
**Lo que enseña:** tienes control arbitrario del puntero de instrucción.
Tampoco necesita fuga, porque el binario está compilado con `-no-pie`, así que
`win()` está en una dirección fija para siempre.
**El equivalente del mundo real** no es "los ataques son fáciles", sino "no
envíes backdoors no documentadas en binarios de red". Si existe una función
como `win()` en tu binario, un desbordamiento de búfer la encontrará. Es
literalmente la clase de CVE de backdoor de Juniper ScreenOS.
**Defensa:** `-fPIE` (o ASLR) randomiza la dirección de carga, así que el
atacante debe conocer la dirección — lo que normalmente significa que primero
necesita una fuga. Por eso `ret2win` falla contra `food_hardened`.
### 2. `ret2libc` — llama a lo que sea, por su nombre
```
[ basura ][ pop rdi; ret ][ dirección de "/bin/sh" ][ dirección de system() ]
^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
pone rdi la cadena a enviar la función a llamar
```
En ejecución: `ret` hace pop de `pop rdi; ret` en RIP; eso hace pop del puntero
`"/bin/sh"` en `RDI`; su `ret` hace pop de `system()` en RIP, mientras `RDI`
sigue sosteniendo la cadena. `system("/bin/sh")` se ejecuta.
Los gadgets (`pop rdi; ret`) no están en `food` — esta glibc no tiene
`__libc_csu_init` — así que `fooc` los encuentra escaneando la memoria viva de
libc en busca del par de bytes `5f c3`. Localiza libc vía `/proc/self/maps`,
encuentra los offsets de `system` y `"/bin/sh"` con `dlsym()` y calcula la base
a partir de la fuga que `food` divulga. Nada está hardcodeado, así que
sobrevive a una actualización de libc.
**Lo que enseña:** una vez que puedes controlar `RIP`, puedes encadenar
instrucciones *existentes*. Eso es return-oriented programming, y así se ven
casi todos los exploits reales, porque no requiere memoria ejecutable provista
por el atacante.
**Defensa:** ninguno de los flags del compilador lo detiene solo. Funciona
contra un binario PIE, con NX, con canary — mientras el atacante tenga una
fuga. Las defensas son "no tengas el desbordamiento" y "no fugues
direcciones". Ver la tabla más abajo.
### 3. `shellcode` — ejecuta tu propio código máquina
23 bytes, colocados al inicio del búfer, con `RIP` apuntando a ellos:
```asm
xor esi, esi ; envp = NULL
xor edx, edx ; argv = NULL
movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" como 8 bytes crudos
push rdi ; deja la cadena en la pila
mov rdi, rsp ; rdi = &"/bin/sh"
push 0x3b ; 59 = __NR_execve
pop rax
syscall ; ahora somos un shell
```
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.
| Mitigación | Cómo activarla | Qué detiene | Qué *no* detiene |
|---|---|---|---|
| **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) | — |
| **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
read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- el relay, 1 byte corto
```
**Fix.** No hay ningún shell en este lado, punto. Hay exactamente un shell en
todo el cuadro, y está en la víctima, dentro del proceso secuestrado, con la
conexión TCP como su stdin/stdout. Este lado solo mueve bytes. Si alguna vez
necesitas dos consumidores de un stream, ese stream necesita un único lector
que lo demultiplexe deliberadamente.
**La meta-lección.** El primer resultado "funcionante" fue un falso positivo,
producido porque la pty ecoaba su propia línea de comandos hacia sí misma, y el
fix de ese falso positivo es lo que reveló el error real. Las pruebas que no
pueden fallar son peores que ninguna prueba, porque convierten "no lo sé" en
"funciona". Un harness de prueba merece la misma sospecha que el código que
prueba.
---
## Experimentar con ello
Cosas que merece la pena probar, más o menos en el orden en que más aprendes de
ellas:
1. **Cambia `FOOD_BUFSZ` a 128.** Vuelve a ejecutar `fooc`. Debería seguir
funcionando sin cambios, porque lee el offset del desensamblado. Luego
rómpelo a mano — hardcodea 88 — y míralo crashear. Después añade un segundo
array entre `buf` y los registros guardados, y mira cómo lo gestiona el
reconocimiento automático.
2. **Añade `-Wformat-security` y mira qué hace el camino de cadena de
formato.** Envía `%p %p %p %n` y mira a `food` fugando la pila.
3. **Usa gdb.** `make debug`, luego:
```gdb
(gdb) break food.c:393 # el read() que se desborda
(gdb) run -p 2342
(gdb) info registers rsp rbp
(gdb) x/24gx $rsp # observa dónde está la dirección de retorno
(gdb) c # en otra terminal: ./fooc -t ret2win
```
El handler de SIGSEGV registra `REG_RIP` y `REG_RSP`, así que `food.log` te
dice si la toma de control aterrizó, incluso cuando el hijo muere antes de
que puedas adjuntarte.
4. **Borra el fix de alineación** en `fooc.c` y mira el error `#GP` con la
firma `si_addr = 0`. Luego lee `/proc/sys/kernel/randomize_va_space` y
piensa qué randomiza ASLR y qué no.
5. **Rompe la resolución de símbolos de libc** y mira cómo se adapta `fooc`.
Todo el punto del enfoque `/proc/self/maps` es que ningún offset está
hardcodeado.
6. **Escribe una cuarta técnica.** Una cadena tipo `ret2csu` si puedes
encontrar `__libc_csu_init`, o una cadena SROP (los marcos `sigreturn` te
dejan controlar todos los registros a la vez). Ambas son ROP puro y no
necesitan memoria ejecutable.
7. **Arregla `food.c` de verdad**, un error a la vez, y vuelve a ejecutar el
exploit después de cada fix. El orden de la tabla al inicio de `food.c` es
más o menos el orden correcto en que pensar: limita primero el read, porque
nada más importa hasta que el bug desaparece.
---
## Limpieza
```sh
make stop # detiene food
make clean # elimina los productos de compilación; deja food.log en paz
pkill -x sh # solo si tienes shells sueltos de una prueba que salió mal
```
Nota: `pkill -x food` coincide exactamente con el **nombre** del proceso. No
uses `pkill -f ./food` — ese patrón también coincide con el shell donde lo
escribes y mata tu propia sesión. No es una hipótesis; ocurrió mientras se
construía este laboratorio.