foo/README.ES.md
2026-09-29 10:04:38 +02:00

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.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

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 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:

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:

  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) 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

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.