Skip to content

Commit efcc310

Browse files
committed
Add experimental C# query: SSRF host guard missing IPv6-transition unwrap (CWE-918/CWE-1389)
Mirrors the JavaScript experimental query SsrfIpv6TransitionIncompleteGuard. Flags SSRF host-validation guards that reject private/loopback IPv4 ranges but never unwrap IPv6-transition forms (IPv4-mapped ::ffff:, NAT64 64:ff9b::, 6to4 2002::), so an internal IPv4 address wrapped in a transition literal bypasses the guard. A partial MapToIPv4 / IsIPv4MappedToIPv6 unwrap (which only canonicalizes ::ffff:0:0/96) is treated as an unsafe signal; an explicit transition-prefix literal or extract-embedded-IPv4 helper suppresses the alert. Signed-off-by: tonghuaroot <23011166+tonghuaroot@users.noreply.github.com>
1 parent f591987 commit efcc310

10 files changed

Lines changed: 354 additions & 0 deletions

csharp/ql/integration-tests/posix/query-suite/not_included_in_qls.expected

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ ql/csharp/ql/src/Security Features/CWE-838/InappropriateEncoding.ql
8484
ql/csharp/ql/src/definitions.ql
8585
ql/csharp/ql/src/experimental/CWE-099/TaintedWebClient.ql
8686
ql/csharp/ql/src/experimental/CWE-918/RequestForgery.ql
87+
ql/csharp/ql/src/experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql
8788
ql/csharp/ql/src/experimental/Security Features/CWE-327/Azure/UnsafeUsageOfClientSideEncryptionVersion.ql
8889
ql/csharp/ql/src/experimental/Security Features/CWE-759/HashWithoutSalt.ql
8990
ql/csharp/ql/src/experimental/Security Features/JsonWebTokenHandler/delegated-security-validations-always-return-true.ql
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: newQuery
3+
---
4+
* 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.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
Server-side request forgery (SSRF) guards frequently reject requests to internal
9+
addresses by checking the request host against a denylist of private, loopback and
10+
cloud-metadata IPv4 ranges. When such a guard inspects only the dotted-quad IPv4 form
11+
and never unwraps IPv6-transition representations, it can be bypassed: the host
12+
validator classifies the address as public, but the operating system routes the
13+
connection to the embedded internal IPv4 endpoint.
14+
</p>
15+
<p>
16+
The affected forms include IPv4-mapped IPv6 (<code>::ffff:169.254.169.254</code>),
17+
NAT64 (<code>64:ff9b::a9fe:a9fe</code>) and 6to4 (<code>2002::</code>). A URL such as
18+
<code>http://[::ffff:169.254.169.254]/</code> passes a dotted-quad denylist unchanged
19+
while still reaching the internal address. Calling
20+
<code>IPAddress.MapToIPv4()</code> or testing
21+
<code>IPAddress.IsIPv4MappedToIPv6</code> only canonicalizes the
22+
<code>::ffff:0:0/96</code> prefix; NAT64, 6to4 and IPv4-compatible forms remain
23+
unrecognized, so the guard still returns "public".
24+
</p>
25+
</overview>
26+
27+
<recommendation>
28+
<p>
29+
Normalize the host before validating it. Parse the address with
30+
<code>System.Net.IPAddress.Parse</code>, and for every IPv6-transition family
31+
(IPv4-mapped <code>::ffff:</code>, NAT64 <code>64:ff9b::/96</code>, 6to4
32+
<code>2002::/16</code> and IPv4-compatible <code>::N.N.N.N</code>) extract the
33+
embedded IPv4 address, then apply the private-range check to the normalized value.
34+
Where possible, validate the address that DNS resolution actually returns rather than
35+
the textual host, and prefer a constant host or scheme allowlist that an
36+
attacker-supplied host cannot match.
37+
</p>
38+
</recommendation>
39+
40+
<example>
41+
<p>
42+
The following guard rejects private IPv4 ranges with a hand-written RFC 1918 /
43+
loopback / metadata denylist that inspects the textual IPv4 form only. An attacker
44+
supplies <code>::ffff:169.254.169.254</code>, which the guard classifies as public,
45+
but the request still reaches the internal metadata endpoint.
46+
</p>
47+
48+
<sample src="examples/SsrfIpv6TransitionIncompleteGuardBad.cs"/>
49+
50+
<p>
51+
The following guard unwraps every IPv6-transition family to its embedded IPv4 address
52+
before applying the private-range check, so the internal address is detected
53+
regardless of the transition form used.
54+
</p>
55+
56+
<sample src="examples/SsrfIpv6TransitionIncompleteGuardGood.cs"/>
57+
</example>
58+
59+
<references>
60+
61+
<li>OWASP: <a href="https://owasp.org/www-community/attacks/Server_Side_Request_Forgery">Server-Side Request Forgery</a>.</li>
62+
<li>Common Weakness Enumeration: <a href="https://cwe.mitre.org/data/definitions/918.html">CWE-918</a>.</li>
63+
<li>Common Weakness Enumeration: <a href="https://cwe.mitre.org/data/definitions/1389.html">CWE-1389</a>.</li>
64+
65+
</references>
66+
</qhelp>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @name SSRF host guard does not reject IPv6-transition forms
3+
* @description An SSRF host guard that rejects private or loopback IPv4 ranges but never
4+
* unwraps IPv6-transition forms (IPv4-mapped `::ffff:`, NAT64 `64:ff9b::`,
5+
* 6to4 `2002::`) can be bypassed by wrapping an internal IPv4 address in a
6+
* transition literal, allowing requests to reach internal endpoints.
7+
* @kind problem
8+
* @problem.severity warning
9+
* @id cs/ssrf-ipv6-transition-incomplete-guard
10+
* @tags security
11+
* experimental
12+
* external/cwe/cwe-918
13+
* external/cwe/cwe-1389
14+
*/
15+
16+
import csharp
17+
18+
/**
19+
* Holds if `c` calls an `IPAddress.IsLoopback` or an `IsPrivate`/`IsInternal`-style host
20+
* classifier whose decision is taken on the dotted-quad IPv4 form, the common shape of a
21+
* hand-rolled SSRF guard.
22+
*/
23+
predicate hasIsPrivateCall(Callable c) {
24+
exists(MethodCall mc | mc.getEnclosingCallable() = c |
25+
mc.getTarget().hasName("IsLoopback") and
26+
mc.getTarget().getDeclaringType().hasFullyQualifiedName("System.Net", "IPAddress")
27+
or
28+
mc.getTarget()
29+
.getName()
30+
.regexpMatch("(?i)^is_?(private|internal|loopback|reserved|local|blocked)(ip|address|host)?$")
31+
)
32+
}
33+
34+
/**
35+
* Holds if `c` contains a hand-written RFC 1918, loopback or cloud-metadata IPv4 literal
36+
* used as a denylist entry.
37+
*/
38+
predicate hasRfc1918Literal(Callable c) {
39+
exists(StringLiteral s | s.getEnclosingCallable() = c |
40+
s.getValue()
41+
.regexpMatch("(?i).*(127\\.0\\.0\\.1|169\\.254\\.169\\.254|10\\.|192\\.168|172\\.1[6-9]|::1|fc00|fd00|metadata\\.google).*")
42+
)
43+
}
44+
45+
/**
46+
* Holds if `c` performs only the partial IPv4-mapped unwrap that `MapToIPv4` /
47+
* `IsIPv4MappedToIPv6` provide. These canonicalise the `::ffff:0:0/96` prefix only, leaving
48+
* NAT64 (`64:ff9b::/96`), 6to4 (`2002::/16`) and IPv4-compatible (`::N.N.N.N`) forms live.
49+
* `MapToIPv4` is a method; `IsIPv4MappedToIPv6` is a property, so both shapes are covered.
50+
*/
51+
predicate hasPartialMappedUnwrap(Callable c) {
52+
exists(MethodCall mc | mc.getEnclosingCallable() = c |
53+
mc.getTarget().getName() = ["MapToIPv4", "MapToIPv6"]
54+
)
55+
or
56+
exists(PropertyAccess pa | pa.getEnclosingCallable() = c |
57+
pa.getTarget().getName() = "IsIPv4MappedToIPv6"
58+
)
59+
}
60+
61+
/** Holds if `c` carries any hand-rolled, dotted-quad-oriented SSRF guard signal. */
62+
predicate hasUnsafeGuardSignal(Callable c) {
63+
hasIsPrivateCall(c) or
64+
hasRfc1918Literal(c) or
65+
hasPartialMappedUnwrap(c)
66+
}
67+
68+
/** Holds if `c` has a name that reads as an SSRF host, URL or IP validator. */
69+
predicate isSsrfValidatorCallable(Callable c) {
70+
c.getName()
71+
.regexpMatch("(?i).*(validate|check|guard|reject|deny|block|allow|is_?safe|sanitiz)e?_?.*(url|host|ip|address|target|endpoint|webhook|origin).*")
72+
or
73+
c.getName()
74+
.regexpMatch("(?i).*(is_?)?(private|internal|loopback|reserved|external)_?(ip|address|host|url).*")
75+
or
76+
c.getName().regexpMatch("(?i).*(ssrf|metadata).*")
77+
}
78+
79+
/**
80+
* Holds if `c` already performs an explicit IPv6-transition unwrap or canonicalization, so
81+
* the guard does see the embedded IPv4 address. The presence of a `64:ff9b` / `2002:`
82+
* literal, or a NAT64 / 6to4 / extract-embedded-IPv4 helper, means every transition family
83+
* is accounted for rather than the `::ffff:0:0/96` prefix alone.
84+
*/
85+
predicate hasTransitionUnwrap(Callable c) {
86+
exists(StringLiteral s | s.getEnclosingCallable() = c |
87+
s.getValue().matches("%64:ff9b%") or
88+
s.getValue().matches("%2002:%") or
89+
s.getValue().matches("%::ffff:%")
90+
)
91+
or
92+
exists(MethodCall mc | mc.getEnclosingCallable() = c |
93+
mc.getTarget()
94+
.getName()
95+
.regexpMatch("(?i).*(nat64|6to4|extractembedded|embeddedipv4|ipv4inipv6|transition).*")
96+
)
97+
}
98+
99+
/** Holds if `c` is treated as safe (transition-aware), suppressing the alert. */
100+
predicate isSafe(Callable c) { hasTransitionUnwrap(c) }
101+
102+
from Callable guard
103+
where
104+
isSsrfValidatorCallable(guard) and
105+
hasUnsafeGuardSignal(guard) and
106+
not isSafe(guard) and
107+
not guard.getFile()
108+
.getRelativePath()
109+
.regexpMatch("(?i).*/(tests?|specs?|examples?|e2e)/.*")
110+
select guard,
111+
"This SSRF host guard rejects private IPv4 ranges but never unwraps IPv6-transition forms " +
112+
"(IPv4-mapped '::ffff:', NAT64 '64:ff9b::', 6to4 '2002::'); an attacker can wrap an internal " +
113+
"IPv4 address in a transition literal to bypass it and reach internal endpoints."
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Threading.Tasks;
5+
6+
public class BadFetcher
7+
{
8+
// BAD: a hand-written RFC 1918 / loopback / metadata denylist matched against the
9+
// textual host. The embedded IPv4 inside `::ffff:169.254.169.254` is never seen, so a
10+
// transition-wrapped internal address is classified as public and the request reaches it.
11+
private static bool IsPrivateHost(string host)
12+
{
13+
return host == "127.0.0.1"
14+
|| host == "169.254.169.254"
15+
|| host.StartsWith("10.")
16+
|| host.StartsWith("192.168")
17+
|| host.StartsWith("172.16");
18+
}
19+
20+
public static async Task<string> FetchAsync(string host)
21+
{
22+
if (IsPrivateHost(host))
23+
{
24+
throw new Exception("blocked internal host");
25+
}
26+
27+
using var client = new HttpClient();
28+
return await client.GetStringAsync("http://" + host + "/");
29+
}
30+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Threading.Tasks;
5+
6+
public class GoodFetcher
7+
{
8+
// GOOD: the guard parses the host and unwraps every IPv6-transition family to its
9+
// embedded IPv4 address before applying the private-range check. NAT64 `64:ff9b::/96`,
10+
// 6to4 `2002::/16` and IPv4-mapped `::ffff:` are all canonicalized, so an internal
11+
// address wrapped in any transition literal is detected.
12+
private static IPAddress UnwrapTransition(IPAddress addr)
13+
{
14+
byte[] b = addr.GetAddressBytes();
15+
// NAT64 well-known prefix 64:ff9b::/96 -> last 4 bytes are the embedded IPv4.
16+
if (b.Length == 16 && b[0] == 0x00 && b[1] == 0x64 && b[2] == 0xff && b[3] == 0x9b)
17+
{
18+
return new IPAddress(new[] { b[12], b[13], b[14], b[15] });
19+
}
20+
// 6to4 2002::/16 -> bytes 2..5 are the embedded IPv4.
21+
if (b.Length == 16 && b[0] == 0x20 && b[1] == 0x02)
22+
{
23+
return new IPAddress(new[] { b[2], b[3], b[4], b[5] });
24+
}
25+
// IPv4-mapped ::ffff:0:0/96.
26+
if (addr.IsIPv4MappedToIPv6)
27+
{
28+
return addr.MapToIPv4();
29+
}
30+
return addr;
31+
}
32+
33+
private static bool IsPrivateHost(string host)
34+
{
35+
IPAddress addr = UnwrapTransition(IPAddress.Parse(host));
36+
byte[] b = addr.GetAddressBytes();
37+
return b.Length == 4
38+
&& (b[0] == 127 || b[0] == 10 || (b[0] == 169 && b[1] == 254)
39+
|| (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] == 16));
40+
}
41+
42+
public static async Task<string> FetchAsync(string host)
43+
{
44+
if (IsPrivateHost(host))
45+
{
46+
throw new Exception("blocked internal host");
47+
}
48+
49+
using var client = new HttpClient();
50+
return await client.GetStringAsync("http://" + host + "/");
51+
}
52+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System;
2+
3+
namespace SsrfIpv6TransitionTest
4+
{
5+
public class HostGuards
6+
{
7+
// BAD: a hand-written RFC 1918 / loopback / metadata denylist matched against the
8+
// textual host. The embedded IPv4 inside `::ffff:10.0.0.1` is never seen.
9+
public static bool ValidateTargetHost(string host) // NOT OK
10+
{
11+
if (host == "127.0.0.1"
12+
|| host == "169.254.169.254"
13+
|| host.StartsWith("10.")
14+
|| host.StartsWith("192.168")
15+
|| host.StartsWith("172.16"))
16+
{
17+
throw new Exception("blocked internal host");
18+
}
19+
return true;
20+
}
21+
22+
// BAD: an `IsPrivateHost`-named guard that only does the partial `::ffff:` unwrap via
23+
// `IsIPv4MappedToIPv6` / `MapToIPv4`, leaving NAT64 and 6to4 forms live.
24+
public static bool IsPrivateHostAddress(FakeIPAddress addr) // NOT OK
25+
{
26+
if (addr.IsIPv4MappedToIPv6)
27+
{
28+
addr = addr.MapToIPv4();
29+
}
30+
return addr.ToString().StartsWith("10.")
31+
|| addr.ToString() == "127.0.0.1";
32+
}
33+
34+
// OK: this guard uses a hand-rolled denylist, but it first unwraps every
35+
// IPv6-transition family via explicit prefix literals before the check.
36+
public static bool CheckHostUnwrapped(string host) // OK
37+
{
38+
string h = host;
39+
if (h.StartsWith("64:ff9b:"))
40+
{
41+
h = ExtractNat64(h);
42+
}
43+
else if (h.StartsWith("2002:"))
44+
{
45+
h = ExtractSixToFour(h);
46+
}
47+
else if (h.StartsWith("::ffff:"))
48+
{
49+
h = h.Substring("::ffff:".Length);
50+
}
51+
return h.StartsWith("10.") || h == "127.0.0.1" || h == "169.254.169.254";
52+
}
53+
54+
// OK: a transition-extract helper (named `Nat64`) is used, so the guard is complete.
55+
public static bool ValidateHostViaHelper(string host) // OK
56+
{
57+
string embedded = ExtractNat64FromTransition(host);
58+
return embedded.StartsWith("10.") || embedded == "127.0.0.1";
59+
}
60+
61+
// OK: not an SSRF host/url/ip validator by name, so it is not in scope even though it
62+
// matches an RFC 1918 literal.
63+
public static bool FormatBanner(string s) // OK
64+
{
65+
return s == "10.0.0.1";
66+
}
67+
68+
private static string ExtractNat64(string h) => h;
69+
70+
private static string ExtractSixToFour(string h) => h;
71+
72+
private static string ExtractNat64FromTransition(string h) => h;
73+
}
74+
75+
// Minimal local stand-in so the test compiles without the System.Net stub; the query
76+
// matches on the member *names* `IsIPv4MappedToIPv6` / `MapToIPv4`.
77+
public class FakeIPAddress
78+
{
79+
public bool IsIPv4MappedToIPv6 { get; set; }
80+
81+
public FakeIPAddress MapToIPv4() => this;
82+
}
83+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
| 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. |
2+
| 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. |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
semmle-extractor-options: /nostdlib /noconfig
2+
semmle-extractor-options: --load-sources-from-project:${testdir}/../../../resources/stubs/_frameworks/Microsoft.NETCore.App/Microsoft.NETCore.App.csproj

0 commit comments

Comments
 (0)