00Context
The target was a consumer fintech investment app: users sign in with their mobile number, receive a one-time passcode by SMS, and enter it to get a session. No password, just the phone number and the code. For a product that holds account balances, bank details, investment limits and transaction history, that OTP is the entire front door. If the code can be guessed, there is nothing else to stop an attacker.
It could be guessed. A single logic flaw in how the passcode was generated, combined with a verification endpoint that had no rate limiting, meant that given only a victim's mobile number, an attacker could recover a valid session token and take over the account. The issue rated Critical, CVSS 10. This is the walk-through, sanitised: no organisation, product, person, phone number, token or identifier is named, and the mechanics are described at the level of vulnerability class, not as a ready-to-run exploit.
01How the login was meant to work
The passwordless flow used two API calls. On paper it had sensible guard rails.
The design did include limits, which is what made the result surprising:
- A given identifier could be used to validate a passcode only a handful of times.
- The generate step was throttled: only a small number of OTPs could be requested within a 60-second window before the server refused with an error.
Those two controls look like brute-force protection. They were not, because the real weakness was somewhere neither of them covered.
02The logic flaw: an OTP that never changed
The defining misconfiguration was in when a new code was issued. The server generated a new passcode only when the previous one was used. If a code was issued and never consumed, every subsequent generate request for that number returned the same passcode. The OTP was, in effect, static until someone logged in with it.
On its own that is bad. Paired with the second problem it is fatal: the verification endpoint enforced no rate limiting and no brute-force protection at all. It would accept an unlimited number of guesses. And the code itself was short, a small numeric space that can be walked end to end very quickly.
A short code is fine if it is fresh and rate-limited. This one was neither: it stood still while an attacker tried every value.
Vulnerability deep-dive · OTP brute force
Why entropy and rate limiting are one control, not two
A one-time passcode resists guessing for two reasons at once: it is drawn from a large enough space that random guessing is impractical, and the server stops an attacker before they can make enough attempts to matter. These are not independent. A short code is acceptable only because rate limiting keeps the number of guesses tiny, and generous rate limits are acceptable only because the code space is large. Weaken either and the other has to carry the whole load.
What broke here
Both halves failed in the same place. The code was short, it did not rotate between attempts, and the step that checks it counted no attempts and imposed no lockout. A static target with unlimited guesses is not a secret; it is a combination lock left open, with all the time in the world to try every number.
Why the existing limits did not help
The throttle sat on the generate step and the reuse cap sat on the identifier, but the attacker did not need to break either. Because the passcode stayed constant, fresh identifiers could be requested at a pace that stayed under the generate throttle, each one buying a new, small allowance of guesses against the same unchanging code. The protection was placed on the steps that were cheap to satisfy, and left off the one step that actually needed it.
03Turning the flaw into a session
Because verification required a valid identifier paired with the code, both endpoints had to be exercised together. The approach, described at a high level, was simply to keep the static passcode in view while cycling the cheap parts around it.
In testing, the correct code was found in well under a hundred attempts, and the server returned a live session token for the account. No password, no device, no second factor stood in the way, because the one-time passcode was the only factor, and it was not really one-time.
04Impact
A valid session token is the whole account. With it, the engagement could reach everything the app exposes to a logged-in user:
- Personal and KYC data: name, contact details and identity information.
- Financial data: account balances, linked bank details, investment limits and full transaction history.
- Account actions available to the user within the app.
Crucially, this worked against any account, because the only prerequisite was a valid mobile number, something an attacker can obtain or simply iterate. For a product that moves and holds money, a phone-number-to-takeover path is close to the worst case, which is why it rated CVSS 10.
05Why it worked
| Root cause | Effect | Vulnerability class |
|---|---|---|
| New code issued only on use | The passcode was static until consumed, so it could be attacked at leisure | Auth logic flaw |
| No rate limit on verification | Unlimited guesses against the code, with no lockout | Missing anti-automation |
| Short code space | The full set of values could be walked quickly | Weak OTP entropy |
| Limits on the wrong steps | Throttling the generate step and capping identifiers did nothing to protect the code | Control placement |
06Remediation
- Issue a fresh, random passcode on every request. Generate a new code when an identifier is requested, not only when the previous one is consumed, so it is never static.
- Never reuse a passcode. Each should be single-use and short-lived, invalidated after use or expiry.
- Use adequate length. At least six random digits, so the space cannot be walked quickly even if other controls slip.
- Rate-limit the verification step, hard. Count attempts per account and per source, lock out after a small number of failures, and apply throttling across the whole flow rather than only the generate step.
- Treat the limits as one system. Entropy and rate limiting protect each other; design and test them together, not in isolation.
The lesson is placement as much as presence. This app had brute-force controls; they simply sat on the steps that did not need them and were missing from the one that did. A control in the wrong place reads as security on a diagram and offers none in practice.