The Blocklist That Forgot About Time
Source: Dev.to
What Actually Happened
CVE‑2026‑27127 was released for Craft CMS today. It’s a high‑severity SSRF via DNS rebinding. The advisory reads like standard boilerplate, but a detail in the patch notes caught my eye: this CVE bypasses CVE‑2025‑68437, a previous SSRF fix in the same codebase. The earlier fix was shipped, signed off by pentesters, yet an attacker could still walk straight through it.
That isn’t a simple bug—it’s a category error that survived a security review.
The original fix added an IP blocklist. Before making any outbound HTTP request, Craft resolves the target hostname and checks the IP against a deny list (AWS metadata 169.254.169.254, GCP, Azure, RFC 1918 ranges, loopback, etc.). If the IP is on the list, the request is blocked.
Reasonable. Standard practice. Wrong.
The vulnerable logic (reconstructed from the advisory) looks like this:
// Validation: DNS lookup #1
$ip = gethostbyname($hostname);
if (in_array($ip, $blocklist)) {
return false; // blocked
}
// Request: DNS lookup #2 (inside Guzzle)
$response = $client->get($url);
Two DNS lookups occur: one for validation and another inside the HTTP library (Guzzle).
An attacker who controls a DNS server can set TTL=0 on their domain. The first lookup returns a safe IP, passing the blocklist check. By the time Guzzle resolves the same hostname for the actual request, the DNS record has changed to 169.254.169.254. The request reaches the AWS metadata endpoint and leaks credentials. The blocklist never sees the real destination.
This Is TOCTOU Applied to DNS
Time‑of‑Check/Time‑of‑Use (TOCTOU) is one of the oldest bug classes. You check a condition at time T₁, act on it at time T₂, and something changes in between. Classic examples involve filesystem races: check a file, then open it, and the file is swapped in the gap.
DNS rebinding is the same bug on a different substrate. The condition being checked is “does this hostname resolve to a safe IP?” The action is the HTTP request. The gap between them is exploitable whenever an attacker controls the DNS server and can return different answers to different queries.
With TTL=0, the rebinding is near‑instant. There’s no caching to defeat. The window is microseconds to milliseconds—tight but reliably exploitable with a cooperating DNS server.
Similar patterns have appeared in bug‑bounty write‑ups since at least 2019 (e.g., a Python webhook service leaking AWS keys) and in CVE‑2024‑28224 (Ollama). The ecosystem keeps reinventing this mistake because the fix looks right: you check the IP, so you think you’re safe.
Why “Better Blocklist” Doesn’t Help
The instinct after seeing this bug is to expand the blocklist: add more ranges, perform a second validation pass, etc. That’s the wrong direction.
No blocklist, however comprehensive, fixes the structural problem. The hostname is resolved twice—once under your control, once by the HTTP library. As long as those are separate resolutions, an attacker with DNS control can return different answers to each query.
Even if your blocklist covers every cloud‑metadata range, every private IP, and every loopback address, the attacker’s DNS server can still:
- Return a safe IP for the validation query.
- Return a malicious IP (e.g., the metadata service) for the library’s query.
The blocklist is checking a different resolution than the one that actually matters.
Fix It at the Architecture Level
The Craft patch uses CURLOPT_RESOLVE, a libcurl option that pins a hostname to a specific IP for the duration of a request. The flow becomes:
- Resolve the hostname once.
- Validate the IP against the blocklist.
- Tell curl: “for this hostname, use this IP; don’t resolve again.”
- Make the request.
Only one resolution occurs, and the library never performs another DNS lookup.
Alternatively, rewrite the URL to use the IP directly and pass the original hostname as a Host header. The principle is the same: you control the IP the request actually goes to, rather than trusting DNS to return the same answer twice.
The pattern that consistently gets this right is:
Resolve at the trust boundary, validate, then pin.
Never hand a hostname back to a library that will resolve it independently. Recent SSRF‑protecting libraries all share this design decision, treating DNS resolution as a one‑shot operation at the perimeter.
The Part That Bothers Me
The 2025 fix was a genuine attempt. Someone identified the missing validation, wrote the blocklist, and shipped it. A security review presumably took place and passed.
“Does this IP look safe?” is the wrong question. The right question is “will the request actually go to this IP?” Those are only the same if you resolve once and pin. The 2025 fix answered the wrong question—competently—but missed the underlying model of DNS during an HTTP request.
If you’re implementing SSRF protection in your own code, especially when using a request library that handles DNS internally, ask yourself:
- Did I resolve the hostname exactly once?
- Did I ensure the library used that same resolution for the actual request?
If you can’t answer “yes” to both, your blocklist is merely decorative.
Craft CMS fix is in 4.16.19 and 5.8.23. If you’re running a self‑hosted instance with GraphQL asset creation enabled, update now.