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.
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 unauthenticatedPOST /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:
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 LISTEN127.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:
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:
/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:
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
Stage
Root cause
→ mcp-dev
MCPJam 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)
→ analyst
Jupyter token passed on the command line, readable by any local user via ps / /proc (CWE-214 / CWE-522)
→ root
Root service with a hardcoded API key and a callable "hidden" tool that dumps /root/.ssh/id_rsa (CWE-798 / CWE-269 / security-by-obscurity)