diff --git a/csharp/ql/integration-tests/posix/query-suite/not_included_in_qls.expected b/csharp/ql/integration-tests/posix/query-suite/not_included_in_qls.expected index 4776a36a6a90..99c704d19664 100644 --- a/csharp/ql/integration-tests/posix/query-suite/not_included_in_qls.expected +++ b/csharp/ql/integration-tests/posix/query-suite/not_included_in_qls.expected @@ -84,6 +84,7 @@ ql/csharp/ql/src/Security Features/CWE-838/InappropriateEncoding.ql ql/csharp/ql/src/definitions.ql ql/csharp/ql/src/experimental/CWE-099/TaintedWebClient.ql ql/csharp/ql/src/experimental/CWE-918/RequestForgery.ql +ql/csharp/ql/src/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql ql/csharp/ql/src/experimental/Security Features/CWE-327/Azure/UnsafeUsageOfClientSideEncryptionVersion.ql ql/csharp/ql/src/experimental/Security Features/CWE-759/HashWithoutSalt.ql ql/csharp/ql/src/experimental/Security Features/JsonWebTokenHandler/delegated-security-validations-always-return-true.ql diff --git a/csharp/ql/src/change-notes/2026-06-10-ssrf-ipv6-transition-incomplete-guard.md b/csharp/ql/src/change-notes/2026-06-10-ssrf-ipv6-transition-incomplete-guard.md new file mode 100644 index 000000000000..f5e9b7630abe --- /dev/null +++ b/csharp/ql/src/change-notes/2026-06-10-ssrf-ipv6-transition-incomplete-guard.md @@ -0,0 +1,4 @@ +--- +category: newQuery +--- +* Added a new experimental query, `cs/ssrf-ipv6-transition-incomplete-guard`, to detect SSRF host-validation guards that reject private IPv4 ranges but fail to unwrap IPv6-transition forms (IPv4-mapped `::ffff:`, NAT64 `64:ff9b::`, 6to4 `2002::`), allowing the guard to be bypassed by wrapping an internal IPv4 address in a transition literal. diff --git a/csharp/ql/src/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.qhelp b/csharp/ql/src/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.qhelp new file mode 100644 index 000000000000..0244f50d356c --- /dev/null +++ b/csharp/ql/src/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.qhelp @@ -0,0 +1,66 @@ + + + + +

+ Server-side request forgery (SSRF) guards frequently reject requests to internal + addresses by checking the request host against a denylist of private, loopback and + cloud-metadata IPv4 ranges. When such a guard inspects only the dotted-quad IPv4 form + and never unwraps IPv6-transition representations, it can be bypassed: the host + validator classifies the address as public, but the operating system routes the + connection to the embedded internal IPv4 endpoint. +

+

+ The affected forms include IPv4-mapped IPv6 (::ffff:169.254.169.254), + NAT64 (64:ff9b::a9fe:a9fe) and 6to4 (2002::). A URL such as + http://[::ffff:169.254.169.254]/ passes a dotted-quad denylist unchanged + while still reaching the internal address. Calling + IPAddress.MapToIPv4() or testing + IPAddress.IsIPv4MappedToIPv6 only canonicalizes the + ::ffff:0:0/96 prefix; NAT64, 6to4 and IPv4-compatible forms remain + unrecognized, so the guard still returns "public". +

+
+ + +

+ Normalize the host before validating it. Parse the address with + System.Net.IPAddress.Parse, and for every IPv6-transition family + (IPv4-mapped ::ffff:, NAT64 64:ff9b::/96, 6to4 + 2002::/16 and IPv4-compatible ::N.N.N.N) extract the + embedded IPv4 address, then apply the private-range check to the normalized value. + Where possible, validate the address that DNS resolution actually returns rather than + the textual host, and prefer a constant host or scheme allowlist that an + attacker-supplied host cannot match. +

+
+ + +

+ The following guard rejects private IPv4 ranges with a hand-written RFC 1918 / + loopback / metadata denylist that inspects the textual IPv4 form only. An attacker + supplies ::ffff:169.254.169.254, which the guard classifies as public, + but the request still reaches the internal metadata endpoint. +

+ + + +

+ The following guard unwraps every IPv6-transition family to its embedded IPv4 address + before applying the private-range check, so the internal address is detected + regardless of the transition form used. +

