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

  1. 01
    Cross-site sourceTop-level GET to task:login.regenerate2FASecret

    A link or navigation can carry the victim's Lax session cookie while selecting the state-changing task in the URL.

  2. 02
    Core dispatchTaskServiceProvider and TasksProcessor

    Core resolves the task from the URI and fires the event without a global POST or nonce requirement.

  3. 03
    Missing plugin gatelogin.php · loginController()

    The per-task nonce switch in 3.8.10 has no case for secret regeneration and no fail-closed default.

  4. 04
    Persistent sinkclasses/Controller.php · taskRegenerate2FASecret()

    The handler replaces twofa_secret on 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

  1. Clone the public login-plugin repository into a unique temporary directory and export login.php plus classes/Controller.php from 3.8.10 and the published fix commit.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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-nonce from 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"/*.php

Benign 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 -> accepted

Expected 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.
Negative control

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.

Fixed-version re-test

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.

References

From evidence to assessment

Need this kind of risk assessed in your environment?

Authorized engagements begin with a fit check, written scope, and the outcome your team needs from the assessment.

Explore security services Start an assessment enquiry