108 lines
14 KiB
Markdown
108 lines
14 KiB
Markdown
# 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 manche 4) : **P0 fermés** + durcissement manche 3 + **ROTATION des credentials fuités faite** (Portainer, Gitea bolbol, n8n, Baserow, 3 logins web Hermes, NyoraNotes, DSM/sudo, tokens Gitea). **Reste** : SMTP OVH (externe), nettoyage docs/skills agents restants, re-scoper 1 token Gitea, 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/mcp` → `initialize` 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 (`/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**.
|
||
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) :
|
||
|
||
```sh
|
||
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)
|
||
```sh
|
||
# 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...}'
|
||
```
|
||
|
||
## Manche 4 (2026-07-09) — ROTATION des credentials fuités (piloté service par service)
|
||
|
||
Le préfixe réutilisé couvrait **~11 familles** (pas 5) + fuite dans **context-hub/.env** (2e magasin central, `root:root 600` → invisible au `grep` en Best0f, lisible via SMB Mac ou `sudo`) et dans les **docs/skills d'agents Hermes**. **Piège grep réutilisable** : un `find`/`grep` lancé en Best0f sur le NAS **rate les fichiers root-600** ; scanner depuis le Mac (SMB) OU en `sudo`.
|
||
|
||
**Rotés + vérifiés (services healthy)** :
|
||
- **Portainer** : API `PUT /api/users/1` avec champ **`newPassword`** (pas `password` → 400). Auth `POST /api/auth`.
|
||
- **Gitea bolbol** : `docker exec -u git gitea gitea admin user change-password -u bolbol -p <new>` (en 1.21, **PAS** de flag `--must-change-password`).
|
||
- **n8n / Baserow** : compte **owner en base** (pas de basic-auth env) → changés en UI ; les `*_PASSWORD` des `.env` ne sont que des copies de référence à répercuter.
|
||
- **NyoraNotes** : `authenticate()` fait un `secrets.compare_digest` **plaintext** sur `NYORA_PASS_{TT,NYORA,PERSO}` (env) ; les agents utilisent le **Bearer token**, pas le mdp → rotation = MAJ `.env` + restart.
|
||
- **3 logins web Hermes** : var interne `HERMES_PASSWORD` = `${HERMES_<i>_PASSWORD}` via le **compose RACINE** `hermes-platform/docker-compose.yml`. **Piège** : les conteneurs n'ont **aucun label compose** → `compose up --force-recreate <workspace>` **cascade sur l'agent** (a mis agent-tt down). **Méthode fiable = `docker stop <c> && docker rm <c>` puis `docker compose up -d --no-deps <service>`** depuis le racine.
|
||
- **DSM/sudo Best0f** : `sudo /usr/syno/sbin/synouser --setpw Best0f <new>` (**chemin complet** requis, sinon `command not found`). `ssh` étant **par clé**, aucun lockout. Mdp hardcodé dans ~23 fichiers (dsm_auth.py liste de spray, ssh_dsm.py, dsm_check_*.py, context-hub/.env, docs) → tous mis à jour.
|
||
- **Tokens Gitea** : 13 tokens bolbol (plusieurs `all`/`write:admin`). `GITEA_TOKEN_NYORA` fuité (`10ccc28dc`) roté → token scopé neuf ; **7 tokens révoqués** (`DELETE /api/v1/users/bolbol/tokens/<id>`). Scripts deploy migrés vers `GITEA_DEPLOY_TOKEN`.
|
||
- **Bifrost VKs** (Nabil-Key + Yesmine-Key) : rotation dans `config.db` (`UPDATE governance_virtual_keys SET value=…` + `allow_all_keys=1`) + **restart bifrost**. **Piège port** : l'API bifrost est en **3085** (8080 interne), **3086 = proxy openresty** (renvoie 401 trompeur) → tester sur 3085. **Piège scan** : la VK vit aussi dans `redaction-pro/nginx.conf` (`.conf` raté par un scan `.env/.json/.yml`) et dans **9 workflows n8n en base** (`UPDATE workflow_entity SET nodes=replace(nodes::text,old,new)::jsonb` sur postgres n8n-DB `n8nuser`/`n8n`, puis restart n8n pour recharger le cache). Vérif : nouvelle VK → 200, ancienne → 401 `virtual_key_not_found`. Yesmine-Key = aucun consommateur infra (clé perso).
|
||
- **Scrub** : 673 secrets masqués dans 105 fichiers de log (sessions/cron/claude_contexte/data-logs/anciens .env) via `sudo python3`.
|
||
|
||
- **SMTP** : migration OVH `postmaster@bolbol.tn` → **Infomaniak `nabil.derouiche@ik.me`** (mail.infomaniak.com, 465 SSL / 587 STARTTLS) pour baserow + portainer/311. ⚠️ Infomaniak SMTP externe **exige un mot de passe d'application** (le mdp compte/webmail est rejeté `535`). Baserow : `EMAIL_SMTP_USE_SSL` et `USE_TLS` **mutuellement exclusifs** (Django ImproperlyConfigured → crashloop) → n'en garder qu'un.
|
||
|
||
### 🚨 INCIDENT — leçon critique : jamais de remplacement texte sur des `.db`/binaires
|
||
Un `open(f).read().replace(old,new)` en masse sur `hermes-platform` a corrompu les **3 `state.db` SQLite** des agents (décalage d'octets) → **perte de l'historique conversation** (tables sessions/messages), aucun backup. Savoir des agents intact (fichiers). Récup : stop agent → `mv state.db*` → start (recrée vierge) → **supprimer le lock `gateway-locks/telegram-bot-token-*.lock`** sinon le bot Telegram ne poll plus. **RÈGLE : tout script de scrub/remplacement DOIT exclure `*.db*`/binaires et se scoper aux extensions texte.**
|
||
|
||
**Nouvelles valeurs** : PAS dans ce runbook — remises à Nabil (Bitwarden). **Reste** : SMTP OVH (externe), SSH_PASS_NYORA, re-scoper token `nyora-notes-claude-code`, nettoyer docs/skills restants, run non-root.
|