Skip to content

Incomplete fix for GHSA-qq8m-8p8v-x4xg: IPv6 transition addresses (NAT64/6to4/Teredo) still bypass both SSRF guards

High
88250 published GHSA-rg26-cg95-gq6p Aug 10, 2026

Package

No package listed

Affected versions

<=v3.7.3

Patched versions

v3.8.0

Description

Incomplete fix for GHSA-qq8m-8p8v-x4xg: IPv6 transition addresses (NAT64/6to4/Teredo) still bypass both SSRF guards

Summary

The fix for GHSA-qq8m-8p8v-x4xg added IsLinkLocalUnicast() and IsUnspecified() checks to isPrivateIP(), but did not add extraction of embedded IPv4 addresses from IPv6 transition address formats. Both SSRF guard functions (isPrivateIP in SSRFSafeDialer and CheckHostSSRF in the agent HTTP tooling) remain bypassable via NAT64 (64:ff9b::/96), 6to4 (2002::/16), and Teredo (2001:0000::/32) addresses that encode private/loopback IPv4 destinations. This allows full-read SSRF against internal services in SafeMode deployments.

Affected component / versions

  • Package: siyuan-note/siyuan
  • Affected versions: all versions through v3.8.0-beta.2 (current), including the "patched" v3.7.4
  • Affected files:
    • kernel/util/net.go (SSRFSafeDialer TCP-level control)
    • kernel/util/httprequest.go (CheckHostSSRF pre-flight DNS check, used by agent web_fetch and http_request tools)

Details

Root cause (CWE-918)

Both SSRF guard functions rely exclusively on Go stdlib IP classification methods that do not handle IPv6 transition addresses.

Guard 1 -- kernel/util/net.go:148-149:

func isPrivateIP(ip net.IP) bool {
    return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified()
}

Guard 2 -- kernel/util/httprequest.go:41-53:

func CheckHostSSRF(host string) error {
    ips, err := net.LookupIP(host)
    if err != nil {
        return errors.New("failed to resolve host: " + err.Error())
    }
    for _, ip := range ips {
        if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified() {
            return errors.New("access to private/internal IP is prohibited")
        }
    }
    return nil
}

Go's net.IP.IsLoopback(), IsPrivate(), IsLinkLocalUnicast(), and IsUnspecified() operate on the raw IPv6 byte representation. They classify 64:ff9b::127.0.0.1 as a non-private, non-loopback global unicast address because:

  • IsLoopback() only matches ::1 and 127.0.0.0/8 (the latter only for 4-byte IPs)
  • IsPrivate() only matches 10/8, 172.16/12, 192.168/16, fc00::/7 -- it does NOT recognize NAT64, 6to4, or Teredo prefixes
  • IsLinkLocalUnicast() only matches fe80::/10 and 169.254/16
  • IsUnspecified() only matches :: and 0.0.0.0

None of these methods extract the embedded IPv4 address from transition encodings.

Transition address formats that bypass both guards

Format Prefix Encoding of 127.0.0.1 RFC
NAT64 well-known prefix 64:ff9b::/96 64:ff9b::7f00:1 RFC 6052
NAT64 local-use 64:ff9b:1::/48 64:ff9b:1::7f00:1 RFC 8215
6to4 2002::/16 2002:7f00:0001::1 RFC 3056
Teredo 2001:0000::/32 2001:0000:xxxx:xxxx:xxxx:xxxx:807f:fffe RFC 4380
IPv4-compatible (deprecated) ::/96 ::127.0.0.1 RFC 4291

Reachability / trust boundary

SafeMode is the trust boundary SiYuan intends to enforce when the instance is network-exposed. The SSRF guards were explicitly introduced to prevent agents from being tricked into accessing internal services. Transition address bypass defeats this protection completely -- the attacker controls the URL fed to the agent, and the guard checks the resolved IP but fails to detect that the IPv6 address encodes a private IPv4 destination. The actual TCP connection routes to the internal host via the operating system's transition mechanism or a NAT64 gateway.

