CVE-2026-72701: Grav · Non constant time nonce comparison in Utils::verifyNonce() used for CSRF protection
Affects getgrav/gravSource GCVE Database
Not known to be exploited · CVSS 6.3 Medium : upgrade to 2.0.16 when convenient.
Data as of 26 Oct 2026, 17:16 UTC
- EPSS
- 0.2% chance of exploitation in 30 days – higher than 11% of CVEsExploit Prediction Scoring System: the likelihood attackers will use this flaw soon.
- Patch
- Available – fixed in 2.0.16
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:NExploitation evidence
Disclosed: evidence found.
CVE published 25 Aug 2026
Detectable: no evidence on record.
No public nuclei template indexed
Weaponised: no evidence on record.
No public exploit code indexed in Metasploit or Exploit-DB
Exploited in the wildExploited: no evidence on record.
Not in CISA KEV and no source reports exploitation
Observed by OffSeq MirageSeen by Mirage: no evidence on record.
No attack traffic recorded by OffSeq Mirage sensors
A missing step is not evidence of safety. Sources: cve.org (opens in a new tab), the Metasploit, Exploit-DB and nuclei-templates index as of 26 Oct 2026.
What to do
Upgrade getgrav/grav to 2.0.16.
Affected software
| Product | Affected | Fixed in |
|---|---|---|
| Packagistgetgrav/grav | Affected
| Fixed in
|
Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.
Description
## Summary `Grav\Common\Utils::verifyNonce()`, the core function Grav and its plugins use to validate CSRF nonces, compares the submitted nonce to the expected value with PHP's `===` operator instead of `hash_equals()`. `===` on strings short circuits at the first differing byte, so the comparison time leaks how many leading bytes of a guess are correct. This is CWE-208, Observable Timing Discrepancy. The codebase already knows to avoid this pattern. `hash_equals()` is used for the equivalent purpose in four other places I found: `system/src/Grav/Common/Session.php`, `system/src/Grav/Framework/Cache/Adapter/FileCache.php`, `system/src/Grav/Common/Scheduler/Scheduler.php` (the webhook token check), and `system/src/Grav/Common/Scheduler/JobQueue.php`. `Utils::verifyNonce()` is the one place I found that still uses a plain equality check for a secret comparison. ## Affected product and version Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 ## Affected code `system/src/Grav/Common/Utils.php`, lines 1512 to 1521: ```php public static function verifyNonce($nonce, $action) { //Safety check for multiple nonces if (is_array($nonce)) { $nonce = array_shift($nonce); } //Nonce generated 0-12 hours ago if ($nonce === self::getNonce($action)) { return true; } //Nonce generated 12-24 hours ago return $nonce === self::getNonce($action, true); } ``` The nonce itself is `md5($tick . '|' . $action . '|' . $username . '|' . session_id() . '|' . Security::getNonceKey())`, computed in the private `generateNonceString()` a few lines above. `Security::getNonceKey()` is an installation level secret. So the value being compared with `===` is a value derived from a secret, which is exactly the case `hash_equals()` exists for. ## Proof of concept, verified, real output I could not exploit this end to end over a real network from this sandbox, since that requires a live deployment and a timing measurement setup outside a single machine. What I did verify directly, by running real code, is that the underlying primitive this function relies on, PHP's `===` string comparison, is not constant time in the PHP build actually used here, and that a measurable timing signal is still present at the exact length Grav's nonces have, 32 hex characters, an md5 digest. Step 1, confirm PHP build: ``` $ php -v PHP 8.3.6 (cli) (built: Jul 16 2026 18:30:41) (NTS) ``` Step 2, benchmark script, measures the median time of `$a === $b` over many trials, once with a long string to establish a clean signal, once at the real 32 byte nonce length: ```php <?php // timing_poc2.php function timeCompare(string $a, string $b, int $iterations): float { $r = null; $start = hrtime(true); for ($i = 0; $i < $iterations; $i++) { $r = ($a === $b); } $end = hrtime(true); return ($end - $start) / $iterations; } function median(array $arr): float { sort($arr); $n = count($arr); $mid = intdiv($n, 2); return $n % 2 ? $arr[$mid] : ($arr[$mid - 1] + $arr[$mid]) / 2; } function runExperiment(int $len, int $iterations, int $trials): array { $secret = bin2hex(random_bytes((int)ceil($len / 2))); $secret = substr($secret, 0, $len); $wrongEarly = $secret; $wrongEarly[0] = ($secret[0] === 'a') ? 'b' : 'a'; $wrongLate = $secret; $last = $len - 1; $wrongLate[$last] = ($secret[$last] === 'a') ? 'b' : 'a'; $earlyTimes = []; $lateTimes = []; timeCompare($wrongEarly, $secret, 20000); timeCompare($wrongLate, $secret, 20000); for ($t = 0; $t < $trials; $t++) { $earlyTimes[] = timeCompare($wrongEarly, $secret, $iterations); $lateTimes[] = timeCompare($wrongLate, $secret, $iterations); } return [median($earlyTimes), median($lateTimes)]; } echo "=== Length 4096 bytes, establishes the primitive is not constant time ===\n"; [$e, $l] = runExperiment(4096, 20000, 15); printf("Median mismatch at position 0 : %.2f ns/op\n", $e); printf("Median mismatch at last position : %.2f ns/op\n", $l); printf("Ratio (late/early) : %.2fx\n\n", $l / $e); echo "=== Length 32 bytes, the actual Grav nonce length, md5 hex output ===\n"; [$e2, $l2] = runExperiment(32, 200000, 21); printf("Median mismatch at position 0 : %.2f ns/op\n", $e2); printf("Median mismatch at last position : %.2f ns/op\n", $l2); printf("Ratio (late/early) : %.2fx\n", $l2 / $e2); ``` Step 3, run it three times to confirm the result is reproducible and not noise: ``` $ php timing_poc2.php ``` Actual output, run 1: ``` === Length 4096 bytes, establishes the primitive is not constant time === Median mismatch at position 0 : 13.83 ns/op Median mismatch at last position : 338.58 ns/op Ratio (late/early) : 24.48x === Length 32 bytes, the actual Grav nonce length, md5 hex output === Median mismatch at position 0 : 14.13 ns/op Median mismatch at last pos
References
- github.com/getgrav/grav/security/advisories/GHSA-38p6-h87p-r4cg (opens in a new tab)
- nvd.nist.gov/vuln/detail/CVE-2026-72701 (opens in a new tab)
- github.com/getgrav/grav/commit/a076b9073abc51c1df924529d65773e44950c5c4 (opens in a new tab)
- github.com/getgrav/grav (opens in a new tab)
- github.com/getgrav/grav/releases/tag/2.0.16 (opens in a new tab)
- vulncheck.com/advisories/grav-cms-before-timing-attack-via-verifynonce (opens in a new tab)