20 KiB
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.0ni a una interfaz de red real. Está deliberadamente diseñado para ser explotable de forma remota. - Apuntar
fooca 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(), yfoodhace reap de él, así que los crashes no se acumulan. Si luego encuentras docenas deshsueltos,pkill -x shes 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
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:
./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:
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
foodimprime: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:
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
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:
-
Cambia
FOOD_BUFSZa 128. Vuelve a ejecutarfooc. 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 entrebufy los registros guardados, y mira cómo lo gestiona el reconocimiento automático. -
Añade
-Wformat-securityy mira qué hace el camino de cadena de formato. Envía%p %p %p %ny mira afoodfugando la pila. -
Usa gdb.
make debug, luego:(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 ret2winEl handler de SIGSEGV registra
REG_RIPyREG_RSP, así quefood.logte dice si la toma de control aterrizó, incluso cuando el hijo muere antes de que puedas adjuntarte. -
Borra el fix de alineación en
fooc.cy mira el error#GPcon la firmasi_addr = 0. Luego lee/proc/sys/kernel/randomize_va_spacey piensa qué randomiza ASLR y qué no. -
Rompe la resolución de símbolos de libc y mira cómo se adapta
fooc. Todo el punto del enfoque/proc/self/mapses que ningún offset está hardcodeado. -
Escribe una cuarta técnica. Una cadena tipo
ret2csusi puedes encontrar__libc_csu_init, o una cadena SROP (los marcossigreturnte dejan controlar todos los registros a la vez). Ambas son ROP puro y no necesitan memoria ejecutable. -
Arregla
food.cde verdad, un error a la vez, y vuelve a ejecutar el exploit después de cada fix. El orden de la tabla al inicio defood.ces 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
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.