Attack chain

  1. Attacker crafts a URL with an IPv6 transition address that encodes an internal IPv4 target (e.g., http://[64:ff9b::7f00:1]:8080/admin/secret).
  2. The URL is passed to a SiYuan agent tool (web_fetch or http_request) or any HTTP endpoint that uses SSRFSafeDialer.
  3. CheckHostSSRF resolves the host to 64:ff9b::7f00:1. All four stdlib checks return false. The request is allowed.
  4. The SSRFSafeDialer's Control hook receives the same IP at dial time. isPrivateIP returns false. The connection proceeds.
  5. The OS routes the connection to 127.0.0.1:8080 via NAT64 translation. The response is returned to the attacker.

Impact

Full-read SSRF against internal services (cloud metadata at 169.254.169.254, localhost admin panels, internal APIs) in SafeMode deployments. The impact is identical to the original GHSA-qq8m-8p8v-x4xg because the same guard functions are bypassed via the same class of addresses that were specifically reported but not addressed in the fix.

Proof of concept

Bypass verification (Go playground equivalent):

package main

import (
    "fmt"
    "net"
)

func isPrivateIP(ip net.IP) bool {
    return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified()
}

func main() {
    vectors := []string{
        "64:ff9b::7f00:1",         // NAT64 -> 127.0.0.1
        "64:ff9b::c0a8:101",       // NAT64 -> 192.168.1.1
        "64:ff9b::a9fe:a9fe",      // NAT64 -> 169.254.169.254
        "2002:7f00:0001::1",       // 6to4  -> 127.0.0.1
        "2002:c0a8:0101::1",       // 6to4  -> 192.168.1.1
        "::ffff:127.0.0.1",        // IPv4-mapped (also passes)
    }
    for _, v := range vectors {
        ip := net.ParseIP(v)
        fmt.Printf("%-30s  isPrivateIP=%v\n", v, isPrivateIP(ip))
    }
}

Output:

64:ff9b::7f00:1                 isPrivateIP=false
64:ff9b::c0a8:101               isPrivateIP=false
64:ff9b::a9fe:a9fe              isPrivateIP=false
2002:7f00:0001::1               isPrivateIP=false
2002:c0a8:0101::1               isPrivateIP=false
::ffff:127.0.0.1                isPrivateIP=false

All six vectors return false, meaning the SSRF guard allows the connection.

Negative control (direct IPv4, correctly blocked):

127.0.0.1                       isPrivateIP=true
192.168.1.1                     isPrivateIP=true
10.0.0.1                        isPrivateIP=true

Remediation / fix

Extract the embedded IPv4 from all transition encodings before classification. Add this helper and call it at the top of both isPrivateIP and CheckHostSSRF:

// extractEmbeddedIPv4 returns the inner IPv4 if ip is an IPv6 transition
// address; otherwise returns ip unchanged.
func extractEmbeddedIPv4(ip net.IP) net.IP {
    if ip4 := ip.To4(); ip4 != nil {
        return ip4 // already IPv4 or IPv4-mapped
    }
    ip16 := ip.To16()
    if ip16 == nil {
        return ip
    }
    // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052)
    if ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b {
        if isZero(ip16[4:12]) {
            return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])
        }
    }
    // 6to4: 2002:AABB:CCDD::/48 -> A.B.C.D (RFC 3056)
    if ip16[0] == 0x20 && ip16[1] == 0x02 {
        return net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5])
    }
    // Teredo: 2001:0000:....:XXXX:YYYY -> obfuscated client IPv4 (RFC 4380)
    if ip16[0] == 0x20 && ip16[1] == 0x01 && ip16[2] == 0x00 && ip16[3] == 0x00 {
        return net.IPv4(ip16[12]^0xff, ip16[13]^0xff, ip16[14]^0xff, ip16[15]^0xff)
    }
    // IPv4-compatible ::/96 (deprecated, RFC 4291 sec 2.5.5.1)
    if isZero(ip16[0:12]) && !isZero(ip16[12:16]) {
        return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])
    }
    return ip
}

func isZero(b []byte) bool {
    for _, v := range b { if v != 0 { return false } }
    return true
}

Then update both guards:

func isPrivateIP(ip net.IP) bool {
    ip = extractEmbeddedIPv4(ip)
    return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified()
}

Credit

Reported by tonghuaroot (tonghuaroot@gmail.com).

Severity

High

CVE ID

No known CVE

Weaknesses

No CWEs