# Grav: 2FA Bypass via 'login.regenerate2FASecret' - Secret Rotation During Pending Challenge

Grav login plugin's regenerate2FASecret task only checks user->exists(), letting an attacker in the 2FA-pending window rotate the victim's TOTP secret and complete login with password alone.

- Payout: Research
- Severity: high
- Program: Grav
- Found by: nicl4ssic
- Disclosed: 2026-09-02
- CVE: CVE-2026-62669
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-7mgc-c7pq-3rr3
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-7mgc-c7pq-3rr3
- Tags: area:web domain:web impact:ato kind:research platform:ghsa sev:high tech:php technique:source-review vuln:access-control vuln:auth-bypass vuln:csrf vuln:mfa-bypass

**Target:** Grav login plugin task endpoint (login.regenerate2FASecret)
**Root cause:** taskRegenerate2FASecret checks only $user->exists() instead of $user->authorized, and the session user is set before TOTP verification completes, so a pending-challenge session can rotate the 2FA secret. The task also lacks a CSRF nonce.
**Steps:**
1. Enable 2FA (plugins.login.twofa_enabled: true) and configure TOTP on the user account.
2. POST credentials with `task=login.login` and the login-form-nonce to land in the 2FA-pending state, keeping the session cookie.
3. GET `/grav/login/task:login.regenerate2FASecret` with the session cookie (no nonce) — response returns the new `secret`.
4. Compute TOTP from the returned secret, e.g. `pyotp.TOTP('FS5PSYNP24YHX3AM3DP3PADGRIPVB4K5').now()`.
5. POST `task=login.twofa&2fa_code=<code>` to complete the 2FA flow and become fully authenticated as the victim.
**How found:** Code review of the login plugin: noticed userLogin() sets $session->user before TOTP verification, then found taskRegenerate2FASecret gated only on $user->exists() and missing from the nonce-check switch. Confirmed live after enabling 2FA.
**Exploitation:** Attacker who knows the victim's password logs in to reach the pending-2FA state, calls the unguarded regenerate2FASecret task to overwrite the victim's twofa_secret with an attacker-chosen value returned in the JSON response, computes a valid TOTP from it, and submits it to finish login. Victim's real secret is overwritten on disk, locking them out.
**Escalation:** No CSRF nonce on the task means a logged-in victim visiting `/login/task:login.regenerate2FASecret` from an attacker page has their 2FA secret silently rotated.
**Insight:** In multi-step auth flows, audit every task/endpoint reachable in the intermediate 'authenticated but not authorized' state — checks like exists() vs authorized() and nonce coverage per task branch are where the second factor leaks.

