foo/suid/README.ES.md

399 lines
19 KiB
Markdown
Raw Normal View History

2026-09-29 09:39:24 +02:00
# Laboratorio de RCE root por SUID — `foosd` (demonio) + `foosc` (exploit)
Un compañero del laboratorio principal (`food` / `fooc`, un demonio normal donde
un desbordamiento de búfer te da un shell de *usuario*). Este añade el cambio de
un solo carácter más peligroso de Unix: **el bit setuid**.
> `chmod u+s` convierte "el atacante puede ejecutar código en este host" en "el
> atacante puede ejecutar código como **root** en este host".
Esa frase es todo el laboratorio. Todo lo que sigue es el mecanismo que tiene
debajo, escrito, para que cuando escribas tu propio software sepas
exactamente qué dos o tres atributos del sistema de archivos y flags del
compilador deciden si un error de seguridad de memoria en tu código es una
molestia o un shell root.
La demo final, cuando `foosd` es setuid-root, es un **shell root** abierto a
través de la red ejecutando 32 bytes de shellcode escrita a mano.
---
## 1. Qué hace realmente el bit setuid
Cada proceso en Linux lleva tres user-ID, y el bit setuid toca la relación
entre ellos:
| ID | Nombre | Significado |
|----|------|---------|
| `ruid` | user-ID real | la cuenta que *inició* el proceso |
| `euid` | user-ID efectivo | lo que el kernel comprueba al imponer el acceso |
| (saved) | set-user-ID guardado | una "ranura" a la que un proceso privilegiado puede volver más tarde |
Un programa normal tiene `ruid == euid`. Cuando ejecutas un binario con el bit
setuid puesto, propiedad de root:
```text
ruid = tú (p. ej. 1000, "hanez")
euid = el dueño (p. ej. 0, "root")
```
El proceso tiene por tanto **la autoridad de root**, aunque el usuario que lo
inició sea perfectamente normal. Cada comprobación que hace el kernel — ¿puede
este proceso leer `/etc/shadow`? ¿escribir un archivo? ¿matar a otro proceso? —
se responde con `euid`, es decir, "sí, es root".
`foosd` es un demonio de red. Enlaza un puerto y luego hace `fork()` de un hijo
por conexión. Un fork *hereda* el euid, así que cada hijo que gestiona una
conexión también es root. El desbordamiento en `vulnerable_handler()` de
`foosd` es por tanto un desbordamiento *dentro de un proceso root*.
**Diagnostícalo tú mismo cuando el demonio esté corriendo:**
```console
$ ./foosd ... # mira la línea de log que imprime al arrancar
[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process
```
y desde el exploit:
```console
$ ./foosc -t leak
foosc: target euid=0 ruid=1000
```
---
## 2. El laboratorio de un vistazo
| Archivo | Rol |
|------|------|
| `foosd.c` | El demonio deliberadamente vulnerable (dueño de los errores). Ejecútalo como *binario setuid-root* para la demo del shell root. |
| `foosc.c` | El exploit. Usa por defecto la técnica de shellcode `setreuid + execve` de 32 bytes. |
| `shellcode.S` | El shellcode de referencia; `make verify` lo compara con el array de bytes en `foosc.c`. |
| `tests/pty_suid_test.c` | Harness de prueba. Conduce a `foosc` a través de un pseudo-terminal y prueba tanto "corrió un shell" *como* "era root" (`uid=0(`). |
| `Makefile` | Compilación, helpers `setuid`/`unsetuid`, matriz de prueba. |
| `README.md` | Este archivo. |
> **¿Por qué una pty?** La última acción del exploit es retransmitir tu
> terminal al shell que corre en la víctima. Un pipe o un here-doc llega al
> lado equivocado de esa retransmisión; se requiere un terminal real.
---
## 3. Inicio rápido
```console
$ make # compila todo, como tu usuario normal
$ make setuid # una vez, pide sudo: chown root + chmod u+s
$ make run # arranca foosd en 127.0.0.1:2343
$ make test-suid # matriz completa; shellcode + ret2win-root deben dar root
```
Prueba de humo interactiva:
```console
$ ./foosc -t shellcode
...
foosc: target euid=0 ruid=1000
foosc: shell is on the victim (root if foosd is SUID); relaying
# id
uid=0(root) gid=0(root) groups=0(root) <-- eres root, en la víctima
# exit
```
Cuando termines:
```console
$ make stop
$ make unsetuid # higiene: no dejes nunca un binario root SUID suelto
```
---
## 4. *¿Cuándo pongo el bit SUID?* — la respuesta que pediste
Exactamente **una vez, después de compilar, antes de arrancar el demonio para
las demos de shell root** — y solo en una máquina que sea tuya, apta para tirar
y desconectada de la red:
```console
$ make # compila foosd, foosc, las pruebas
$ make setuid # <-- EL MOMENTO. sudo chown root:root foosd && sudo chmod u+s foosd
$ make run # arranca DESPUÉS de poner el bit
```
Dos reglas que importan más que el momento exacto:
1. **Ponlo solo cuando el binario esté terminado.** Si recompilas (`make` /
`make clean`) después de poner el bit, te topas con "Permission denied" al
escribir los archivos de salida propiedad de root — y si fuerzas la
recompilación, el toolchain recrea el archivo **sin** la `s` y deshace la
configuración en silencio. El orden canónico en cualquier recompilación es
por tanto
```console
$ make unsetuid && make && make setuid
```
2. **Quítalo cuando termines.** `make unsetuid`. Un binario setuid vivo,
propiedad de root, con un error explotable en tu árbol no es una herramienta
pedagógica, es un agujero root con un error de compilación entre él y nada.
En una máquina compartida o de producción: **no hagas nada de esto.** El
demonio además se niega por defecto a enlazarse a nada que no sea loopback
(ver §7).
Si ejecutas el exploit *sin* poner nunca el bit, nada se rompe — el payload
sigue aterrizando y sigues obteniendo un shell. La diferencia está en un solo
número, y el exploit lo dice en voz alta:
```console
foosc: WARNING: the daemon is NOT running with euid 0.
The payload will still land, but the shell will be
a plain user shell, not root.
Fix: sudo make setuid
```
El resultado "funcionó, pero no root" es en sí parte del laboratorio.
Recuérdalo para la siguiente sección.
---
## 5. El mecanismo — y el giro que hace interesante a SUID
### 5.1 El desbordamiento (idéntico a `food`)
El handler de `foosd` da a un `read()` 512 bytes de confianza mientras le
ofrece un búfer de pila de 64 bytes:
```c
char buf[64];
n = read(fd, buf, 512); /* <- CWE-120: 448 bytes sobre el borde */
```
En x86-64 la pila crece hacia abajo. El exploit escribe 64 bytes de basura para
llenar `buf`, 8 para llenar el puntero de marco guardado y 8 más para
reemplazar la **dirección de retorno guardada**. Cuando `vulnerable_handler`
ejecuta `ret`, la CPU hace pop del valor del atacante en `RIP` — ejecución de
código controlada por el atacante. El exploit encuentra la distancia exacta (88
bytes para esta compilación) analizando la salida de `objdump` en lugar de
hardcodearla, así que el número sobrevive a las recompilaciones.
### 5.2 El giro: el shell se niega a ser root
Aquí es donde pensar "bug SUID → spawn /bin/sh → root" iría mal, y por qué este
laboratorio tiene exactamente la forma que tiene.
Cuando corre un programa setuid-root, su `ruid` sigue siendo el usuario que lo
inició y su `euid` es root. Si el programa — o el atacante — lanza ahora un
shell:
* `execve("/bin/sh")` **no** cambia los uids; el nuevo proceso hereda
`(ruid=1000, euid=0)`.
* bash (y dash) **comprueba exactamente ese estado al arrancar**. Del manual de
bash: *"If the shell is started with the effective user (group) id not equal
to the real user (group) id, and the -p option is not supplied, … the
effective user id is set to the real user id."*
Así que el shell se mira y *suelta root* — una defensa que los autores de
shell construyeron exactamente contra este ataque (la justificación histórica
era el problema de las shells setuid / scripts setuid). El resultado son los
casos "funcionó, pero no root":
| Técnica | Qué ejecuta | uid resultante |
|-----------|------------------|---------------|
| `ret2win` | el `win()` de `foosd` → `execl("/bin/sh")` | **1000** — shell aterrizado, root reseteado por bash |
| `ret2libc` | `system("/bin/sh")` → `sh -c '/bin/sh'` fresco | **1000** — el mismo reset, un nivel abajo |
| `ret2win-root` | el `win_root()` de `foosd` → `setreuid(0,0); execl("/bin/sh")` | **0** — ruid limpiado desde C |
| `shellcode` | 32 bytes: `setreuid(0,0); execve("/bin/sh")` | **0** — ruid limpiado desde código máquina |
Las que alcanzan root se diferencian de las que no lo hacen en exactamente una
idea: **limpian el uid *real*, no solo el efectivo.**
```c
setuid(0) /* pone euid a 0, pero ruid sigue en 1000:
bash sigue viendo euid != ruid y resetea IGUAL. */
setreuid(0, 0) /* pone AMBOS: ruid = euid = 0.
bash ve uids iguales y conserva root. */
```
Por eso el shellcode `/bin/sh` clásico que encuentras por todo internet
empieza con un syscall de limpieza de uid — y por eso el shellcode aquí es de
32 bytes en lugar de 23: las primeras cinco instrucciones son
```asm
xor edi, edi ; ruid = 0
xor esi, esi ; euid = 0
push 0x71 ; 113 = __NR_setreuid
pop rax
syscall
```
### 5.3 Entonces, ¿qué es el exploit, de principio a fin?
1. `foosc` lee el banner de `foosd` por el socket. Obtiene:
- `ids=0/1000` — euid/ruid (el autodiagnóstico SUID)
- `stack=…` y `libc=…` — punteros (las fugas de ASLR)
- `BUF=…` — la dirección exacta del búfer que está a punto de desbordar
2. Del binario objetivo (vía `objdump`) aprende `rip_off` y las direcciones de
`win()` / `win_root()`.
3. De *su propia* libc (vía `/proc/self/maps` + `dlsym` + un escaneo de memoria)
mide los offsets de `system`, `read`, `/bin/sh` y un gadget `pop rdi; ret` —
nada está hardcodeado.
4. Ensambla el payload. Para `-t shellcode`, es:
`[código setreuid+execve de 32 bytes][basura hasta RIP][ret-fix][dirección de buf]`.
5. El `read()` de `foosd` se desborda; el `ret` aterriza en el shellcode; el
kernel ejecuta `setreuid(0,0)` (sin problema: euid 0 es privilegiado) y luego
`execve` de `/bin/sh`. bash arranca con `ruid == euid == 0` y sigue siendo
root.
6. `foosc` retransmite tu terminal a ese shell root, hasta que escribes `exit`.
Un detalle de comodidad que cuesta caro a la gente si se pasa por alto: el
exploit prueba cada comportamiento de limpieza de uid **sin** necesitar primero
el bit setuid. Ejecuta `make test` antes de `make setuid`, y verás cada técnica
aterrizar un shell con `ROOT=MISSING`; ejecuta `make test-suid` después de
`make setuid`, y `ROOT=SEEN` aparece en las dos técnicas que limpian el uid
real. Ese A/B es toda la lección, representada en diez segundos.
---
## 6. Los viejos one-liners — y por qué la mayoría están muertos
Si has leído sobre SUID, has leído sobre secuestro de `PATH`, `LD_PRELOAD` y
shells setuid. Los tres son clásicos, y los tres fallan en un sistema moderno
contra *este programa*. Vale la pena saber exactamente por qué, porque las
razones son las defensas que obtienes gratis:
| Clase de ataque | Vieja afirmación | Por qué falla en una máquina moderna |
|--------------|-----------|------------------------------|
| `LD_PRELOAD` de una biblioteca maliciosa | "El programa setuid carga mi `.so` y ejecuta mi código como root." | El kernel marca un binario setuid como **AT_SECURE**; glibc ignora entonces `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_DEBUG` y compañía. El entorno se trata como *entrada no confiable*. `LD_PRELOAD` contra un binario setuid es un no-op. |
| Secuestro de `PATH` (`system("ls")` con un PATH envenenado) | "Apunta PATH a un directorio con mi `ls` falso; el programa root lo ejecutará." | Otra cara de la misma defensa: un proceso AT_SECURE recibe un **PATH saneado** (un valor por defecto seguro, más o menos `/usr/local/bin:/usr/bin:/bin`) para `system()`/`execvp`, así que el directorio envenenado nunca se consulta. |
| Inyección de comando `system()` setuid | "El comando inyectado se ejecuta con euid 0." | `system()` ejecuta el comando en un `/bin/sh` nuevo, y ese shell — §5.2 — resetea `euid = ruid` al arrancar. El comando inyectado se ejecuta con el uid *real*. (Sigue siendo un error; solo que ya no escala vía `/bin/sh`.) |
| Shell root setuid en disco (`cp /bin/sh /tmp; chmod u+s`) | "Ejecútalo, consigue root." | Exactamente la defensa de arriba, y esa es la razón por la que las distros modernas no entregan ningún shell root setuid. Incluso si consigues fabricar uno, bash se niega a mantener euid 0 salvo que se inicie con `-p`. |
Lo que sigue vivo, y eso es este laboratorio: **el programa *ya* es root cuando
corre.** No necesitas el entorno ni `system()`; necesitas que el programa
ejecute *tu* código (vía un error de corrupción de memoria) mientras es
privilegiado, y que tu código sea lo bastante cuidadoso para corregir él mismo
el desajuste de uids — `setreuid(0,0)` — antes de entregarte un shell. La
corrupción de memoria + SUID es la combinación que todavía termina en `uid=0`,
y eso es exactamente por qué los lenguajes seguros en memoria, las canaries y
las pilas no-ejecutables no son una decisión de moda.
---
## 7. Las barandillas de seguridad integradas en el demonio
`foosd` es deliberadamente la *peor* pieza de software de este repositorio, así
que también lleva más barandillas:
1. **Solo loopback, impuesto.** `foosd` rechaza cualquier dirección de bind
fuera del loopback, salvo que pases `-L`. Un listener setuid-root en una
interfaz real es un servicio root remoto; el rechazo es el valor por defecto,
para que el estado peligroso tenga que escribirse deliberadamente.
2. **Autodiagnóstico.** Al arrancar registra `ruid`/`euid` y si corre como root,
para que la consola muestre el estado del que depende el exploit.
3. **El log nunca llega al cliente.** El demonio reserva un descriptor de log
privado antes de que los sockets reemplacen a fd 1, para que la salida del
crash-reporter y las rutas internas no puedan leerse de vuelta por el cable
por el atacante.
4. **Crash-reporter.** Un handler de SIGSEGV registra `RIP`/`RSP` — el valor que
el atacante escribió en la dirección de retorno — para que una toma de
control exitosa sea visible en `foosd.log` en lugar de ser una muerte
silenciosa.
5. **`make unsetuid`.** Quitar el bit está scripteado, porque dejarlo puesto es
el modo de fallo que la gente realmente tiene.
---
## 8. Mitigaciones — qué detiene cada una y qué *no* detiene
Aplicadas a `foosd` vía `make hardened`, una a una o juntas:
| Mitigación | Qué detiene | Qué *no* detiene |
|------------|---------------|-------------------------|
| `-fstack-protector-strong` (canary) | El desbordamiento: `ret` detecta una canary destruida y aborta antes de que se use la dirección del atacante. Detiene aquí **las cuatro** técnicas — comparten el único `read()` vulnerable. | Nada por *diseño*: el binario sigue siendo setuid-root; otro error (format-string-`%n`, heap-overflow, use-after-free) no tiene canary que disparar. |
| `-fPIE -pie` (ASLR para el binario) | El uso de direcciones `win()`/`win_root()` predecibles (las técnicas ret2win). | La técnica de shellcode, si todavía se filtra una dirección de pila (línea `BUF=`). |
| `-z noexecstack` (NX / W^X) | El shellcode: la CPU se niega a buscar instrucciones en una página solo-de-datos, así que un salto a `buf` es un SIGSEGV. | ROP — ejecutar código que ya existe (`ret2libc`). |
| Las tres juntas | Un binario difícil de desbordar, randomizado, con pila no ejecutable. Así se ve una build endurecida normal. | El bit setuid. **Un binario SUID endurecido sigue siendo un binario SUID.** Si sobrevive cualquier error de memoria alcanzable, sigue siendo "error en un proceso root". |
La prueba en consola es `make test-hardened`, que intercambia la build
endurecida y muestra las técnicas muriendo en la canary, mientras
`foosd_hardened.log` captura `*** stack smashing detected ***`.
Dos mitigaciones de nivel de diseño que ningún flag de compilador entrega, y
que el laboratorio principal (`food`) también usa:
- **Mínimo privilegio.** Un demonio para un puerto no privilegiado (2343 >
1024) no tiene ninguna necesidad legítima de root. Un `foosd` correcto
enlazaría y luego haría `setgroups`/`setgid`/`setuid` a una cuenta no
privilegiada y *confirmaría que se mantuvo* (la versión correcta está en la
fuente como `drop_privs()`, nunca llamada — el no-lamarlo es el error n.º 3
del laboratorio).
- **Limita el read.** `n = read(fd, buf, sizeof(buf) - 1)`. Una línea correcta
supera a todos los flags de compilador de la tabla.
---
## 9. El protocolo wire (para que puedas leer el demonio con netcat)
```text
FOOSD 1.0 - deliberately vulnerable SUID service
Type 'quit' to disconnect. Buffer = 64 bytes, read accepts 512.
FOOSD 1.0 ids=0/1000 leak stack=0x7ffd... libc=0x7f...
BUF=0x7ffd...
```
* `ids=euid/ruid` — no podía imprimirse como `euid=`/`ruid=`, porque el harness
de prueba prueba un shell haciendo grep del `uid=` literal, y el banner no
debe contenerlo (una sonda que comparte la firma con la respuesta es una
trampa clásica de falso positivo; ver el comentario en `foosd.c`). El harness
además exige la forma estricta de salida `id` — `uid=NNN(...)` — para que nada
de lo que imprima el demonio o el exploit pueda satisfacer la comprobación
por accidente: el propio "target euid=… ruid=…" de `foosc` contiene `uid=`
como subcadena, lo que una vez hizo que una prueba endurecida reportara un
shell que nunca había corrido.
* `stack=`, `libc=`, `BUF=` — las fugas de ASLR: dejan que el shellcode y
ret2libc calculen direcciones exactas.
---
## 10. Ejercicios
1. **Considera la degradación no-root.** Ejecuta `make test` *antes* de `make
setuid`, y luego otra vez después. Explica el cambio a `ROOT=SEEN` con la
historia ruid/euid de la §5.2.
2. **Lee el crash.** Ejecuta `./foosc -t demo -n` y luego lee `foosd.log`. La
línea `RIP=0x4141414141414141` es la basura del atacante — la prueba de que
es el desbordamiento, no el azar, quien controla la ejecución.
3. **Añade la canary.** `make hardened` y modifica tú mismo el bucle
`test-hardened`; la línea de log `*** stack smashing detected ***` es la
defensa funcionando.
4. **Desactiva la fuga.** Comenta la línea `BUF=` en `foosd.c`, recompila, y
mira `-t shellcode` pasar de determinista a un juego de adivinanzas. Esa
única línea es la razón por la que los bypass reales de ASLR son todo un
campo.
5. **El experimento `-p`.** En una copia de `win()`, cambia `execl("/bin/sh",
"sh", NULL)` por `execl("/bin/sh", "sh", "-p", NULL)` y observa root. `-p`
es la salida de emergencia documentada del guardián del shell — y la razón
por la que el consejo "solo haz spawn de un shell" de los viejos write-ups es
incompleto.
6. **¿Por qué no `setuid(0)`?** Reescribe el shellcode para llamar a `setuid(0)`
en lugar de `setreuid(0,0)` (syscall 105). El shell sigue aterrizando — y
sigue cayendo a `uid=1000`. Es el experimento de una sola línea más
instructivo de todo el repositorio.
---
## 11. Seguridad y limpieza
- Solo loopback, por defecto y por diseño; `-L` enlaza más lejos, y solo una VM
apta para tirar debería siquiera considerarlo.
- Esto es un laboratorio de shell root. No lo ejecutes en una máquina que
importe, y no apuntes `foosc -h` a algo que no poseas.
- Rito de limpieza: `make stop` y luego `make unsetuid`, y si quieres el árbol
impecable otra vez: `sudo make clean`.
```console
$ make stop
$ make unsetuid
```