+ + +
+ + + +
  • OWASP: Server-Side Request Forgery.
  • +
  • Common Weakness Enumeration: CWE-918.
  • +
  • Common Weakness Enumeration: CWE-1389.
  • + +
    +
    diff --git a/csharp/ql/src/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql b/csharp/ql/src/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql new file mode 100644 index 000000000000..371e228f20ab --- /dev/null +++ b/csharp/ql/src/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql @@ -0,0 +1,113 @@ +/** + * @name SSRF host guard does not reject IPv6-transition forms + * @description An SSRF host guard that rejects private or loopback IPv4 ranges but never + * unwraps IPv6-transition forms (IPv4-mapped `::ffff:`, NAT64 `64:ff9b::`, + * 6to4 `2002::`) can be bypassed by wrapping an internal IPv4 address in a + * transition literal, allowing requests to reach internal endpoints. + * @kind problem + * @problem.severity warning + * @id cs/ssrf-ipv6-transition-incomplete-guard + * @tags security + * experimental + * external/cwe/cwe-918 + * external/cwe/cwe-1389 + */ + +import csharp + +/** + * Holds if `c` calls an `IPAddress.IsLoopback` or an `IsPrivate`/`IsInternal`-style host + * classifier whose decision is taken on the dotted-quad IPv4 form, the common shape of a + * hand-rolled SSRF guard. + */ +predicate hasIsPrivateCall(Callable c) { + exists(MethodCall mc | mc.getEnclosingCallable() = c | + mc.getTarget().hasName("IsLoopback") and + mc.getTarget().getDeclaringType().hasFullyQualifiedName("System.Net", "IPAddress") + or + mc.getTarget() + .getName() + .regexpMatch("(?i)^is_?(private|internal|loopback|reserved|local|blocked)(ip|address|host)?$") + ) +} + +/** + * Holds if `c` contains a hand-written RFC 1918, loopback or cloud-metadata IPv4 literal + * used as a denylist entry. + */ +predicate hasRfc1918Literal(Callable c) { + exists(StringLiteral s | s.getEnclosingCallable() = c | + s.getValue() + .regexpMatch("(?i).*(127\\.0\\.0\\.1|169\\.254\\.169\\.254|10\\.|192\\.168|172\\.1[6-9]|::1|fc00|fd00|metadata\\.google).*") + ) +} + +/** + * Holds if `c` performs only the partial IPv4-mapped unwrap that `MapToIPv4` / + * `IsIPv4MappedToIPv6` provide. These canonicalise the `::ffff:0:0/96` prefix only, leaving + * NAT64 (`64:ff9b::/96`), 6to4 (`2002::/16`) and IPv4-compatible (`::N.N.N.N`) forms live. + * `MapToIPv4` is a method; `IsIPv4MappedToIPv6` is a property, so both shapes are covered. + */ +predicate hasPartialMappedUnwrap(Callable c) { + exists(MethodCall mc | mc.getEnclosingCallable() = c | + mc.getTarget().getName() = ["MapToIPv4", "MapToIPv6"] + ) + or + exists(PropertyAccess pa | pa.getEnclosingCallable() = c | + pa.getTarget().getName() = "IsIPv4MappedToIPv6" + ) +} + +/** Holds if `c` carries any hand-rolled, dotted-quad-oriented SSRF guard signal. */ +predicate hasUnsafeGuardSignal(Callable c) { + hasIsPrivateCall(c) or + hasRfc1918Literal(c) or + hasPartialMappedUnwrap(c) +} + +/** Holds if `c` has a name that reads as an SSRF host, URL or IP validator. */ +predicate isSsrfValidatorCallable(Callable c) { + c.getName() + .regexpMatch("(?i).*(validate|check|guard|reject|deny|block|allow|is_?safe|sanitiz)e?_?.*(url|host|ip|address|target|endpoint|webhook|origin).*") + or + c.getName() + .regexpMatch("(?i).*(is_?)?(private|internal|loopback|reserved|external)_?(ip|address|host|url).*") + or + c.getName().regexpMatch("(?i).*(ssrf|metadata).*") +} + +/** + * Holds if `c` already performs an explicit IPv6-transition unwrap or canonicalization, so + * the guard does see the embedded IPv4 address. The presence of a `64:ff9b` / `2002:` + * literal, or a NAT64 / 6to4 / extract-embedded-IPv4 helper, means every transition family + * is accounted for rather than the `::ffff:0:0/96` prefix alone. + */ +predicate hasTransitionUnwrap(Callable c) { + exists(StringLiteral s | s.getEnclosingCallable() = c | + s.getValue().matches("%64:ff9b%") or + s.getValue().matches("%2002:%") or + s.getValue().matches("%::ffff:%") + ) + or + exists(MethodCall mc | mc.getEnclosingCallable() = c | + mc.getTarget() + .getName() + .regexpMatch("(?i).*(nat64|6to4|extractembedded|embeddedipv4|ipv4inipv6|transition).*") + ) +} + +/** Holds if `c` is treated as safe (transition-aware), suppressing the alert. */ +predicate isSafe(Callable c) { hasTransitionUnwrap(c) } + +from Callable guard +where + isSsrfValidatorCallable(guard) and + hasUnsafeGuardSignal(guard) and + not isSafe(guard) and + not guard.getFile() + .getRelativePath() + .regexpMatch("(?i).*/(tests?|specs?|examples?|e2e)/.*") +select guard, + "This SSRF host guard rejects private IPv4 ranges but never unwraps IPv6-transition forms " + + "(IPv4-mapped '::ffff:', NAT64 '64:ff9b::', 6to4 '2002::'); an attacker can wrap an internal " + + "IPv4 address in a transition literal to bypass it and reach internal endpoints." diff --git a/csharp/ql/src/experimental/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardBad.cs b/csharp/ql/src/experimental/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardBad.cs new file mode 100644 index 000000000000..81d2ceccc570 --- /dev/null +++ b/csharp/ql/src/experimental/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardBad.cs @@ -0,0 +1,30 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +public class BadFetcher +{ + // BAD: a hand-written RFC 1918 / loopback / metadata denylist matched against the + // textual host. The embedded IPv4 inside `::ffff:169.254.169.254` is never seen, so a + // transition-wrapped internal address is classified as public and the request reaches it. + private static bool IsPrivateHost(string host) + { + return host == "127.0.0.1" + || host == "169.254.169.254" + || host.StartsWith("10.") + || host.StartsWith("192.168") + || host.StartsWith("172.16"); + } + + public static async Task FetchAsync(string host) + { + if (IsPrivateHost(host)) + { + throw new Exception("blocked internal host"); + } + + using var client = new HttpClient(); + return await client.GetStringAsync("http://" + host + "/"); + } +} diff --git a/csharp/ql/src/experimental/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardGood.cs b/csharp/ql/src/experimental/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardGood.cs new file mode 100644 index 000000000000..c7bf7e0fa89a --- /dev/null +++ b/csharp/ql/src/experimental/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardGood.cs @@ -0,0 +1,52 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +public class GoodFetcher +{ + // GOOD: the guard parses the host and unwraps every IPv6-transition family to its + // embedded IPv4 address before applying the private-range check. NAT64 `64:ff9b::/96`, + // 6to4 `2002::/16` and IPv4-mapped `::ffff:` are all canonicalized, so an internal + // address wrapped in any transition literal is detected. + private static IPAddress UnwrapTransition(IPAddress addr) + { + byte[] b = addr.GetAddressBytes(); + // NAT64 well-known prefix 64:ff9b::/96 -> last 4 bytes are the embedded IPv4. + if (b.Length == 16 && b[0] == 0x00 && b[1] == 0x64 && b[2] == 0xff && b[3] == 0x9b) + { + return new IPAddress(new[] { b[12], b[13], b[14], b[15] }); + } + // 6to4 2002::/16 -> bytes 2..5 are the embedded IPv4. + if (b.Length == 16 && b[0] == 0x20 && b[1] == 0x02) + { + return new IPAddress(new[] { b[2], b[3], b[4], b[5] }); + } + // IPv4-mapped ::ffff:0:0/96. + if (addr.IsIPv4MappedToIPv6) + { + return addr.MapToIPv4(); + } + return addr; + } + + private static bool IsPrivateHost(string host) + { + IPAddress addr = UnwrapTransition(IPAddress.Parse(host)); + byte[] b = addr.GetAddressBytes(); + return b.Length == 4 + && (b[0] == 127 || b[0] == 10 || (b[0] == 169 && b[1] == 254) + || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] == 16)); + } + + public static async Task FetchAsync(string host) + { + if (IsPrivateHost(host)) + { + throw new Exception("blocked internal host"); + } + + using var client = new HttpClient(); + return await client.GetStringAsync("http://" + host + "/"); + } +} diff --git a/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.cs b/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.cs new file mode 100644 index 000000000000..c2b34547afe5 --- /dev/null +++ b/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.cs @@ -0,0 +1,83 @@ +using System; + +namespace SsrfIpv6TransitionTest +{ + public class HostGuards + { + // BAD: a hand-written RFC 1918 / loopback / metadata denylist matched against the + // textual host. The embedded IPv4 inside `::ffff:10.0.0.1` is never seen. + public static bool ValidateTargetHost(string host) // NOT OK + { + if (host == "127.0.0.1" + || host == "169.254.169.254" + || host.StartsWith("10.") + || host.StartsWith("192.168") + || host.StartsWith("172.16")) + { + throw new Exception("blocked internal host"); + } + return true; + } + + // BAD: an `IsPrivateHost`-named guard that only does the partial `::ffff:` unwrap via + // `IsIPv4MappedToIPv6` / `MapToIPv4`, leaving NAT64 and 6to4 forms live. + public static bool IsPrivateHostAddress(FakeIPAddress addr) // NOT OK + { + if (addr.IsIPv4MappedToIPv6) + { + addr = addr.MapToIPv4(); + } + return addr.ToString().StartsWith("10.") + || addr.ToString() == "127.0.0.1"; + } + + // OK: this guard uses a hand-rolled denylist, but it first unwraps every + // IPv6-transition family via explicit prefix literals before the check. + public static bool CheckHostUnwrapped(string host) // OK + { + string h = host; + if (h.StartsWith("64:ff9b:")) + { + h = ExtractNat64(h); + } + else if (h.StartsWith("2002:")) + { + h = ExtractSixToFour(h); + } + else if (h.StartsWith("::ffff:")) + { + h = h.Substring("::ffff:".Length); + } + return h.StartsWith("10.") || h == "127.0.0.1" || h == "169.254.169.254"; + } + + // OK: a transition-extract helper (named `Nat64`) is used, so the guard is complete. + public static bool ValidateHostViaHelper(string host) // OK + { + string embedded = ExtractNat64FromTransition(host); + return embedded.StartsWith("10.") || embedded == "127.0.0.1"; + } + + // OK: not an SSRF host/url/ip validator by name, so it is not in scope even though it + // matches an RFC 1918 literal. + public static bool FormatBanner(string s) // OK + { + return s == "10.0.0.1"; + } + + private static string ExtractNat64(string h) => h; + + private static string ExtractSixToFour(string h) => h; + + private static string ExtractNat64FromTransition(string h) => h; + } + + // Minimal local stand-in so the test compiles without the System.Net stub; the query + // matches on the member *names* `IsIPv4MappedToIPv6` / `MapToIPv4`. + public class FakeIPAddress + { + public bool IsIPv4MappedToIPv6 { get; set; } + + public FakeIPAddress MapToIPv4() => this; + } +} diff --git a/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.expected b/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.expected new file mode 100644 index 000000000000..9523af63e305 --- /dev/null +++ b/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.expected @@ -0,0 +1,2 @@ +| SsrfIpv6TransitionIncompleteGuard.cs:9:28:9:45 | ValidateTargetHost | This SSRF host guard rejects private IPv4 ranges but never unwraps IPv6-transition forms (IPv4-mapped '::ffff:', NAT64 '64:ff9b::', 6to4 '2002::'); an attacker can wrap an internal IPv4 address in a transition literal to bypass it and reach internal endpoints. | +| SsrfIpv6TransitionIncompleteGuard.cs:24:28:24:47 | IsPrivateHostAddress | This SSRF host guard rejects private IPv4 ranges but never unwraps IPv6-transition forms (IPv4-mapped '::ffff:', NAT64 '64:ff9b::', 6to4 '2002::'); an attacker can wrap an internal IPv4 address in a transition literal to bypass it and reach internal endpoints. | diff --git a/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.qlref b/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.qlref new file mode 100644 index 000000000000..1d047c2c8dc8 --- /dev/null +++ b/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.qlref @@ -0,0 +1 @@ +experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql \ No newline at end of file diff --git a/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/options b/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/options new file mode 100644 index 000000000000..75c39b4541ba --- /dev/null +++ b/csharp/ql/test/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard/options @@ -0,0 +1,2 @@ +semmle-extractor-options: /nostdlib /noconfig +semmle-extractor-options: --load-sources-from-project:${testdir}/../../../resources/stubs/_frameworks/Microsoft.NETCore.App/Microsoft.NETCore.App.csproj