Skip to content

[Security] Account Takeover via Username Collision in Registration Flow #300

@lijiajun1997

Description

@lijiajun1997

Security Vulnerability: Account Takeover via Username Collision in Registration Flow

Summary

A critical security vulnerability exists in the registration flow that allows an attacker to gain access to an existing user's account by registering with a different email address that shares the same username prefix.

Severity: Critical

Vulnerability Details

Root Cause Analysis

The vulnerability stems from two issues:

1. find_or_create_identity uses username as a lookup key

File: backend/app/services/registration_service.py:121-124

# Try to find by username
if not identity and username:
    res = await db.execute(select(Identity).where(Identity.username == username))
    identity = res.scalar_one_or_none()

This function searches for existing identities by email, phone, AND username. If a username match is found, it returns the existing identity without verifying ownership.

2. _handle_normal_register lacks password verification

File: backend/app/api/auth.py:344

identity = await registration_service.find_or_create_identity(
    db,
    email=data.email,
    username=data.username,
    password=data.password,
    is_platform_admin=is_first_user
)
# NO PASSWORD VERIFICATION HERE!

Compare with the secure implementation in register_init (auth.py:181-186):

# If identity existed, verify password
if identity.password_hash and not verify_password(data.password, identity.password_hash):
     raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Email already registered. Incorrect password."
    )

3. Frontend derives username from email

File: frontend/src/pages/Login.tsx:119

username: form.login_identifier.split('@')[0],  // e.g., "john" from "john@gmail.com"

Attack Scenario

  1. Victim registers with john@gmail.com, username john, password secret123
  2. Attacker registers with john@yahoo.com, username john, password attacker456

What happens:

  1. find_or_create_identity searches for john@yahoo.com email → not found
  2. find_or_create_identity searches for john username → found victim's identity!
  3. _handle_normal_register creates a new User record linked to victim's Identity
  4. Attacker successfully "registers" and gains access to victim's companies

Result:

  • Attacker and victim share the same Identity record
  • Attacker can see all companies the victim belongs to
  • Attacker can switch between victim's companies
  • Both users can login with their respective passwords

Database State After Attack

identities table:
+----------------------------------+------------------+----------+
| id                               | email            | username |
+----------------------------------+------------------+----------+
| uuid-victim                      | john@gmail.com   | john     |
+----------------------------------+------------------+----------+

users table:
+----------------------------------+----------------------------------+------------------+
| id                               | identity_id                      | tenant_id        |
+----------------------------------+----------------------------------+------------------+
| uuid-user-1                      | uuid-victim                      | company-A        |
| uuid-user-2 (attacker's entry)   | uuid-victim                      | company-B        |
+----------------------------------+----------------------------------+------------------+

Impact

Impact Type Description
Account Takeover Attacker gains access to victim's organizations
Data Breach Attacker can view victim's agents, conversations, and data
Privilege Escalation Attacker may inherit victim's roles across organizations
Misleading Errors If passwords differ, users see "Email already registered" for an email they never used

Affected Code Paths

Endpoint Function Has Password Check Vulnerable
/auth/register _handle_normal_register ❌ No ✅ Yes
/auth/register/init register_init ✅ Yes ❌ No
SSO flows Various N/A (OAuth) ❌ No
Channel user service create_user_from_external N/A ⚠️ Potential

Proof of Concept

Scenario 1: Same Password (Critical - Account Takeover)

# Step 1: Victim registers
curl -X POST /api/auth/register \
  -d '{"email":"victim@gmail.com","password":"shared123","display_name":"victim"}'

# Step 2: Attacker registers with different email, same username prefix
curl -X POST /api/auth/register \
  -d '{"email":"victim@yahoo.com","password":"shared123","display_name":"attacker"}'

# Result: Both users share the same Identity!
# Attacker can now login and see victim's companies

Scenario 2: Different Password (Misleading Error)

# Step 1: Victim registers with password "secret123"
# Step 2: Attacker tries to register with password "hacker456"
# Result: "Email already registered. Incorrect password."
#         (But attacker's email was never registered!)

Recommended Fix

Option 1: Add password verification (Quick fix)

In _handle_normal_register (auth.py), add after line 344:

identity = await registration_service.find_or_create_identity(...)

# ADD THIS:
if identity.email != data.email and identity.password_hash:
    if not verify_password(data.password, identity.password_hash):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Username already taken. Please choose a different username."
        )

Option 2: Remove username lookup (Recommended)

Modify find_or_create_identity to only search by email/phone:

async def find_or_create_identity(...) -> Identity:
    identity = None
    
    # Only search by email and phone, NOT username
    if email:
        res = await db.execute(select(Identity).where(Identity.email == email))
        identity = res.scalar_one_or_none()
        
    if not identity and phone:
        normalized_phone = re.sub(r"[\s\-\+]", "", phone)
        res = await db.execute(select(Identity).where(Identity.phone == normalized_phone))
        identity = res.scalar_one_or_none()

    if identity:
        return identity
    
    # Handle username collision for new identities
    final_username = username
    if username:
        existing = await db.execute(select(Identity).where(Identity.username == username))
        if existing.scalar_one_or_none():
            final_username = f"{username}_{uuid.uuid4().hex[:6]}"
    
    # Create new identity with unique username
    identity = Identity(
        email=email,
        phone=normalized_phone,
        username=final_username,
        ...
    )

Option 3: Frontend generates unique usernames

Instead of deriving username from email, generate a unique identifier:

username: `${form.login_identifier.split('@')[0]}_${Date.now().toString(36)}`,

Environment

  • Clawith version: v1.8.1+
  • Affected versions: All versions with the current registration flow

Credit

Discovered during testing of invitation code registration flow.


CVSS Score Estimate: 8.1 (High)
CWE: CWE-287 (Improper Authentication), CWE-288 (Authentication Bypass Using an Alternate Path)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions