IPv6 transition address bypass of webhook URL SSRF guard
Summary
The ExternalUrl validation rule in app/Rules/ExternalUrl.php prevents webhook URLs from targeting private/internal addresses. The isPublicIp method unwraps IPv4-mapped IPv6 (::ffff:x.x.x.x) and then relies on PHP's filter_var with FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE to reject non-public addresses.
PHP's built-in filter flags do not classify IPv6 transition addresses as private or reserved. Three families of transition mechanisms encode a private IPv4 target inside a globally-routable IPv6 wrapper, and all three pass through filter_var unchecked.
Affected code
app/Rules/ExternalUrl.php lines 72-88:
private function isPublicIp(string $ip): bool
{
// Unwrap IPv4-mapped IPv6 so ::ffff:127.0.0.1 doesn't sneak past
// the IPv4 private/reserved checks.
if (stripos($ip, '::ffff:') === 0) {
$ipv4 = substr($ip, 7);
if (filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ip = $ipv4;
}
}
return (bool) filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
PHP's FILTER_FLAG_NO_PRIV_RANGE blocks fc00::/7 (unique local) only. FILTER_FLAG_NO_RES_RANGE blocks ::1, ::, ::ffff:0:0/96, fe80::/10, 2001:db8::/32, and ff00::/8. None of these cover:
| Mechanism |
Prefix |
RFC |
Embedding |
| NAT64 well-known prefix |
64:ff9b::/96 |
RFC 6052 |
Last 32 bits = IPv4 |
| 6to4 |
2002::/16 |
RFC 3056 |
Bits 16-47 = IPv4 |
| Teredo |
2001:0000::/32 |
RFC 4380 |
Last 32 bits = IPv4 XOR 0xffffffff |
Bypass vectors
A super-admin configuring a webhook URL can supply an IPv6 literal containing a transition address. Since IPv6 literals are valid IP addresses, they go directly to isPublicIp without DNS resolution.
NAT64 -- targeting AWS IMDS 169.254.169.254
http://[64:ff9b::a9fe:a9fe]/latest/meta-data/
http://[64:ff9b::169.254.169.254]/latest/meta-data/
filter_var('64:ff9b::a9fe:a9fe', FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) returns '64:ff9b::a9fe:a9fe' (passes validation). On a NAT64-enabled host, this routes to 169.254.169.254.
6to4 -- targeting localhost 127.0.0.1
http://[2002:7f00:1::]/
http://[2002:a9fe:a9fe::]/latest/meta-data/
2002:7f00:1:: embeds 127.0.0.1 in bits 16-47. Passes filter_var.
Teredo -- targeting 10.0.0.1
http://[2001:0000:4136:e378:8000:63bf:f5ff:fffe]/
Last 32 bits f5ff:fffe XOR ffff:ffff = 0a00:0001 = 10.0.0.1. Passes filter_var.
DNS resolution path
The same bypass applies through DNS resolution. If an attacker controls a DNS zone, they can return an AAAA record containing a transition address for any hostname. The resolveHost method uses dns_get_record($host, DNS_AAAA) and passes each resolved IPv6 address to isPublicIp, where filter_var again fails to detect the embedded private IPv4.
Impact
A super-admin who sets a webhook URL to a transition address (or a domain resolving to one) can trigger SSRF on a NAT64/6to4/Teredo-enabled host:
- Reading cloud instance metadata (AWS/GCP/Azure IMDS at 169.254.169.254)
- Accessing internal services on private networks
- Port scanning internal infrastructure
Suggested fix
Extract the embedded IPv4 from transition addresses and re-validate:
private function isPublicIp(string $ip): bool
{
if (stripos($ip, '::ffff:') === 0) {
$ipv4 = substr($ip, 7);
if (filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ip = $ipv4;
}
}
// Extract embedded IPv4 from IPv6 transition addresses
$embedded = $this->extractTransitionIpv4($ip);
if ($embedded !== null) {
return $this->isPublicIp($embedded);
}
return (bool) filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
private function extractTransitionIpv4(string $ip): ?string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
return null;
}
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$words = array_values(unpack('n8', $packed));
// NAT64 64:ff9b::/96
if ($words[0] === 0x0064 && $words[1] === 0xff9b
&& $words[2] === 0 && $words[3] === 0 && $words[4] === 0 && $words[5] === 0) {
return long2ip(($words[6] << 16) | $words[7]);
}
// 6to4 2002::/16
if ($words[0] === 0x2002) {
return long2ip(($words[1] << 16) | $words[2]);
}
// Teredo 2001:0000::/32
if ($words[0] === 0x2001 && $words[1] === 0x0000) {
return long2ip((($words[6] ^ 0xffff) << 16) | ($words[7] ^ 0xffff));
}
return null;
}
Credit
tonghuaroot (https://github.com/tonghuaroot)
IPv6 transition address bypass of webhook URL SSRF guard
Summary
The
ExternalUrlvalidation rule inapp/Rules/ExternalUrl.phpprevents webhook URLs from targeting private/internal addresses. TheisPublicIpmethod unwraps IPv4-mapped IPv6 (::ffff:x.x.x.x) and then relies on PHP'sfilter_varwithFILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGEto reject non-public addresses.PHP's built-in filter flags do not classify IPv6 transition addresses as private or reserved. Three families of transition mechanisms encode a private IPv4 target inside a globally-routable IPv6 wrapper, and all three pass through
filter_varunchecked.Affected code
app/Rules/ExternalUrl.phplines 72-88:PHP's
FILTER_FLAG_NO_PRIV_RANGEblocksfc00::/7(unique local) only.FILTER_FLAG_NO_RES_RANGEblocks::1,::,::ffff:0:0/96,fe80::/10,2001:db8::/32, andff00::/8. None of these cover:64:ff9b::/962002::/162001:0000::/320xffffffffBypass vectors
A super-admin configuring a webhook URL can supply an IPv6 literal containing a transition address. Since IPv6 literals are valid IP addresses, they go directly to
isPublicIpwithout DNS resolution.NAT64 -- targeting AWS IMDS 169.254.169.254
filter_var('64:ff9b::a9fe:a9fe', FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)returns'64:ff9b::a9fe:a9fe'(passes validation). On a NAT64-enabled host, this routes to169.254.169.254.6to4 -- targeting localhost 127.0.0.1
2002:7f00:1::embeds127.0.0.1in bits 16-47. Passesfilter_var.Teredo -- targeting 10.0.0.1
Last 32 bits
f5ff:fffeXORffff:ffff=0a00:0001=10.0.0.1. Passesfilter_var.DNS resolution path
The same bypass applies through DNS resolution. If an attacker controls a DNS zone, they can return an AAAA record containing a transition address for any hostname. The
resolveHostmethod usesdns_get_record($host, DNS_AAAA)and passes each resolved IPv6 address toisPublicIp, wherefilter_varagain fails to detect the embedded private IPv4.Impact
A super-admin who sets a webhook URL to a transition address (or a domain resolving to one) can trigger SSRF on a NAT64/6to4/Teredo-enabled host:
Suggested fix
Extract the embedded IPv4 from transition addresses and re-validate:
Credit
tonghuaroot (https://github.com/tonghuaroot)