Skip to content

SSRF via IPv6 Transition Address Bypass of SSRFSafeDialer Guard in SafeMode

High
88250 published GHSA-qq8m-8p8v-x4xg Aug 4, 2026

Package

No package listed

Affected versions

<=v3.7.3

Patched versions

v3.7.4

Description

SSRF via IPv6 Transition Address Bypass of SSRFSafeDialer Guard in SafeMode

Summary

The isPrivateIP function in kernel/util/net.go, used by SSRFSafeDialer to enforce SSRF protection in SafeMode, only checks IsLoopback(), IsLinkLocalUnicast(), IsPrivate(), and IsUnspecified(). It does not recognize IPv6 transition addresses (NAT64 64:ff9b::/96, 6to4 2002::/16, Teredo 2001::/32) that embed private IPv4 destinations. When SafeMode is enabled, an attacker can bypass the SSRF guard via the network forward proxy endpoints by supplying a URL whose hostname resolves to one of these transition addresses.

Affected Component

  • File: kernel/util/net.go
  • Function: isPrivateIP (line ~148)
  • Guard: SSRFSafeDialer with SafeMode check
  • Consumers: kernel/api/network.go -- HTTP forward proxy, WebSocket proxy, SSE proxy endpoints; kernel/plugin/server.go

Details

The isPrivateIP function:

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

Go's standard library methods do not recognize IPv6 transition prefixes:

Prefix RFC Embeds
64:ff9b::/96 RFC 6052 (NAT64) Low 32 bits = IPv4 destination
64:ff9b:1::/48 RFC 8215 (NAT64 local-use) Same
2002::/16 RFC 3056 (6to4) Bits 16-47 = IPv4 destination
2001::/32 RFC 4380 (Teredo) Bits 96-127 = IPv4 destination

For example, 64:ff9b::a9fe:a9fe encodes 169.254.169.254 (cloud metadata). net.IP.IsPrivate() returns false because 64:ff9b:: is not in fc00::/7 or any RFC 1918 range.

Additionally, IsMulticast() and IsLinkLocalMulticast() are not checked.

PoC

  1. Set up a DNS record:

    ssrf-test.attacker.com  AAAA  64:ff9b::7f00:1       ;; loopback
    ssrf-test.attacker.com  AAAA  64:ff9b::a9fe:a9fe    ;; metadata
    ssrf-test.attacker.com  AAAA  2002:c0a8:0101::1     ;; 192.168.1.1
    
  2. With SiYuan running in SafeMode (--safeMode), use the network forward proxy endpoint:

    # Encode target URL as base64
    TARGET=$(echo -n "http://ssrf-test.attacker.com/latest/meta-data/" | base64 | tr -d '=')
    
    # Call the forward proxy
    curl "http://localhost:6806/api/network/forwardProxy?u=$TARGET"
  3. The SSRFSafeDialer.Control hook runs:

    • DNS resolves ssrf-test.attacker.com to 64:ff9b::a9fe:a9fe
    • isPrivateIP(64:ff9b::a9fe:a9fe) returns false (no matching check)
    • Connection proceeds even in SafeMode
    • On a NAT64 network, the request reaches 169.254.169.254

Impact

When SafeMode is enabled (specifically designed to provide SSRF protection among other security hardening), an authenticated user can bypass the SSRF guard to:

  • Access cloud metadata endpoints (AWS IMDSv1 at 169.254.169.254) to steal instance credentials
  • Reach internal services on private networks via the forward proxy
  • Port-scan internal infrastructure

The forward proxy endpoints return the full response body to the caller, making this a full-read SSRF (not blind).

Suggested Fix

Add IPv6 transition prefix checks to isPrivateIP:

func isPrivateIP(ip net.IP) bool {
    if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
        ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() {
        return true
    }
    
    // Check IPv6 transition addresses that embed private IPv4
    if ip16 := ip.To16(); ip16 != nil && len(ip16) == 16 {
        // NAT64 (64:ff9b::/96)
        if ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b {
            embedded := net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])
            return isPrivateIP(embedded)
        }
        // 6to4 (2002::/16)
        if ip16[0] == 0x20 && ip16[1] == 0x02 {
            embedded := net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5])
            return isPrivateIP(embedded)
        }
        // Teredo (2001:0000::/32)
        if ip16[0] == 0x20 && ip16[1] == 0x01 && ip16[2] == 0x00 && ip16[3] == 0x00 {
            embedded := net.IPv4(ip16[12]^0xff, ip16[13]^0xff, ip16[14]^0xff, ip16[15]^0xff)
            return isPrivateIP(embedded)
        }
    }
    return false
}

Credit

tonghuaroot (tonghuaroot@gmail.com)

Severity

High

CVE ID

No known CVE

Weaknesses

No CWEs