DNS rebinding a web page onto the IDA Pro MCP loopback listener
ida-pro-mcp served MCP over HTTP on 127.0.0.1 and relied on CORS, but a page whose host name rebinds to 127.0.0.1 is same-origin with the listener and CORS never applies. In Chrome on Windows such a page read IDA's tool list and wrote a comment into the open database, and the merged fix rejects any Host that isn't a loopback name.
ida-pro-mcp is an IDA Pro plugin that exposes the open database to MCP clients. It starts an HTTP server inside IDA, by default on 127.0.0.1:13337, with a Streamable HTTP endpoint at /mcp, an SSE endpoint at /sse, and /output/<id>.json for tool results too large to return inline. Behind those endpoints sit the tools, 71 of them according to the package docstring: decompilation, renaming, comments, assembly patches, and py_eval, which runs Python inside IDA.
Binding to loopback keeps other machines out. The browser on the same machine is still a client of that listener, and it runs code from whatever site is open. This post is about PR #352, which made the MCP endpoints check Host and Origin, and about the reproduction the maintainer asked for before he merged it.
The handler before the patch
The HTTP handler is McpHttpRequestHandler in the bundled zeromcp package. Before the patch, the only thing it did with the Origin header was decide whether to send Access-Control-Allow-Origin:
def send_cors_headers(self, *, preflight = False):
origin = self.headers.get("Origin", "")
if not origin:
return
def is_allowed():
allowed = self.mcp_server.cors_allowed_origins
if allowed is None:
return False
if callable(allowed):
return allowed(origin)
if isinstance(allowed, str):
allowed = [allowed]
assert isinstance(allowed, list)
return "*" in allowed or origin in allowed
if not is_allowed():
return
self.send_header("Access-Control-Allow-Origin", origin)do_POST read the body and handed it to the JSON-RPC dispatcher with no condition on any header. The plugin’s config page offers three policies (unrestricted, local, direct), and the default, local, maps to cors_localhost, which accepts origins whose host name is localhost, 127.0.0.1 or ::1 on any port. A request from any other origin was parsed and executed all the same. Its response simply went out without the header.
That is what passive means here. CORS is a rule the browser enforces when a script reads a response from another origin, and the server takes part only by granting permission with a header. Leaving the header off refuses cross-origin reads. It says nothing about same-origin requests, because for those the browser never consults the server at all.
The project knew about DNS rebinding. The config page, served by the same plugin, already checked both headers, with docstrings that name the attack:
def _check_host(self) -> bool:
"""
Prevents DNS rebinding attacks where an attacker's domain (e.g., evil.com)
resolves to 127.0.0.1, allowing their page to read localhost resources.
"""
host = self.headers.get("Host")
port = self.server_port
if host not in (f"127.0.0.1:{port}", f"localhost:{port}"):
self.send_error(403, "Invalid Host")
return False
return True_check_host ran for GET /config.html and a matching _check_origin ran for POST /config. Nothing like them ran for /mcp, /sse or /output/*. The page that changes the plugin’s settings was protected against rebinding, and the endpoint that drives IDA had CORS and nothing else.
DNS rebinding
An origin is a scheme, a host name and a port. The browser compares those three values and nothing else, and an origin carries no record of the address its name resolved to when the page loaded. DNS rebinding uses exactly that: the attacker serves a page from a name they control, then lets the name resolve somewhere else.
I used rbndr, whose host names encode two IPv4 addresses in hex. Its README:
The server responds to queries by randomly selecting one of the addresses specified in the hostname and returning it as the answer with a very low ttl.
The name for this test was 7f000001.c0a8cd80.rbndr.us. 7f000001 is 127.0.0.1 and c0a8cd80 is 192.168.205.128, the attacker VM. Each fresh lookup returns one of the two at random, and the low TTL tells resolvers to keep the answer only briefly. The page keeps sending requests until one lands on the other address:
- The victim opens
http://7f000001.c0a8cd80.rbndr.us:13337/. That lookup has to return the VM’s address, and the browser loads the PoC page from the VM. - Script on the page sends
POST /mcpto a relative URL, which belongs to the page’s own origin. While the name still resolves to the VM, the request goes to the VM, and the script ignores the reply and tries again. - When a later lookup returns
127.0.0.1, the next connection goes to IDA’s listener. The origin hasn’t changed, so the browser treats the request as same-origin and lets the script read the response.
The port matters as much as the name. The page has to be served on the same port IDA listens on, or /mcp would be a different origin and the CORS rule would apply again. That is why the VM served the page on 13337.
On IDA’s side, the request carries Host: 7f000001.c0a8cd80.rbndr.us:13337 and an Origin with the same name. Before the patch neither header was looked at on /mcp.
Reproduction
I opened the PR on 2026-04-07 with the patch, unit tests and a rationale written from the code. The next day the maintainer, mrexodia, replied:
I need to see a real reproduction of this ‘vulnerability’ before fixing it.
That was a reasonable thing to ask. An argument about browser behavior is easy to get wrong, and the rationale alone gave him nothing to check. So I set it up in a real browser on Windows:
| part | setup |
|---|---|
| victim | IDA with the MCP plugin on 127.0.0.1:13337 on the Windows host; ida.exe was the only listener on that port |
| attacker | a separate Windows VM at 192.168.205.128, serving the PoC page on port 13337 |
| host name | 7f000001.c0a8cd80.rbndr.us |
| browser | Chrome on the Windows host |
The first run called tools/list, which reads and changes nothing. DevTools showed this for the request that landed:
Request URL: http://7f000001.c0a8cd80.rbndr.us:13337/mcp?cb=...
Request Method: POST
Status Code: 200 OK
Remote Address: 127.0.0.1:13337
Host: 7f000001.c0a8cd80.rbndr.us:13337
Origin: http://7f000001.c0a8cd80.rbndr.us:13337
Referer: http://7f000001.c0a8cd80.rbndr.us:13337/The URL and headers carry the rebinding name, and the remote address is loopback. The response body was IDA’s full tools/list JSON, 63,668 bytes, and the page’s script had it.
Then a write. The page that confirmed it is in the evidence I posted to the thread; its core:
const payload = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "append_comments",
arguments: {
items: { addr: "0x3420", comment: "dns-rebinding-poc: POC CONFIRMED" }
}
}
});
async function attempt() {
if (done) return;
tries++;
try {
const res = await fetch("/mcp?cb=" + Date.now() + "-" + tries, {
method: "POST",
mode: "same-origin",
cache: "no-store",
headers: { "Content-Type": "application/json" },
body: payload
});
const text = await res.text();
if (res.ok && text.includes('"appended": true')) { done = true; return; }
} catch (e) {}
setTimeout(attempt, 1500);
}A few details in there are deliberate. mode: "same-origin" makes the browser fail the request outright if it would be cross-origin. It went through, because the URL has the page’s own scheme, host name and port, and that is all the browser checks. The cb= parameter and cache: "no-store" keep any cache from answering in place of IDA. The 1.5 second retry absorbs the random DNS answers. There is no initialize call first: zeromcp’s require_streamable_http_session defaults to False, so /mcp accepted a bare tools/call with no session. The application/json content type would have forced a CORS preflight on a cross-origin request. On a same-origin one there is no preflight.
append_comments returned "appended": true, and the comment showed up at 0x3420 in the pseudocode view. I posted the DevTools capture, the IDA screenshot and the PoC page on 2026-04-09. On 2026-04-10 mrexodia wrote:
Thanks for your diligence, I hope you understand my skepticism on issues like this. LLMs love to exaggerate security issues as you probably know
and merged the PR three seconds later. I do understand it. A security rationale costs little to produce. A capture with a loopback remote address and a comment sitting in the decompiler view takes a real browser and a real IDA, and it was what the thread needed.
The patch
The patch adds three helpers and one gate:
def _is_loopback_host(host: str) -> bool:
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return host.lower() == "localhost"
def _host_header_allowed_for_bind(bound_host: str, host_header: str | None) -> bool:
"""Reject DNS-rebinding style Host headers when the server is loopback-bound."""
if host_header is None:
return True
host_name = _parse_host_header(host_header)
if host_name is None:
return False
if not _is_loopback_host(bound_host):
return True
return _is_loopback_host(host_name) def _check_api_request(self) -> bool:
bound_host = self.server.server_address[0]
if not _host_header_allowed_for_bind(bound_host, self.headers.get("Host")):
self.send_error(403, "Invalid Host")
return False
origin = self.headers.get("Origin", "")
if origin and not _origin_allowed_by_policy(
self.mcp_server.cors_allowed_origins, origin
):
self.send_error(403, "Invalid Origin")
return False
return True_check_api_request runs first in do_GET, do_POST and do_OPTIONS. The plugin’s own handler serves /output/* before it calls the base do_GET, so http.py got its own call there. _parse_host_header drops the port and handles bracketed IPv6 such as [::1]:13337. _is_loopback_host accepts any 127.0.0.0/8 address, ::1 and localhost. _origin_allowed_by_policy is the old is_allowed closure moved to module level, so the CORS header and the new rejection use the same policy.
The Host check is the one that stops rebinding, for three reasons.
The browser fills in Host from the URL, and page script can’t change it: Host is on the Fetch standard’s list of forbidden request headers. After rebinding, the TCP connection goes to 127.0.0.1 while Host still carries the rebinding name. A legitimate client of a loopback-bound server reaches it through a loopback address or localhost, so any other name in Host means some other name was made to resolve to it. That is the attack, and the server can see it in every request.
The Host check also works the same under every CORS setting. Under the unrestricted policy the Origin check passes everything, and the Host check still rejects the rebinding name.
And Host is on every request, GETs included. The Fetch standard’s “append a request Origin header” step adds Origin only to CORS requests and to methods other than GET and HEAD, so a same-origin GET gets none. After rebinding, GET /sse and GET /output/<id>.json are same-origin GETs, so a check on Origin alone would let them through. My captured request had an Origin only because it was a POST.
The Origin check covers a different case: a page on some other origin sending requests to the real loopback name. Those carry Host: 127.0.0.1:13337, which passes, and an Origin the policy doesn’t list, so they are now refused before dispatch. Before the patch the handler dispatched any POST body that reached it, whatever its Origin, and it never looked at Content-Type. A cross-origin POST with application/json would have been stopped at the preflight, since do_OPTIONS answered without Access-Control-Allow-Origin for unlisted origins. A POST with a CORS-safelisted content type such as text/plain skips the preflight, and with that body the old handler would have dispatched it without the page ever seeing the reply. I didn’t test that case. The PR and the reproduction are about rebinding.
Requests with no Origin are still allowed because the clients this server exists for, MCP clients and curl, are not browsers and don’t send one. A browser always attaches Origin to a POST, and script can’t forge it, since it is a forbidden header too. So a POST without Origin comes from a non-browser client, and the GET case, where a browser sends none either, is covered by Host. The PR’s tests pin both behaviors (test_check_api_request_allows_direct_clients_without_origin, test_check_api_request_rejects_rebinding_host). Under the default local policy, the outcomes are:
| request | Host | Origin | after the patch |
|---|---|---|---|
| MCP client or curl | 127.0.0.1:13337 |
none | dispatched |
page served from http://127.0.0.1:3000 |
127.0.0.1:13337 |
http://127.0.0.1:3000 |
dispatched |
rebinding page, POST /mcp |
7f000001.c0a8cd80.rbndr.us:13337 |
the rebinding origin | 403 Invalid Host |
rebinding page, GET /sse |
7f000001.c0a8cd80.rbndr.us:13337 |
none | 403 Invalid Host |
page on another origin, POST to 127.0.0.1 |
127.0.0.1:13337 |
that origin | 403 Invalid Origin |
Under the direct policy, any request that carries an Origin gets 403, which matches the config page’s description of it (“Browser-based requests are blocked”).
Limitations
Shown, in Chrome on Windows: a page served by another machine read IDA’s tools/list through a rebinding name, and made a state-changing tools/call that wrote a comment into the open database, visible in the decompiler. Neither needed a session.
The rest is what the code allows in principle, and I didn’t demonstrate it. Once the request is same-origin, the page is an MCP client like any other, and the transport had no idea which tools a browser should reach. In the code at the merge base, handle_enabled_tools defaults every tool to enabled, including py_eval, which the config page flags as unsafe and which executes Python inside IDA’s process. The ?ext= query parameter that unlocks extension tool groups, such as the debugger tools under ?ext=dbg, is read from the request URL, and the page writes that URL. Results too large to return inline are cached and served from /output/<id>.json, and the id comes back in the tool response the page can read. I didn’t call py_eval or any debugger tool. Code execution through this path was not demonstrated.
Limits of the reproduction:
- The attacker page was served from a VM on the same LAN. I didn’t test a page hosted at a public address. zeromcp already answers Chrome’s Private Network Access preflight header (
Access-Control-Request-Private-Network) for allowed origins, so Chrome has its own checks for pages reaching private and loopback addresses, and I didn’t measure whether they would stop the same page from a public host. - One browser, Chrome. I didn’t try Firefox or Safari.
- One IDA instance on the default port. When 13337 is taken, the plugin takes the next free port up to 13436, and each port is a separate origin the page would have to be served from.
- The Host check only applies when the server is bound to a loopback address. If the user changes the bind address to something like
0.0.0.0in the plugin’s settings,_host_header_allowed_for_bindreturnsTruefor everyHost, and only the Origin check remains, which doesn’t see same-origin GETs. At that point the listener is reachable from the network directly anyway, and the docstring says the check is meant for the loopback case.
Upstream
PR #352, opened 2026-04-07, merged 2026-04-10 by mrexodia (Duncan Ogilvie) as merge commit af70626. It changes src/ida_pro_mcp/ida_mcp/zeromcp/mcp.py and src/ida_pro_mcp/ida_mcp/http.py and adds tests/test_browser_transport_guards.py (207 lines added, 13 removed). The PR doesn’t mention a backport.