Feb 24, 2026·6 min read·10 visits
Craft CMS failed to atomically validate token usage limits. Attackers can race simultaneous requests to reuse 'single-use' tokens (like admin impersonation links) before the database updates. Fixed in 4.16.19 and 5.8.23 via Mutex locking.
A classic Time-of-Check Time-of-Use (TOCTOU) race condition in Craft CMS allows attackers to bypass usage limits on sensitive tokens. By flooding the server with concurrent requests, a single-use administrative impersonation token can be reused multiple times, leading to potential privilege escalation and unauthorized access.
In the world of web security, "once" is a very difficult number to enforce. We build systems that rely on the concept of ephemeral access: password reset links, email verification codes, and the holy grail of Craft CMS workflow—User Impersonation Tokens.
These tokens are the digital equivalent of a "Self-Destructs in 5 Seconds" tape. The developer promises that a token can be used exactly N times (usually 1). Once consumed, it vanishes into the ether, preventing replay attacks or accidental reuse. It is a simple contract: Check Limit -> Permit Access -> Increment Counter.
But here is the dirty secret of web development: between the "Check" and the "Increment," time exists. And where time exists, hackers exist. CVE-2026-27128 is a pristine example of what happens when you trust PHP to handle concurrency without adult supervision. It turns out, if you scream at Craft CMS loud enough (and in parallel), it forgets how to count.
The vulnerability is a textbook Time-of-Check Time-of-Use (TOCTOU) race condition located in the Tokens service. This is CWE-367 in its purest form.
The logic flaw occurred because the application treated a database transaction as a casual conversation rather than a strictly serialized event. Here is the sequence of events that the code thought was happening:
usageCount < usageLimit? (e.g., is 0 < 1?).usageCount or delete the token.In a single-threaded universe, this is fine. But the web is not single-threaded. When an attacker sends 50 requests simultaneously (using HTTP/2 multiplexing or just a really fast script), all 50 requests might hit Step 1 and Step 2 before the first request reaches Step 4.
Since the database hasn't been updated yet, all 50 threads see usageCount = 0. All 50 threads pass the check. All 50 threads grant access. By the time the database catches up, the damage is done. The "single-use" token has just been used 50 times.
Let's dissect the vulnerable code in src/services/Tokens.php. This is a simplified view of the crime scene before the fix.
// The Vulnerable Logic
$result = (new Query())
->select(['id', 'usageLimit', 'usageCount'])
->from([Table::TOKENS])
->where(['token' => $token])
->one();
// THE GAP IS HERE
// Thousands of CPU cycles pass between the SELECT above
// and the logic below.
if ($result['usageLimit']) {
// The fatal check: PHP is checking stale memory
if ($result['usageCount'] < $result['usageLimit']) {
// Access Granted!
$this->incrementTokenUsageCountById($result['id']);
} else {
// Too late (in theory)
$this->deleteTokenById($result['id']);
}
}The fix implemented in commit 3e4afe18279951c024c64896aa2b93cda6d95fdf introduces a Mutex (Mutual Exclusion) lock. This forces the requests to form a single-file line. Request B cannot even check the token until Request A has finished updating it.
// The Fix (Mutex Implementation)
$mutex = Craft::$app->getMutex();
$lockKey = "token:$token";
// Try to acquire lock for 5 seconds
if (!$mutex->acquire($lockKey, 5)) {
return false; // Go away if busy
}
try {
// Re-fetch logic happens INSIDE the lock
// ... validation ...
} finally {
$mutex->release($lockKey);
}> [!NOTE]
> While a Mutex works, a more performant database-native solution would have been an atomic update: UPDATE tokens SET usageCount = usageCount + 1 WHERE token = ? AND usageCount < usageLimit. If the affected rows = 0, the token is dead. No Mutex required. But hey, a patch is a patch.
To exploit this, we don't need complex memory corruption exploits or ROP chains. We just need speed.
asyncio script.We configure the attack to hold back the TCP connection establishment and send the last byte of the HTTP request for 20 threads simultaneously (the "Last-Byte Sync" technique).
In a successful exploitation, the attacker ends up with multiple valid session cookies for the target user, effectively bypassing the security control that was supposed to limit exposure to a single click.
Why is getting 5 sessions better than 1? Persistence and stealth.
If I steal a single-use token and use it, the legitimate user might click the link 5 minutes later, get an error, and alert IT. "Hey, my link didn't work."
However, if I race the condition, I can consume the token and potentially leave it in a state where the legitimate user might still squeeze in (depending on how the race resolves on the delete), or more likely, I establish multiple sessions on different devices. One session is for active browsing; the others are backups in case I get kicked out.
Furthermore, in complex workflows where tokens trigger actions (like "Approve Expense Report" or "Deploy to Production"), replaying the token could trigger the business logic multiple times, leading to financial loss or corruption of state.
The official fix relies on Craft's Mutex component. This is effective, but it comes with a major infrastructure caveat that many sysadmins miss.
The Trap: If your Craft CMS application is load-balanced across multiple servers (e.g., AWS Autoscaling Group) and you are using the default FileMutex, you are still vulnerable. The file lock only exists on the local server's filesystem. Request A hits Server 1 (locked), but Request B hits Server 2 (unlocked). Race condition succeeds.
The Proper Fix:
config/app.php is using RedisMutex, MysqlMutex, or PgsqlMutex for distributed locking.// config/app.php
'components' => [
'mutex' => [
'class' => \craft\mutex\Mutex::class, // Ensure this maps to a distributed driver
],
],If you cannot patch immediately, you can mitigate this at the WAF level by strictly rate-limiting endpoints like actions/users/login-by-token, though this is a fragile bandage at best.
CVSS:4.0/AV:N/AC:H/AT:P/PR:H/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.19 | 4.16.19 |
Craft CMS Pixel & Tonic | >= 5.0.0-RC1, < 5.8.23 | 5.8.23 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-367 |
| Attack Vector | Network (Race Condition) |
| CVSS Score | 6.9 (Medium) |
| Impact | Security Bypass / Privilege Escalation |
| Affected Component | Tokens Service (getTokenRoute) |
| Exploit Status | PoC Available (Theoretical) |
The product checks the state of a resource before using it, but the resource's state can change between the check and the use in a way that invalidates the results of the check.