# SearXNG MCP Server: DNS-resolved Private Hostname SSRF in `web_url_read`

mcp-searxng's web_url_read validates hostname strings without DNS resolution, letting a public-looking hostname resolving to 127.0.0.1 bypass the blocklist and read internal services.

- Payout: Research
- Severity: high
- Program: mcp-searxng
- Found by: EQSTLab, useworld
- Disclosed: 2026-06-19
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-mrvx-jmjw-vggc
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-mrvx-jmjw-vggc
- Tags: area:ai domain:ai impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:nodejs technique:chained technique:source-review vuln:access-control vuln:ssrf

**Target:** MCP server tool web_url_read (src/url-reader.ts)
**Root cause:** assertUrlAllowed() only lexically compares url.hostname against private IP/hostname patterns and never resolves DNS; undiciFetch() resolves the hostname later, after the gate has passed.
**Steps:**
1. Start an internal HTTP service on 127.0.0.1:9796 returning a sentinel.
2. Map a syntactically public hostname to loopback, e.g. `--add-host ssrf-target.internal:127.0.0.1` (or use `127.0.0.1.nip.io`).
3. Send MCP `tools/call` for `web_url_read` with `{"url":"http://127.0.0.1:9796/"}` — blocked by assertUrlAllowed().
4. Send `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"web_url_read","arguments":{"url":"http://ssrf-target.internal:9796/","maxLength":600}}}` — hostname passes string check, undiciFetch resolves to 127.0.0.1 and returns the sentinel.
**How found:** Code review of the URL policy gate: noticed assertUrlAllowed() compares url.hostname as a string while DNS resolution happens later inside undiciFetch(), implying a TOCTOU between check and resolution.
**Exploitation:** Attacker supplies a URL whose hostname is syntactically public but resolves to a private/loopback IP (wildcard DNS like nip.io or attacker-controlled record). In default HTTP mode requireAuth is false, so any unauthenticated client can invoke web_url_read; in STDIO mode the agent must be induced via prompt injection. The tool fetches the internal service and returns its body as markdown, enabling metadata/credential exfiltration and internal port enumeration.
**Escalation:** Chaining to cloud metadata endpoint 169.254.169.254 to steal credentials, then pivoting into cloud APIs.
**Insight:** When auditing SSRF guards in Node/undici-based tools, check whether the blocklist runs on the hostname string before fetch-time DNS resolution; any hostname-only check is bypassable with a DNS record pointing at loopback or link-local metadata IPs.

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

**MCP JSON-RPC SSRF bypass request**

```
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"web_url_read","arguments":{"url":"http://ssrf-target.internal:9796/","maxLength":200}}}
```

**Docker run with --add-host DNS mapping**

```
docker run --rm \
  --add-host ssrf-target.internal:127.0.0.1 \
  -v $(pwd)/vuln-001/poc.py:/poc.py \
  mcp-searxng-ssrf-001 \
  python3 /poc.py
```

