# SiYuan Agent Tools SSRF via DNS-Rebinding TOCTOU (Bypass of CheckHostSSRF)

SiYuan agent tools http_request/web_fetch check SSRF only at parse time, so a rebinding domain passes the guard then dials 127.0.0.1 or cloud metadata.

- Payout: Research
- Severity: high
- Program: SiYuan
- Found by: joysinleung
- Disclosed: 2026-10-02
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-x8gv-g2g3-65fj
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-x8gv-g2g3-65fj
- Tags: area:ai domain:ai domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:go technique:incomplete-fix technique:source-review vuln:access-control vuln:ssrf

**Target:** Go kernel AI agent HTTP tools (http_request / web_fetch) and outbound HTTP client
**Root cause:** CheckHostSSRF resolves DNS once at guard time and checks for private IPs, but the connection re-resolves independently via the default net.Dialer with no connect-time private-IP Control hook, so guard and dial can see different answers.
**Steps:**
1. Configure attacker authoritative DNS for `rebind.local`: odd query (guard) returns `203.0.113.1`, even query (dial) returns `127.0.0.1`.
2. Run a loopback victim service returning `F9_REBIND_PROOF=reached-internal-only-service-via-TOCTOU`.
3. Invoke the real agent tool path: `util.HTTPRequest("GET", "http://rebind.local:15353/secret", ...)` and `util.WebFetch("http://rebind.local:15353/secret", ...)`.
4. Observe the internal-only proof body returned; negative control `CheckHostSSRF("127.0.0.1")` blocks, positive control `pub.local` passes.
**How found:** Noticed the prior SSRF fix (GHSA-rg26-cg95-gq6p) added both parse-time CheckHostSSRF and connect-time SSRFSafeDialer, but the agent tool paths only got the parse-time half; compared against the correctly-defended generatedImageDialer in openai.go which mounts a connect-time Control hook, then built a process-level DNS hijack to prove guard and dial diverge.
**Exploitation:** Attacker prompt-injects the SiYuan AI agent/MCP client into fetching an attacker-controlled rebinding domain; the guard resolution returns a public IP, the connect resolution returns 169.254.169.254 or an internal host, and the response body (IMDS IAM credentials, internal service data) is returned to the agent/attacker. Works with SafeMode on or off.
**Escalation:** Pivot from SSRF to cloud instance metadata (IMDSv1 IAM temporary credentials) and same-host unauthenticated internal services, since the kernel often runs with cloud-instance privileges.
**Insight:** When auditing an SSRF fix, check whether the guard-time resolver and the dialer are the same resolution: grep for LookupIP/isPrivateIP checks and confirm the transport's DialContext mounts a Control hook, and compare sibling code paths (e.g. image fetchers) that already do it right to find the omitted one.

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

**Agent tool calls hitting rebinding domain**

```
util.HTTPRequest("GET", "http://rebind.local:15353/secret", ...)
util.WebFetch("http://rebind.local:15353/secret", ...)
```

