CVE-2026-20779
Gitea: TOTP TOCTOU race on web 2FA paths + missing replay check on Basic-Auth `X-Gitea-OTP` surface
Summary
### Summary I'm reporting **two related TOTP one-time-use defects** in Gitea that survive the CVE-2021-45331 fix. The 2018 fix (PR #3878) introduced the `TwoFactor.LastUsedPasscode` field and added an in-memory inequality check on the web 2FA login path. That check works correctly in the single-request case, but it leaves two follow-up gaps: 1. **A TOCTOU race on the web surfaces (Defect 1).** The read-validate-check-save sequence against the `two_factor` row is not atomic. Two parallel submissions of the same passcode each load their own in-memory copy where `LastUsedPasscode` still holds the prior value; both pass the inequality check, both authenticate, and both then write the same new value back. Net effect: the same OTP redeems for two independent logged-in sessions. 2. **No `LastUsedPasscode` check at all on the Basic-Auth API surface (Defect 2).** `services/auth/basic.go` calls `twofa.ValidateTOTP(...)` for `X-Gitea-OTP` without ever reading or writing `LastUsedPasscode`. The same six-digit code is replayable for the full `totp.Validate` acceptance window (~60–90 s with the default `Skew=1`). This is a clean [RFC 6238 §5.2](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2) violation independent of timing, shaped identically to the pre-CVE-2021-45331 behaviour but scoped to the API / Git-over-HTTPS basic-auth path instead of the web form. Both defects post-date the 2018 fix; neither is referenced in any published Gitea advisory I could find. I'm filing this as a **follow-up** to CVE-2021-45331, not a duplicate. ### Vulnerable code #### Defect 1 — TOCTOU race on web 2FA login `routers/web/auth/2fa.go:55-88`: ```go 54 id := idSess.(int64) 55 twofa, err := auth.GetTwoFactorByUID(ctx, id) // (A) read row 56 ... 62 ok, err := twofa.ValidateTOTP(form.Passcode) // (B) pure-function RFC 6238 check ... 68 if ok && twofa.LastUsedPasscode != form.Passcode { // (C) check against in-memory copy ... 84 twofa.LastUsedPasscode = form.Passcode // (D) mutate in-memory 85 if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {// (E) UPDATE … AllCols where id=? ``` Step (E) is plain `db.GetEngine(ctx).ID(t.ID).AllCols().Update(t)` (`models/auth/twofactor.go:128-131`) — no row lock, no `WHERE last_used_passcode = ` predicate, and no DB uniqueness on `(uid, last_used_passcode)`. The model definition at `models/auth/twofactor.go:48-57` shows `LastUsedPasscode string` is a plain column — no constraint, no version field. #### Defect 1 — same shape, password-reset 2FA re-auth `routers/web/auth/password.go:179-196` shows the identical pattern in the password-reset flow: ```go 179 passcode := ctx.FormString("passcode") 180 ok, err := twofa.ValidateTOTP(passcode) ... 185 if !ok || twofa.LastUsedPasscode == passcode { // same check-against-in-memory pattern ... 192 twofa.LastUsedPasscode = passcode 193 if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { ``` Same shape, same race window. #### Defect 2 — Basic-Auth API / Git-over-HTTPS (stateless replay — no check at all) `services/auth/basic.go:170-185`: ```go func validateTOTP(req *http.Request, u *user_model.User) error { twofa, err := auth_model.GetTwoFactorByUID(req.Context(), u.ID) ... if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil { // :179 return err } else if !ok { return util.NewInvalidArgumentErrorf("invalid provided OTP") } return nil } ``` `LastUsedPasscode` is neither read nor written on this path. The same six-digit code in `X-Gitea-OTP` succeeds for the full `totp.Validate` acceptance window on every request. #### Why the existing failed-login counter doesn't catch either defect Gitea's `loginAttempts` counter increments on **failed** sign-ins. A successful replay is a success — the counter is never touched, and two parallel successes produce two access tokens with no anomaly logged at the auth layer
Affected packages
| Ecosystem | Package | Affected versions | Fixed versions |
|---|---|---|---|
| Go | code.gitea.io/gitea | — | 1.26.3 |
Remediation: Upgrade to 1.26.3 or later.
References
Includes data from the GitHub Advisory Database, licensed under CC-BY 4.0.
CVE® is a registered trademark of The MITRE Corporation. CVE content reproduced under the CVE Terms of Use; copyright designation © MITRE.
EPSS scores provided by the FIRST.org Exploit Prediction Scoring System.