The Link You Can Write Yourself: Deriving a Reset Token From Source
The challenge
Brightloom's password reset does everything the checklists ask for. It does not say whether an address is registered, the link expires in thirty minutes, and it goes to the address on file. It also never needs to be delivered to you, because the token it emails is calculated rather than generated. Three source files and a support export from the admin console are here. Work out the token the server would email for the operations account, [email protected], and write it exactly as the server would.
What you'll learn
- Trace which generator a flow actually calls when several look alike
- Derive a token value by executing a format string by hand
- Explain why a reset token must be unpredictable, not merely unique
- Identify the fields an attacker can already see as a token's entropy source
- Recognise that confirming a token without a session removes the last barrier
Skills tested
Prerequisites
- Reading Python
- What strftime and the modulo operator do
How it works
A password reset link is a capability. Holding it is the same as proving you control the mailbox it was sent to, which is why the value has to be unpredictable to anybody who did not receive the email. Uniqueness is a database property and has nothing to do with it: sequential integers are unique too.
This generator draws both of its components from fields that are printed in a support console, on invoices and in the admin UI. The signup date and the account number are not secrets and were never meant to be. Feeding them into a format string produces a value that is unique per account and derivable by anyone who has ever seen a customer record, which is a much larger group than the people with access to the mailbox.
What makes it worse is the controls around it. No account enumeration, a thirty minute expiry, delivery to the registered address only: every one of them is correct, and every one of them assumes the token is the hard part. When the token can be computed, they protect the wrong thing, and the confirm endpoint looking a token up with no session attached is what turns the calculation into an account takeover.
Common mistakes
- Using the id column. The operations row has
id7741 andaccount_no40218. The generator readsaccount_no. - Taking the date from the billing row. It shares the 2024-03-11 signup date but has a different account number.
- Taking the digits from Rosa Castellanos. Her
account_no30218 also ends in 0218, which is why a wrong row still looks plausible. - Using invite_token. It is right above the one that matters and uses day-of-year with three digits. The reset flow does not call it.
- Dropping the leading zero. The format is
%04d, so it is0218rather than218. - Being reassured by
secrets.token_urlsafe. It is in the same file, and it is used for sessions, not for resets.
How to defend against it
Generate the token, do not derive it.
- Use a cryptographically secure random value of at least 128 bits, for example
secrets.token_urlsafe(32), which this file already imports for sessions. - Store a hash of the token rather than the token, so a database read does not hand over live reset links.
- Bind confirm to something more than the token: rate limit it hard, invalidate every other session on success, and email the account when a password changes.
- Invalidate outstanding tokens when a new one is requested, and keep the expiry short. Both are cheap and both limit the window.
- Treat BILL-1877 as the finding it is. A reset row that can be consumed by anyone who names it, with no rate limit, is the second half of this bug.