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=bigbird

The 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.