GHSA-GP2F-7WCM-5FHX
Vulnerability from github – Published: 2026-02-23 22:16 – Updated: 2026-02-23 22:16Summary
The SSRF validation in Craft CMS’s GraphQL Asset mutation performs DNS resolution separately from the HTTP request. This Time-of-Check-Time-of-Use (TOCTOU) vulnerability enables DNS rebinding attacks, where an attacker’s DNS server returns different IP addresses for validation compared to the actual request.
This is a bypass of the security fix for CVE-2025-68437 (GHSA-x27p-wfqw-hfcc) that allows access to all blocked IPs, not just IPv6 endpoints.
Severity
Bypass of cloud metadata SSRF protection for all blocked IPs
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
Vulnerable Code Flow
The code at src/gql/resolvers/mutations/Asset.php performs two separate DNS lookups:
// VALIDATION PHASE: First DNS resolution at time T1
private function validateHostname(string $url): bool
{
$hostname = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($hostname); // DNS Lookup #1 - Returns safe IP
if (in_array($ip, [
'169.254.169.254', // AWS, GCP, Azure IMDS
'169.254.170.2', // AWS ECS metadata
'100.100.100.200', // Alibaba Cloud
'192.0.0.192', // Oracle Cloud
])) {
return false; // Check passes - IP looks safe
}
return true;
}
// ... time gap between validation and request ...
// REQUEST PHASE: Second DNS resolution at time T2 (inside Guzzle)
$response = $client->get($url); // DNS Lookup #2 - Guzzle resolves DNS AGAIN
// Now returns 169.254.169.254!
Root Cause
Two separate DNS lookups occur:
1. Validation: gethostbyname() in validateHostname()
2. Request: Guzzle's internal DNS resolution via libcurl
An attacker controlling a DNS server can return different IPs for each query.
Bypass Mechanism
+-----------------------------------------------------------------------------+
| Attacker's DNS Server: evil.attacker.com |
+-----------------------------------------------------------------------------+
| Query 1 (Validation - T1): |
| Request: A record for evil.attacker.com |
| Response: 1.2.3.4 (safe IP, TTL: 0) |
| Result: Validation PASSES |
+-----------------------------------------------------------------------------+
| Query 2 (Guzzle Request - T2): |
| Request: A record for evil.attacker.com |
| Response: 169.254.169.254 (metadata IP, TTL: 0) |
| Result: Request goes to blocked IP -> CREDENTIALS STOLEN |
+-----------------------------------------------------------------------------+
Target Endpoints via DNS Rebinding
DNS rebinding allows access to all blocked IPs:
| Target | Rebind To | Impact |
|---|---|---|
| AWS IMDS | 169.254.169.254 |
IAM credentials, instance identity |
| AWS ECS | 169.254.170.2 |
Container credentials |
| GCP Metadata | 169.254.169.254 |
Service account tokens |
| Azure Metadata | 169.254.169.254 |
Managed identity tokens |
| Alibaba Cloud | 100.100.100.200 |
Instance credentials |
| Oracle Cloud | 192.0.0.192 |
Instance metadata |
| Internal Services | 127.0.0.1, 10.x.x.x |
Internal APIs, databases |
Attack Scenario
- Attacker sets up DNS server with alternating responses
- Attacker sends mutation with
url: "http://evil.attacker.com/latest/meta-data/" - First DNS query returns safe IP (e.g.,
1.2.3.4) → validation passes - Second DNS query returns metadata IP (
169.254.169.254) → request to metadata - Attacker retrieves credentials from ANY cloud provider
- Attacker can now achieve code execution by creating new instances with their SSH key
Remediation
Fix: DNS Pinning with CURLOPT_RESOLVE
Pin the DNS resolution - use the same resolved IP for both validation and request:
private function validateHostname(string $url): bool
{
$hostname = parse_url($url, PHP_URL_HOST);
// Resolve once
$ip = gethostbyname($hostname);
// Validate the resolved IP
if (in_array($ip, [
'169.254.169.254', '169.254.170.2',
'100.100.100.200', '192.0.0.192',
])) {
return false;
}
// Store for later use
$this->pinnedDNS[$hostname] = $ip;
return true;
}
// When making the request - CRITICAL: Use pinned IP
protected function makeRequest(string $url): ResponseInterface
{
$hostname = parse_url($url, PHP_URL_HOST);
$ip = $this->pinnedDNS[$hostname] ?? null;
$options = [];
if ($ip) {
// Force Guzzle/curl to use the SAME IP we validated
$options['curl'] = [
CURLOPT_RESOLVE => [
"$hostname:80:$ip",
"$hostname:443:$ip"
]
];
}
return $this->client->get($url, $options);
}
Alternative: Single Resolution with Immediate Use
// Resolve to IP and use IP directly in URL
$ip = gethostbyname($hostname);
if (in_array($ip, $blockedIPs)) {
return false;
}
// Make request directly to IP with Host header
$client->get("http://$ip" . parse_url($url, PHP_URL_PATH), [
'headers' => [
'Host' => $hostname
]
]);
Additional Mitigations
| Mitigation | Description |
|---|---|
| DNS Pinning (CURLOPT_RESOLVE) | Force same IP for validation and request |
| Single IP-based request | Use resolved IP directly in URL |
| Implement IMDSv2 | Requires token header (infrastructure-level) |
| Network egress filtering | Block metadata IPs at network level |
Resources
- https://github.com/craftcms/cms/commit/a4cf3fb63bba3249cf1e2882b18a2d29e77a8575
- GHSA-x27p-wfqw-hfcc - Original SSRF vulnerability (CVE-2025-68437)
- DNSrebinder - Lightweight Python DNS server for testing DNS rebinding vulnerabilities; responds with legitimate IP for first N queries, then rebinds to target IP
- Singularity DNS Rebinding Tool
- rbndr DNS Rebinding Service
- DNS Rebinding Attacks Explained
- CURLOPT_RESOLVE Documentation
- OWASP SSRF Prevention Cheat Sheet
{
"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-27127"
],
"database_specific": {
"cwe_ids": [
"CWE-367"
],
"github_reviewed": true,
"github_reviewed_at": "2026-02-23T22:16:01Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nThe SSRF validation in Craft CMS\u2019s GraphQL Asset mutation performs DNS resolution **separately** from the HTTP request. This Time-of-Check-Time-of-Use (TOCTOU) vulnerability enables DNS rebinding attacks, where an attacker\u2019s DNS server returns different IP addresses for validation compared to the actual request.\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)) that allows access to all blocked IPs, not just IPv6 endpoints.\n\n## Severity\n\nBypass of cloud metadata SSRF protection for all blocked IPs\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### Vulnerable Code Flow\n\nThe code at `src/gql/resolvers/mutations/Asset.php` performs two separate DNS lookups:\n\n```php\n// VALIDATION PHASE: First DNS resolution at time T1\nprivate function validateHostname(string $url): bool\n{\n $hostname = parse_url($url, PHP_URL_HOST);\n $ip = gethostbyname($hostname); // DNS Lookup #1 - Returns safe IP\n\n if (in_array($ip, [\n \u0027169.254.169.254\u0027, // AWS, GCP, Azure IMDS\n \u0027169.254.170.2\u0027, // AWS ECS metadata\n \u0027100.100.100.200\u0027, // Alibaba Cloud\n \u0027192.0.0.192\u0027, // Oracle Cloud\n ])) {\n return false; // Check passes - IP looks safe\n }\n return true;\n}\n\n// ... time gap between validation and request ...\n\n// REQUEST PHASE: Second DNS resolution at time T2 (inside Guzzle)\n$response = $client-\u003eget($url); // DNS Lookup #2 - Guzzle resolves DNS AGAIN\n // Now returns 169.254.169.254!\n```\n\n### Root Cause\n\nTwo separate DNS lookups occur:\n1. **Validation**: `gethostbyname()` in `validateHostname()`\n2. **Request**: Guzzle\u0027s internal DNS resolution via libcurl\n\nAn attacker controlling a DNS server can return different IPs for each query.\n\n### Bypass Mechanism\n\n```\n+-----------------------------------------------------------------------------+\n| Attacker\u0027s DNS Server: evil.attacker.com |\n+-----------------------------------------------------------------------------+\n| Query 1 (Validation - T1): |\n| Request: A record for evil.attacker.com |\n| Response: 1.2.3.4 (safe IP, TTL: 0) |\n| Result: Validation PASSES |\n+-----------------------------------------------------------------------------+\n| Query 2 (Guzzle Request - T2): |\n| Request: A record for evil.attacker.com |\n| Response: 169.254.169.254 (metadata IP, TTL: 0) |\n| Result: Request goes to blocked IP -\u003e CREDENTIALS STOLEN |\n+-----------------------------------------------------------------------------+\n```\n\n---\n\n## Target Endpoints via DNS Rebinding\n\nDNS rebinding allows access to all blocked IPs:\n\n| Target | Rebind To | Impact |\n|--------|-----------|--------|\n| **AWS IMDS** | `169.254.169.254` | IAM credentials, instance identity |\n| **AWS ECS** | `169.254.170.2` | Container credentials |\n| **GCP Metadata** | `169.254.169.254` | Service account tokens |\n| **Azure Metadata** | `169.254.169.254` | Managed identity tokens |\n| **Alibaba Cloud** | `100.100.100.200` | Instance credentials |\n| **Oracle Cloud** | `192.0.0.192` | Instance metadata |\n| **Internal Services** | `127.0.0.1`, `10.x.x.x` | Internal APIs, databases |\n\n---\n\n### Attack Scenario\n\n1. Attacker sets up DNS server with alternating responses\n2. Attacker sends mutation with `url: \"http://evil.attacker.com/latest/meta-data/\"`\n3. First DNS query returns safe IP (e.g., `1.2.3.4`) \u2192 validation passes\n4. Second DNS query returns metadata IP (`169.254.169.254`) \u2192 request to metadata\n5. Attacker retrieves credentials from ANY cloud provider\n6. **Attacker can now achieve code execution by creating new instances with their SSH key**\n\n---\n\n## Remediation\n\n### Fix: DNS Pinning with CURLOPT_RESOLVE\n\nPin the DNS resolution - use the same resolved IP for both validation and request:\n\n```php\nprivate function validateHostname(string $url): bool\n{\n $hostname = parse_url($url, PHP_URL_HOST);\n\n // Resolve once\n $ip = gethostbyname($hostname);\n\n // Validate the resolved IP\n if (in_array($ip, [\n \u0027169.254.169.254\u0027, \u0027169.254.170.2\u0027,\n \u0027100.100.100.200\u0027, \u0027192.0.0.192\u0027,\n ])) {\n return false;\n }\n\n // Store for later use\n $this-\u003epinnedDNS[$hostname] = $ip;\n\n return true;\n}\n\n// When making the request - CRITICAL: Use pinned IP\nprotected function makeRequest(string $url): ResponseInterface\n{\n $hostname = parse_url($url, PHP_URL_HOST);\n $ip = $this-\u003epinnedDNS[$hostname] ?? null;\n\n $options = [];\n if ($ip) {\n // Force Guzzle/curl to use the SAME IP we validated\n $options[\u0027curl\u0027] = [\n CURLOPT_RESOLVE =\u003e [\n \"$hostname:80:$ip\",\n \"$hostname:443:$ip\"\n ]\n ];\n }\n\n return $this-\u003eclient-\u003eget($url, $options);\n}\n```\n\n### Alternative: Single Resolution with Immediate Use\n\n```php\n// Resolve to IP and use IP directly in URL\n$ip = gethostbyname($hostname);\n\nif (in_array($ip, $blockedIPs)) {\n return false;\n}\n\n// Make request directly to IP with Host header\n$client-\u003eget(\"http://$ip\" . parse_url($url, PHP_URL_PATH), [\n \u0027headers\u0027 =\u003e [\n \u0027Host\u0027 =\u003e $hostname\n ]\n]);\n```\n\n### Additional Mitigations\n\n| Mitigation | Description |\n|------------|-------------|\n| DNS Pinning (CURLOPT_RESOLVE) | Force same IP for validation and request |\n| Single IP-based request | Use resolved IP directly in URL |\n| Implement IMDSv2 | Requires token header (infrastructure-level) |\n| Network egress filtering | Block metadata IPs at network level |\n\n---\n\n## Resources\n\n- https://github.com/craftcms/cms/commit/a4cf3fb63bba3249cf1e2882b18a2d29e77a8575\n- [GHSA-x27p-wfqw-hfcc](https://github.com/craftcms/cms/security/advisories/GHSA-x27p-wfqw-hfcc) - Original SSRF vulnerability (CVE-2025-68437)\n- [DNSrebinder](https://github.com/mogwailabs/DNSrebinder) - Lightweight Python DNS server for testing DNS rebinding vulnerabilities; responds with legitimate IP for first N queries, then rebinds to target IP\n- [Singularity DNS Rebinding Tool](https://github.com/nccgroup/singularity)\n- [rbndr DNS Rebinding Service](https://github.com/taviso/rbndr)\n- [DNS Rebinding Attacks Explained](https://unit42.paloaltonetworks.com/dns-rebinding/)\n- [CURLOPT_RESOLVE Documentation](https://curl.se/libcurl/c/CURLOPT_RESOLVE.html)\n- OWASP SSRF Prevention Cheat Sheet",
"id": "GHSA-gp2f-7wcm-5fhx",
"modified": "2026-02-23T22:16:01Z",
"published": "2026-02-23T22:16:01Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/craftcms/cms/security/advisories/GHSA-gp2f-7wcm-5fhx"
},
{
"type": "WEB",
"url": "https://github.com/craftcms/cms/security/advisories/GHSA-x27p-wfqw-hfcc"
},
{
"type": "WEB",
"url": "https://github.com/craftcms/cms/commit/a4cf3fb63bba3249cf1e2882b18a2d29e77a8575"
},
{
"type": "WEB",
"url": "https://curl.se/libcurl/c/CURLOPT_RESOLVE.html"
},
{
"type": "PACKAGE",
"url": "https://github.com/craftcms/cms"
},
{
"type": "WEB",
"url": "https://github.com/mogwailabs/DNSrebinder"
},
{
"type": "WEB",
"url": "https://github.com/nccgroup/singularity"
},
{
"type": "WEB",
"url": "https://github.com/taviso/rbndr"
},
{
"type": "WEB",
"url": "https://unit42.paloaltonetworks.com/dns-rebinding"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Craft CMS has Cloud Metadata SSRF Protection Bypass via DNS Rebinding"
}
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.