The Username That Changed When Asked Twice — Python Bug Hunt
Modelled on Spotify's June 2013 account-hijacking bug, described in Spotify's own engineering write-up.
- Language: Python
- Layer: Backend
- Difficulty: Hard
- Concepts: Security, Unicode, Auth
- Modelled on: Spotify · 2013
- Visible tests: an ordinary account registers and resets; a name whose canonical form is unstable is refused
- Reward: 50 XP for a complete fix
Briefing
Modelled on Spotify's June 2013 account-hijacking bug, described in Spotify's own engineering write-up. Usernames were canonicalised with an XMPP "nodeprep" routine that was not idempotent for some Unicode characters: canonicalising ᴮᴵᴳᴮᴵᴿᴰ gave BIGBIRD, and canonicalising that again gave bigbird. Because the password-reset flow canonicalised a name that was already canonical, an attacker could register the odd-looking name and reset the password of the ordinary account it collapsed into. Spotify's fix rejected names whose canonical form is not stable.
nodeprep.py (locked) reproduces the non-idempotent mapping; accounts.py trusts it.
Fix AccountStore so no name can be steered into another account.
Bug report
BUG-SPOT-1306 · Priority: Critical (account takeover) · Reported by: security
AccountStore:
- register(name, password): key = canonical(name). Raise ValueError if the key is not stable (canonical(key) != key) or is already taken. Return key.
- check_password(name, password): looks up canonical(name).
- request_reset(name): {"account": canonical(name)} or None if no account.
- complete_reset(ticket, new_password): ticket["account"] is already a canonical key — apply the change to exactly that account, without canonicalising again. Unknown key -> False, nothing changes.
Observed: after registering a name written in modifier letters, resetting its password changed the password of the account "bigbird".
Logs
[accounts] register display="ᴮᴵᴳᴮᴵᴿᴰ" key=BIGBIRD
[reset] ticket account=BIGBIRD -> applied to account=bigbirdThe code as shipped
src/accounts/accounts.py (editable)
nodeprep = bug_require("./nodeprep.py")
class AccountStore:
def __init__(self):
self.accounts = {}
def register(self, name, password):
key = nodeprep.canonical(name)
if key in self.accounts:
raise ValueError("username taken")
self.accounts[key] = {"display": name, "password": password}
return key
def check_password(self, name, password):
acct = self.accounts.get(nodeprep.canonical(name))
return acct is not None and acct["password"] == password
def request_reset(self, name):
key = nodeprep.canonical(name)
if key not in self.accounts:
return None
return {"account": key}
def complete_reset(self, ticket, new_password):
acct = self.accounts.get(nodeprep.canonical(ticket["account"]))
if acct is None:
return False
acct["password"] = new_password
return True
Read-only context: src/accounts/nodeprep.py.
Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More Python bug hunts.