GitHub Security Advisory: GHSA-gp2f-7wcm-5fhx
Package: craftcms/cms (COMPOSER)
Severity: HIGH (CVSS 0.0)
CWE: CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition
Vulnerable versions: >= 5.0.0-RC1, <= 5.8.22
Patched in: 5.8.23

Summary: Craft CMS has Cloud Metadata SSRF Protection Bypass via DNS Rebinding

Details:
## Summary

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](https://github.com/craftcms/cms/security/advisories/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:

```php
// 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.2