CVE Patch Review

CVE-2026-20779: Root-Cause Fix for Gitea TOTP Replay via Atomic Consumption

CVE-2026-20779 · GHSA-GX3V-Q759-G323 · Updated 2026-07-22 Root-cause

Summary

The patch addresses a TOTP replay flaw in Gitea by moving from split validation/update logic to a single atomic validation-and-consume operation backed by persistent last-used passcode state. The core security improvement is the conditional database update in models/auth/twofactor.go, which closes the read-validate-write race across login surfaces that previously allowed the same valid TOTP to be redeemed more than once. The review also notes adjacent hardening and unrelated fixes present in the same pull request, which should be separated conceptually from the CVE-specific remediation.

Analysis

Vulnerability

CVE-2026-20779 describes a high-severity authentication bypass in Gitea where TOTP codes could be replayed across authentication paths. The vulnerable pattern was a classic time-of-check/time-of-use split: code paths validated a passcode first and only later updated LastUsedPasscode, with the replay check performed in application logic rather than as part of an atomic state transition. This left a race window where concurrent requests using the same still-valid TOTP could both succeed.

The vulnerable web and password-reset flows show the issue directly. They called ValidateTOTP, then separately compared and stored LastUsedPasscode:

// Validate the passcode with the stored TOTP secret.
ok, err := twofa.ValidateTOTP(form.Passcode)
if ok && twofa.LastUsedPasscode != form.Passcode {
    twofa.LastUsedPasscode = form.Passcode
    if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
        ctx.ServerError("UserSignIn", err)
        return
    }
}

That sequence is not safe under concurrency because two requests can both observe the same pre-update state and both proceed. The historical migration and model references in the earlier one-hop code reference show that Gitea had already introduced LastUsedPasscode storage, but the enforcement remained non-atomic and therefore insufficient against replay races. The CVE record and MITRE entry provide the vulnerability context, while the main remediation appears in the patch pull request and aligns with the single-use requirement cited in RFC 6238 language embedded in the patch comments.

Patch

The patch refactors TOTP handling so all login surfaces must use a single helper, ValidateAndConsumeTOTP, instead of calling validation and persistence separately. The old public validator is renamed to an internal helper validateTOTP, and the new method performs validation followed by a conditional database update that only succeeds if the stored passcode is different from the presented one.

// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
// invalid passcode as well as for a replay, including the case where a concurrent request with
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
    ok, err := t.validateTOTP(passcode)
    if err != nil || !ok {
        return false, err
    }
    t.LastUsedPasscode = passcode
    n, err := db.GetEngine(ctx).ID(t.ID).
        Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
        Cols("last_used_passcode").Update(t)
    if err != nil {
        return false, err
    }
    return n == 1, nil
}

This is the key security change in PR #38151. The conditional UPDATE makes replay detection part of the write itself. If another request has already consumed the same passcode, the update affects zero rows and the second request is rejected. The patch comments explicitly state that the row lock taken by the UPDATE serializes racing requests and closes the TOCTOU window.

The patch also updates the web sign-in and password-reset flows to call the new helper directly:

// Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
if ok {
    ...
}

Tests were added to verify first-use success, replay rejection within the same TOTP validity window, and invalid-code rejection. The earlier migration reference in PR #3878 is relevant because the fix depends on persistent LastUsedPasscode state already existing in the schema and model.

The pull request also contains unrelated or adjacent changes in API authorization and pre-receive hook permission evaluation. Those may be valid fixes on their own, but they are not necessary to explain the TOTP replay remediation for CVE-2026-20779.

Review

Pros

  • The patch addresses the actual race condition rather than only tightening caller-side checks. Moving from ValidateTOTP plus later persistence to ValidateAndConsumeTOTP is the correct architectural change.
  • The conditional database update is the strongest part of the fix. It converts replay prevention into an atomic state transition and rejects both sequential replays and concurrent duplicates.
  • The implementation is explicit about intended security semantics: invalid code and replay both return false, reducing side-channel differentiation and making caller behavior simpler.
  • The patch updates multiple authentication surfaces shown in the diff, including web 2FA and password reset, reducing the chance that one path remains vulnerable.
  • The new unit test in models/auth/twofactor_test.go validates the expected single-use behavior and confirms that replay is denied even while the TOTP is still time-valid.

Cons

  • The review evidence only shows some login surfaces being migrated. The patch comment says all login surfaces must use the helper, but the provided snippets do not enumerate every possible web, API, OAuth, CLI, or token-exchange path. Completeness depends on broader call-site coverage in the full tree.
  • The design stores the last used passcode itself. While operationally practical, it is still authentication state derived from a secret-based factor. The patch does not show hashing or alternate replay markers, though this is a lower concern than the race itself.
  • The fix appears to rely on a single last-used value. That is appropriate for preventing immediate reuse of the same TOTP, but it does not by itself express richer anti-replay windows or clock-skew bookkeeping. This is not a defect relative to the CVE, just a scope limit.
  • The pull request mixes unrelated authorization and hook-permission changes with the TOTP remediation, which increases review complexity and can obscure security reasoning for the CVE-specific fix.

Verdict

Root-cause.

The patch fixes the underlying defect by eliminating the split read/validate/write workflow that enabled TOTP replay under concurrency. The new ValidateAndConsumeTOTP helper centralizes enforcement and uses an atomic conditional update to serialize competing requests and reject duplicates. Based on the supplied sources, this is not merely a caller-side guard or a best-effort replay check; it is a stateful, database-backed remediation of the race condition described in the NVD record. The main residual review item is call-site completeness across all authentication surfaces, but the implementation strategy itself is sound and aligned with the vulnerability's root cause.

Sources