nas-runbooks/common/mcp-nas-security-audit.md

9.5 KiB
Raw Blame History

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 (MàJ 2026-07-09) : P0 fermés (exposition anonyme, docker.sock, clés SSH Contabo) + durcissement non-disruptif manche 3 fait (rédaction audit.log, scrub historique, token Gitea scopé). Reste : rotation des credentials fuités (disruptif, à séquencer) + run non-root. Détail en bas.

Verdict

Compromission totale possible par un attaquant anonyme sur Internet. Chaîne vérifiée depuis un poste externe, sans aucun credential :

  1. Endpoint MCP sans authentification. POST https://mcp-nas.nd.i234.me/mcpinitialize répond, tools/list renvoie run_command. Le bloc nginx (deploy/nginx-reverse-proxy.conf) fait proxy_pass http://localhost:3042 sans auth_basic, sans allowlist IP, sans vérif de token. Les routes OAuth du serveur (/registerclient_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.
  2. Évasion vers root hôte via docker.sock. Le conteneur tourne en root (uid=0) avec /var/run/docker.sock monté (RW). L'API Docker est joignable (curl --unix-socket /var/run/docker.sock http://localhost/version OK). → créer un conteneur privilégié montant / = root sur le NAS. Le binaire docker n'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.
  3. Pivot latéral. Clés SSH privées /root/.ssh/claude_oversight_contabo{,_v2,_v3} dans le conteneur → accès VPS Contabo.
  4. 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/docker monté RW → tous les .env de l'infra (43 conteneurs) lisibles ET modifiables par la RCE. Doit être :ro.
  • Denylist (DENY_ANCHORED/DENY_GLOBAL dans linux_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 bolbol complet dans l'URL de clone (→ audit.log + argv visible en ps), 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) :

  1. Couper l'exposition publique : retirer la règle nginx mcp-nas.nd.i234.me OU la restreindre au réseau WireGuard uniquement (pas de proxy_pass depuis le WAN). Si un accès distant reste nécessaire : auth_request nginx ou un secret partagé obligatoire (header) + implémenter une vraie validation bearer côté serveur.
  2. Supprimer le mount docker.sock du docker-compose.yml (jamais utilisé par le code).
  3. Run non-root : user: "1026:100" + cap_drop: [ALL] + security_opt: no-new-privileges.
  4. 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/docker en :ro (garder RW uniquement le sous-chemin strictement nécessaire).
  5. Rédiger les secrets avant écriture dans audit.log (regex password/token/bearer/x-bf-vk → ***) + chmod 600 + rotation du fichier.
  6. ROTATION complète des credentials fuités : mot de passe sudo NAS Best0f, n8n, Portainer bestof, compte Gitea bolbol, 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 Authorization brut, 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'URL https://mcp-nas.nd.i234.me/<secret>/mcp, champs OAuth vides.
    • /mcp direct reste gaté par Authorization: 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 NAS linux-mcp-nas/ scratch). Rotation = regénérer + MAJ nginx + MAJ URL connecteur.
  • P0 #2 — évasion hôte fermée (compose). Mount docker.sock retiré + security_opt: no-new-privileges + cap_drop: [ALL] dans docker-compose.yml (backup .bak-audit-20260708). Recréé, healthy, run_command OK.

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é.

Manche 3 (2026-07-08) — durcissement non-disruptif FAIT et vérifié

  • Rédaction audit.log DÉPLOYÉE. _redact / _SECRET_PATTERNS ajoutés dans linux_mcp_server.py : masquage à l'écriture de user:pass@ (URL), Bearer/token <val>, x-bf-vk, sshpass -p, echo '…' | sudo, <<< "…" vers sudo, curl -u user:pass, NOM…PASS/PWD/SECRET/TOKEN/KEY=…, JSON "password":…. Testé en live (curl -u bob:***, token *** masqués). N'affecte PAS l'exécution (redaction sur la copie journalisée uniquement). → clôt P0 #5 / P1 #4 (volet rédaction).
  • audit.log HISTORIQUE scrubbé à 0. Après scrub : famille 2L2u519w = 0, URLs user:pass@ non masquées = 0, x-bf-vk / Bearer = 0. Méthode : one-off python (mêmes patterns + filet littéral via env SCRUB_LITERAL_RE), remplacement d'inode puis recréation du conteneur pour rouvrir le fd d'écriture. Backup pré-scrub SUPPRIMÉ (il contenait 278 mots de passe en clair). Temp .claude-tmp nettoyés. Fichier en chmod 600 root:root.
  • Token Gitea repo-scopé créé (mcp-nas-deploy, scope write:repository) → hermes-platform/.env sous GITEA_DEPLOY_TOKEN (gitignoré). Vérifié : clone OK, API admin = 403 (bien limité). Remplace le mot de passe bolbol complet dans les URLs de clone/API. → clôt P1 (token scopé).
  • Versionné via API Gitea : linux_mcp_server.py (commit c4f3b2d) + docker-compose.yml (4094366) sur bolbol/linux-mcp-nas main.

🔑 Astuce git/SMB réutilisable (découverte)

git sur le share SMB /Volumes/docker hang (timeouts ~2 min : le .git est sur SMB) et git est absent de l'hôte NAS. → NE PAS faire git push depuis le Mac sur ce share. À la place, pousser via l'API HTTP Gitea contents (aucun git requis, curl vers gitea.bolbol.tn répond vite) :

TOK=$GITEA_DEPLOY_TOKEN   # scope write:repository suffit pour contents API
# 1) récupérer le sha courant du fichier
SHA=$(curl -s -H "Authorization: token $TOK" \
  "https://gitea.bolbol.tn/api/v1/repos/OWNER/REPO/contents/PATH?ref=main" | jq -r .sha)
# 2) PUT le nouveau contenu (base64) avec le sha
curl -s -X PUT -H "Authorization: token $TOK" -H "Content-Type: application/json" \
  "https://gitea.bolbol.tn/api/v1/repos/OWNER/REPO/contents/PATH" \
  -d "{\"message\":\"msg\",\"content\":\"$(base64 -i FICHIER)\",\"sha\":\"$SHA\",\"branch\":\"main\"}"

Marche aussi depuis le conteneur mcp-nas (pas de git dedans). Pour créer un fichier neuf : même PUT sans sha.

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