CVE case study
CVE-2026-62671: A Cross-Site GET Could Rotate a Grav TOTP Secret
A login task regenerated and saved the active user's TOTP secret without a CSRF nonce. Under Grav's default SameSite=Lax setting, an off-site top-level GET could desynchronize a logged-in user's authenticator.
- Severity
- Medium (5.4)
- Scoring
- CVSS 3.1
- Weakness
- CWE-352
- Affected
- grav-plugin-login 3.8.10 and earlier
- Remediation state
- Upgrade to grav-plugin-login 3.8.11
- Advisory published
- 29 Jun 2026
Official vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L
Why it matters
The login plugin exposed login.regenerate2FASecret as a state-changing task with no anti-CSRF nonce. Grav core could resolve that task from the URL, so it was reachable with a GET request.
A top-level cross-site GET carries a session cookie under Grav's default SameSite=Lax setting. For a logged-in, 2FA-enrolled user, the task replaced and saved the TOTP secret tied to the account.
The result was authenticator desynchronization, not account takeover. The attacker could not read the new secret across origins, the navigation was visible, and the user could recover by re-enrolling or asking an administrator for a reset.
How I found it
I enumerated the frontend login tasks and compared their CSRF handling in the controller switch. Login, password recovery, magic-link, and 2FA-cancel actions verified a nonce, but login.regenerate2FASecret had no matching case and the switch had no default deny.
I traced Grav's task resolution back to the URI parameter and confirmed that the task could be dispatched with GET. The handler then used the current session user, created a new TOTP secret, and saved it without a later method or nonce check.
In a disposable local lab, I recorded only hashes of the original and replacement secrets. A top-level navigation changed the hash, and a TOTP generated from the original secret no longer verified. I kept the replacement secret and QR code out of the evidence.
Root cause
Grav core accepted a task name from either the request body or a URI parameter and dispatched it without a global method or nonce policy.
The plugin's per-task switch verified nonces for several login operations but omitted regenerate2FASecret. The switch also had no fail-closed default for unlisted state-changing tasks.
The handler used the ambient session user, generated a fresh secret, assigned it to twofa_secret, and saved the account without a later CSRF check.
Source-to-sink trace
- 01Cross-site source
Top-level GET to task:login.regenerate2FASecretA link or navigation can carry the victim's Lax session cookie while selecting the state-changing task in the URL.
- 02Core dispatch
TaskServiceProvider and TasksProcessorCore resolves the task from the URI and fires the event without a global POST or nonce requirement.
- 03Missing plugin gate
login.php · loginController()The per-task nonce switch in 3.8.10 has no case for secret regeneration and no fail-closed default.
- 04Persistent sink
classes/Controller.php · taskRegenerate2FASecret()The handler replaces
twofa_secreton the ambient session user and saves the account.
Safe proof of concept
Prerequisites
- A disposable local Grav lab with grav-plugin-login 3.8.10, 2FA enabled, and the default SameSite=Lax session setting.
- One synthetic, 2FA-enrolled account with an active local session and a second localhost origin for the navigation page.
- Git for the separate offline source comparison.
Step-by-step reproduction
- Clone the public login-plugin repository into a unique temporary directory and export
login.phpplusclasses/Controller.phpfrom 3.8.10 and the published fix commit. - Run the focused source search below. Confirm that 3.8.10 registers the task and saves a new secret, but has no task-specific nonce or POST gate.
- In the disposable lab, record a SHA-256 hash of the synthetic account's current TOTP secret and generate one local code from the old secret. Do not copy either secret into the transcript.
- Serve the benign local navigation page from the second localhost origin, sign in to the local Grav lab, and follow the link in the top-level window.
- Record that the browser reaches the JSON response, the stored-secret hash changes, and the old local TOTP code no longer verifies. Treat the visible navigation and cross-origin unreadability as part of the evidence boundary.
- Upgrade to 3.8.11. Confirm that GET, nonce-free POST, and a valid-nonce request from a not-fully-authorized session leave the hash unchanged. Only the legitimate POST with a valid
login-form-noncefrom a fully authorized session should rotate the synthetic secret.
Compare the affected and fixed task gates
LAB_DIR=$(mktemp -d "\${TMPDIR:-/tmp}/grav-login-cve.XXXXXX")
git clone https://github.com/getgrav/grav-plugin-login.git "$LAB_DIR/login"
git -C "$LAB_DIR/login" show 3.8.10:login.php > "$LAB_DIR/login-affected.php"
git -C "$LAB_DIR/login" show 3.8.10:classes/Controller.php > "$LAB_DIR/controller-affected.php"
git -C "$LAB_DIR/login" show 5d1b722298cb947d8f434025d121b99152a2c630:login.php > "$LAB_DIR/login-fixed.php"
rg -n -C 6 \
'regenerate2FASecret|REQUEST_METHOD|login-form-nonce|twofa_secret|user->save' \
"$LAB_DIR"/*.phpBenign navigation page for the localhost lab
<!doctype html>
<meta charset="utf-8">
<title>Local Grav CSRF check</title>
<a href="http://localhost:8080/login/task:login.regenerate2FASecret">
Open the local Grav proof
</a>Record only the comparison result
affected 3.8.10: GET + active Lax session -> secret hash changed
fixed 3.8.11: GET -> rejected, hash unchanged
fixed 3.8.11: POST without nonce -> rejected, hash unchanged
fixed 3.8.11: valid nonce + unauthorized session -> rejected
fixed 3.8.11: valid nonce + fully authorized session -> acceptedExpected evidence
- The affected source contains the regeneration task and persistent save but no matching nonce or method gate in the controller switch.
- The local top-level navigation changes only the synthetic account's secret hash; the attacker origin cannot read the response or replacement secret.
- The fixed source requires POST, a valid nonce, and a fully authorized session. Only the legitimate fixed-version request changes the local hash.
Repeat with SameSite=Strict or without an active session, then repeat on 3.8.11 with GET and with a nonce-free POST. None of those paths should change the synthetic account's secret hash.
On 3.8.11, require all four outcomes: GET rejected, POST without a valid nonce rejected, valid nonce from a not-fully-authorized session rejected, and valid nonce from a fully authorized session accepted. Confirm the account file changes only in the fourth case.
Impact
An anonymous attacker could make a logged-in, 2FA-enrolled user lose synchronization with their authenticator after the user followed a top-level link. The disruption was limited to that account and was recoverable.
The exploit required 2FA to be enabled, an enrolled user with an active session, a cookie policy that allowed the top-level cross-site GET, and user interaction. An iframe, image, fetch request, or cross-site POST did not provide the same default Lax-cookie path.
The response did not expose the replacement secret to the attacker's origin, so the finding did not provide session theft, credential theft, or account takeover.
Fix and retest
Upgrade to grav-plugin-login 3.8.11. The patch requires POST, verifies a login-form-nonce, and requires the session user to be fully authorized before the handler can regenerate a secret. The 2FA field template emits the nonce and the legitimate JavaScript action sends it.
Retest four paths: GET must not change the stored secret, POST without a valid nonce must not change it, a valid-nonce request from a not-fully-authorized session must not change it, and the legitimate POST from a fully authorized session must still work.
Engineering lesson
State-changing routes need both a method policy and CSRF validation at the trust boundary. A per-task authorization switch should fail closed so a new task cannot silently miss the established control.