6.8 KiB
Audit sécurité mcp-nas — RCE root anonyme exposée Internet (2026-07-08)
Tags : infra, securite, mcp-nas, rce, docker-socket, secrets, rotation
Périmètre : conteneur linux-mcp-nas (mcp-nas.nd.i234.me, port hôte 3042→8000, réseau Docker n8n).
Complète : common/audit-securite-reseau-nas-20260703.md (couche réseau/WAN). Ici = couche applicative.
Statut : audit seul, correctifs NON appliqués (décision Nabil requise — rotation + coupure exposition = disruptif).
Verdict
Compromission totale possible par un attaquant anonyme sur Internet. Chaîne vérifiée depuis un poste externe, sans aucun credential :
- Endpoint MCP sans authentification.
POST https://mcp-nas.nd.i234.me/mcp→initializerépond,tools/listrenvoierun_command. Le bloc nginx (deploy/nginx-reverse-proxy.conf) faitproxy_pass http://localhost:3042sansauth_basic, sans allowlist IP, sans vérif de token. Les routes OAuth du serveur (/register→client_id:"public",/token,/authorize,/.well-known/...) sont des stubs décoratifs : elles ne valident aucun bearer. FastMCP n'a aucun auth provider. → shell arbitraire pour tout le monde. - Évasion vers root hôte via
docker.sock. Le conteneur tourne en root (uid=0) avec/var/run/docker.sockmonté (RW). L'API Docker est joignable (curl --unix-socket /var/run/docker.sock http://localhost/versionOK). → créer un conteneur privilégié montant/= root sur le NAS. Le binairedockern'est pas installé mais le socket suffit (curl). Le serveur MCP n'utilise jamais ce socket (aucune référence dans le code) → mount inutile. - Pivot latéral. Clés SSH privées
/root/.ssh/claude_oversight_contabo{,_v2,_v3}dans le conteneur → accès VPS Contabo. - 2e chemin root hôte. Le conteneur ssh vers
Best0f@172.17.0.1:22222(host-gateway) avec le mot de passe sudo passé en clair → sudo hôte.
audit.log = coffre à secrets en clair
/mnt/docker/linux-mcp-nas/audit.log (volume hôte RW, lisible par la RCE) journalise chaque commande entière. Relevé au 2026-07-08 : mot de passe sudo NAS en clair 266×, 102 URLs http://bolbol:MDP@.../repo.git, mots de passe n8n / Portainer, 127 clés Bifrost (x-bf-vk), 19 Bearer. Le mot de passe maître est réutilisé (même préfixe pour sudo / n8n / Portainer). → un attaquant cat le log et récupère tout le trousseau.
Autres surfaces
- Volumes RW :
/volume1/dockermonté RW → tous les.envde l'infra (43 conteneurs) lisibles ET modifiables par la RCE. Doit être:ro. - Denylist (
DENY_ANCHORED/DENY_GLOBALdanslinux_mcp_server.py) contournable trivialement : segment après&&/;,find -delete,python -c, docker.sock, ssh+sudo. C'est de la défense en profondeur, pas une frontière de sécurité — ne jamais s'y fier. - Contournement Gitea-API-HTTP (quand git absent du NAS) : utilise le mot de passe du compte
bolbolcomplet dans l'URL de clone (→ audit.log +argvvisible enps), au lieu d'un token scopé. Sur-privilège (accès à tous les repos) + fuite. - Gemini : aucune fuite. Le serveur ne référence ni Gemini ni Bifrost (grep code = 0 résultat) ; la sortie texte ne part que vers NyoraNotes. La frontière « exclusif Claude » tient au niveau code : aucune commande mcp-nas n'alimente un prompt Gemini (Gemini n'est câblé que pour la vision dans Bifrost, et mcp-nas ne produit pas d'image).
Correctifs priorisés
P0 — urgence (RCE root exposée) :
- Couper l'exposition publique : retirer la règle nginx
mcp-nas.nd.i234.meOU la restreindre au réseau WireGuard uniquement (pas deproxy_passdepuis le WAN). Si un accès distant reste nécessaire :auth_requestnginx ou un secret partagé obligatoire (header) + implémenter une vraie validation bearer côté serveur. - Supprimer le mount
docker.sockdudocker-compose.yml(jamais utilisé par le code). - Run non-root :
user: "1026:100"+cap_drop: [ALL]+security_opt: no-new-privileges. - Ne plus embarquer le mot de passe sudo dans les commandes ; sortir les clés SSH du conteneur (ou les monter en lecture seule depuis un secret) ; monter
/mnt/dockeren:ro(garder RW uniquement le sous-chemin strictement nécessaire). - Rédiger les secrets avant écriture dans audit.log (regex password/token/bearer/x-bf-vk →
***) +chmod 600+ rotation du fichier. - ROTATION complète des credentials fuités : mot de passe sudo NAS
Best0f, n8n, Portainerbestof, compte Giteabolbol, toutes les clés virtuelles Bifrost, clés SSH Contabo.
P1 : token Gitea repo-scopé (au lieu du mot de passe bolbol) via credential helper, jamais dans l'URL ; rate-limiting/fail2ban sur l'endpoint.
P2 : healthcheck ne teste que /health pas /mcp (connu) ; dérive doc MCP_MAX_OUTPUT_CHARS (README dit 2000, défaut code 8000).
Correctifs APPLIQUÉS le 2026-07-08
- P0 #1 — exposition fermée (nginx).
/etc/nginx/conf.d/http.mcp-nas.conf(backup.bak-audit-20260708).- Piège connecteur claude.ai : il n'accepte PAS de header
Authorizationbrut, uniquement OAuth (Client ID/Secret). Le gate Bearer ne marche donc pas avec le connecteur. → auth par CHEMIN SECRET :location /<secret>/ { proxy_pass http://localhost:3042/; }(le/final strippe le préfixe). Le connecteur utilise l'URLhttps://mcp-nas.nd.i234.me/<secret>/mcp, champs OAuth vides. /mcpdirect reste gaté parAuthorization: Bearer <secret>(pour scripts/curl)./health+ stubs OAuth ouverts.- Le
<secret>(64 hex) N'EST PAS dans ce runbook — il est chez Nabil (mémoire Claude Code + fichier NASlinux-mcp-nas/scratch). Rotation = regénérer + MAJ nginx + MAJ URL connecteur.
- Piège connecteur claude.ai : il n'accepte PAS de header
- P0 #2 — évasion hôte fermée (compose). Mount
docker.sockretiré +security_opt: no-new-privileges+cap_drop: [ALL]dansdocker-compose.yml(backup.bak-audit-20260708). Recréé,healthy,run_commandOK.
Reste (non fait) : P0 #3 (clés SSH Contabo hors conteneur + pwd sudo), P1 #4 (rédaction audit.log + rotation credentials), run non-root (user:1026:100), token Gitea repo-scopé.
Vérif rapide (depuis un poste externe)
# Doit ÉCHOUER (401) — plus de RCE anonyme :
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://mcp-nas.nd.i234.me/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}'
# Doit MARCHER (200) via le chemin secret (URL du connecteur) :
curl -s -X POST https://mcp-nas.nd.i234.me/<secret>/mcp -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -d '{...initialize...}'