Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!DOCTYPE qhelp PUBLIC
"-//Semmle//qhelp//EN"
"qhelp.dtd">
<qhelp>

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

<recommendation>
<p>
Normalize the host before validating it. Parse the address with
<code>System.Net.IPAddress.Parse</code>, and for every IPv6-transition family
(IPv4-mapped <code>::ffff:</code>, NAT64 <code>64:ff9b::/96</code>, 6to4
<code>2002::/16</code> and IPv4-compatible <code>::N.N.N.N</code>) 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.
</p>
</recommendation>

<example>
<p>
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 <code>::ffff:169.254.169.254</code>, which the guard classifies as public,
but the request still reaches the internal metadata endpoint.
</p>

<sample src="examples/SsrfIpv6TransitionIncompleteGuardBad.cs"/>

<p>
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.
</p>

<sample src="examples/SsrfIpv6TransitionIncompleteGuardGood.cs"/>
</example>

<references>

<li>OWASP: <a href="https://owasp.org/www-community/attacks/Server_Side_Request_Forgery">Server-Side Request Forgery</a>.</li>
<li>Common Weakness Enumeration: <a href="https://cwe.mitre.org/data/definitions/918.html">CWE-918</a>.</li>
<li>Common Weakness Enumeration: <a href="https://cwe.mitre.org/data/definitions/1389.html">CWE-1389</a>.</li>

</references>
</qhelp>
Original file line number Diff line number Diff line change
@@ -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."
Original file line number Diff line number Diff line change
@@ -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<string> FetchAsync(string host)
{
if (IsPrivateHost(host))
{
throw new Exception("blocked internal host");
}

using var client = new HttpClient();
return await client.GetStringAsync("http://" + host + "/");
}
}
Original file line number Diff line number Diff line change
@@ -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<string> FetchAsync(string host)
{
if (IsPrivateHost(host))
{
throw new Exception("blocked internal host");
}

using var client = new HttpClient();
return await client.GetStringAsync("http://" + host + "/");
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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. |
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
experimental/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql
Original file line number Diff line number Diff line change
@@ -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
Loading