back to research
LARP-2026-003open-source11 min read

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:

pythonzeromcp/mcp.py, before the patch
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:

pythonida_mcp/http.py, before the patch
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:

  1. 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.
  2. Script on the page sends POST /mcp to 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.
  3. 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.

the name encodes both answers
7f000001127.0.0.1.c0a8cd80192.168.205.128.rbndr.us
the origin, all the browser compares
httpscheme7f000001.c0a8cd80.rbndr.ushost name13337port
request 1, loads the pagedns answer192.168.205.128connected toattacker VM, port 13337got backthe PoC page and its script
request n, the script's fetch("/mcp")dns answer127.0.0.1connected toida.exe, port 13337got back200 and IDA's tool list, readable
what the listener can checkHost: 7f000001.c0a8cd80.rbndr.us:13337
before #352dispatchedafter #352403 Invalid Host
fig. 1The origin is three values and none of them is an address. Every request the page makes has the same three, so the browser lets the script read whichever machine answered.

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:

textvalues from the PR comment, laid out the way DevTools shows them
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:

jsPoC page, abbreviated and reformatted
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:

pythonzeromcp/mcp.py, after the patch
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)
pythonzeromcp/mcp.py, after the patch (docstring trimmed)
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.0 in the plugin’s settings, _host_header_allowed_for_bind returns True for every Host, 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.

Zx41R
back to research