Summary
SiYuan Agent Tools SSRF via DNS-Rebinding TOCTOU (Bypass of CheckHostSSRF)
Security Advisory, SiYuan Agent Tools SSRF via DNS-Rebinding TOCTOU (Bypass of CheckHostSSRF)
| Field | Value |
|---|---|
| Disclosed by | joysinleung ([email protected]) |
| Report date | 2026-08-13 |
| Product | SiYuan (思源笔记), siyuan-note/siyuan |
| Go module | github.com/siyuan-note/siyuan/kernel |
| Affected versions | <= 3.8.0 (latest release at report time; dynamically verified on v3.8.0) |
| Patched versions | 3.8.1 |
| Component | kernel/util/httprequest.go (CheckHostSSRF), kernel/mcp/tools/http_request.go, kernel/util/webfetch.go, kernel/util/net.go (SSRFSafeDialer) |
| Relationship to prior advisory | Incomplete-fix variant of GHSA-rg26-cg95-gq6p (SSRF main-vector remediation). See §Relationship. |
| EPSS (exploitation probability) | Low–Moderate. Requires the attacker to influence an AI Agent / MCP client into fetching an attacker-controlled domain (prompt-injection scenario documented by the tool itself). |
| KEV (CISA Known Exploited) | No (not listed in CISA KEV at report time). |
| Default-config reachable | Yes, exploitable under both SafeMode on and off; only requires the agent http_request / web_fetch tool to be reachable (default AI tooling). |
SiYuan's AI Agent tools http_request (util.HTTPRequest) and web_fetch (util.WebFetch) are the only SSRF gate for outbound requests from the kernel. That gate is CheckHostSSRF, which performs a single DNS resolution at guard time and checks whether any returned IP is private/loopback/link-local. The actual connection, however, performs a second, independent DNS resolution through the default net.Dialer, and no connect-time private-IP check is mounted on this path.
Because the two resolutions are not pinned to the same result, an attacker-controlled domain can answer the guard-resolution with a public IP (passing CheckHostSSRF) and the connect-resolution with a private/loopback/metadata IP (e.g. 169.254.169.254). This is a classic DNS-rebinding TOCTOU that bypasses the SSRF defense entirely. It reaches cloud instance metadata and internal services that the guard was specifically added to block.
Relationship to Prior Advisories
- GHSA-rg26-cg95-gq6p remediated the SSRF main vector by adding
CheckHostSSRF(parse-time) andSSRFSafeDialer(connect-time). However, the agent tool paths (http_request/web_fetch) only received the parse-time half: they callCheckHostSSRFbut then connect viahttpclient.NewBrowserRequest(), whose transport does not mountSSRFSafeDialer. Even whereSSRFSafeDialeris mounted, it only blocks private IPs whenSafeMode == true(default false), so it would not help here regardless. The sibling pathopenai.go:generatedImageDialerdoes mount a connect-timeControlhook (blocking private/loopback/link-local/100.64/198.18), proving the project knows the technique, the agent path is a clear omission. We report this as an incomplete-fix variant with a concrete v3.8.0 reproduction. - CVE-2026-32110 (GHSA-56cv-c5p2-j2wg) covered the older
forwardProxyendpoint and is unrelated to the agent tool path.
Affected Version
Dynamically verified on v3.8.0 (tag v3.8.0, commit 251596fc0). A process-level DNS hijack was installed so that the attacker domain rebind.local returns a public IP (203.0.113.1) on odd (guard) resolutions and 127.0.0.1 on even (connect) resolutions; a loopback "victim" service returned F9_REBIND_PROOF=reached-internal-only-service-via-TOCTOU. Both util.HTTPRequest("GET", "http://rebind.local:<port>/secret") and util.WebFetch(...) returned the internal-only proof body, while CheckHostSSRF("127.0.0.1") directly blocked and the legit public domain pub.local passed, confirming the guard passed but the connection hit the internal address. All versions <= 3.8.0 are affected.
Component
kernel/util/httprequest.go:42CheckHostSSRF, singlenet.LookupIP+isPrivateIPat guard time only.kernel/mcp/tools/http_request.go:90→util.HTTPRequest;kernel/util/webfetch.go:50→CheckHostSSRF+httpclient.NewBrowserRequest().github.com/siyuan-note/httpclientclient.go:92NewBrowserRequestuses the defaulthttp.Transport→ defaultnet.Dialerwith noSSRFSafeDialer.kernel/util/net.go:151SSRFSafeDialerexists but is (a) not mounted on the agent path and (b) only active underSafeMode.- Correctly-defended sibling:
kernel/util/openai.go:829generatedImageDialermounts a connect-timeControlhook.
Attack Vector
Network + AI Agent. The url of http_request / web_fetch is fully controlled by the agent / MCP client. In SiYuan's documented red-team scenario ("prompt-inject the agent → induce it to visit an attacker domain"), the attacker needs only a rebinding domain (own authoritative DNS, first answer public, later 169.254.169.254 / internal). No auth, no special position beyond prompting the agent. Real targets: cloud metadata 169.254.169.254 (IMDSv1 IAM creds) and same-host/internal unauthenticated services.
Proof of Concept
# Attacker authoritative DNS for rebind.local:
# odd query (guard) -> 203.0.113.1 (public, passes CheckHostSSRF)
# even query (dial) -> 127.0.0.1 (loopback internal victim)
#
# Directly invoke the real agent-tool functions (v3.8.0 code path):
util.HTTPRequest("GET", "http://rebind.local:15353/secret", ...)
util.WebFetch("http://rebind.local:15353/secret", ...)
# Result (evidence):
# body = "F9_REBIND_PROOF=reached-internal-only-service-via-TOCTOU"
# Negative control: CheckHostSSRF("127.0.0.1") -> blocked.
# Positive control: CheckHostSSRF("pub.local") -> 203.0.113.1, allowed.
The loopback victim is a faithful stand-in for 169.254.169.254 / any internal address: the guard allowed a public IP while the connection reached a private one.
Scope
Reachable whenever the agent http_request / web_fetch tool is usable (default AI tooling). Independent of Publish/auth configuration. Not gated by SafeMode.
Appendix: Suggested Patch (F9)
diff --git a/kernel/util/httprequest.go b/kernel/util/httprequest.go
index aaa..bbb 100644
--- a/kernel/util/httprequest.go
+++ b/kernel/util/httprequest.go
@@ -40,6 +40,18 @@ func CheckHostSSRF(host string) error {
return nil
}
+// SSRFSafeClient returns an *http.Client whose transport enforces the
+// private/loopback/link-local IP block at CONNECT time (independent of SafeMode),
+// closing the DNS-rebinding TOCTOU left by parse-time-only CheckHostSSRF.
+func SSRFSafeClient(timeout time.Duration) *http.Client {
+ return &http.Client{
+ Timeout: timeout,
+ Transport: &http.Transport{
+ DialContext: util.SSRFSafeDialer(timeout).DialContext,
+ },
+ }
+}
+
diff --git a/kernel/mcp/tools/http_request.go b/kernel/mcp/tools/http_request.go
index ccc..ddd 100644
--- a/kernel/mcp/tools/http_request.go
+++ b/kernel/mcp/tools/http_request.go
@@ -90,7 +90,7 @@ func httpRequest(args map[string]any) (CallToolResult, error) {
if serr := util.CheckHostSSRF(u.Hostname()); serr != nil {
return CallToolResult{}, serr
}
- resp, err := httpclient.NewBrowserRequest().Get(rawURL)
+ resp, err := util.SSRFSafeClient(30 * time.Second).Get(rawURL)
...
}
diff --git a/kernel/util/webfetch.go b/kernel/util/webfetch.go
index eee..fff 100644
--- a/kernel/util/webfetch.go
+++ b/kernel/util/webfetch.go
@@ -50,7 +50,7 @@ func WebFetch(rawURL string, ...) (string, error) {
if serr := util.CheckHostSSRF(u.Hostname()); serr != nil {
return "", serr
}
- resp, err := httpclient.NewBrowserRequest().Get(rawURL)
+ resp, err := util.SSRFSafeClient(30 * time.Second).Get(rawURL)
...
}
Impact
Confidentiality breach via SSRF: an attacker who can steer the agent can read cloud instance metadata (IAM temporary credentials), internal service responses, and anything reachable from the SiYuan kernel's network position. Scope is changed (S:C) because the kernel often runs with cloud-instance privileges. Exploitable under default config (SafeMode on or off).
Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside. Typical impact: access to internal metadata services, internal APIs, or cloud credentials.
GHSA-X8GV-G2G3-65FJ has a CVSS score of 8.2 (High). The vector is network-reachable, no privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (0.0.0-20260813142806-dd2778b70d02); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
- Connect-time enforcement (preferred): mount
SSRFSafeDialer(or a dedicated always-on private-IPControlhook) on the transport used byhttp_request/web_fetch, independent ofSafeMode, matching the already-correctgeneratedImageDialer. - Pin resolution: after
CheckHostSSRFpasses, reuse the same resolved IP for the connection (or short-TTL cache) so guard and dial cannot diverge. - Minimum change: replace
httpclient.NewBrowserRequest()inhttp_request.go/webfetch.gowith a custom client whoseDialContextisutil.SSRFSafeDialer(timeout).DialContext(notSafeMode-gated).
Note: patch authored against v3.8.0 source; regression-tested in the researcher's environment for the PoC path but not compiled into a full SiYuan release build. Provided for the maintainer to validate in CI.
Frequently Asked Questions
- What is GHSA-X8GV-G2G3-65FJ? GHSA-X8GV-G2G3-65FJ is a high-severity server-side request forgery (SSRF) vulnerability in github.com/siyuan-note/siyuan/kernel (go), affecting versions < 0.0.0-20260813142806-dd2778b70d02. It is fixed in 0.0.0-20260813142806-dd2778b70d02. Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside.
- How severe is GHSA-X8GV-G2G3-65FJ? GHSA-X8GV-G2G3-65FJ has a CVSS score of 8.2 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of github.com/siyuan-note/siyuan/kernel are affected by GHSA-X8GV-G2G3-65FJ? github.com/siyuan-note/siyuan/kernel (go) versions < 0.0.0-20260813142806-dd2778b70d02 is affected.
- Is there a fix for GHSA-X8GV-G2G3-65FJ? Yes. GHSA-X8GV-G2G3-65FJ is fixed in 0.0.0-20260813142806-dd2778b70d02. Upgrade to this version or later.
- Is GHSA-X8GV-G2G3-65FJ exploitable, and should I be worried? Whether GHSA-X8GV-G2G3-65FJ is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether GHSA-X8GV-G2G3-65FJ is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix GHSA-X8GV-G2G3-65FJ? Upgrade
github.com/siyuan-note/siyuan/kernelto 0.0.0-20260813142806-dd2778b70d02 or later.