ghost_sec writes
Mobile App Sec · Case Study

One phone number to account takeover

A Critical authentication bypass in a fintech investment app: how a one-time passcode that was never actually one-time, plus a verification step with no brakes, turned any user's phone number into full account takeover.

@ghost_sec Mobile & API case study Educational write-up

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.

Note. This write-up is for educational purposes only. The techniques described illustrate offensive security methodology and are intended to help defenders understand and improve their detection capabilities.

01How the login was meant to work

The passwordless flow used two API calls. On paper it had sensible guard rails.

Step 1 · GeneratePOST /login/otp/generateTakes the mobile number, returns a one-time identifier (a UUID) and sends an OTP to the phone.
Step 2 · VerifyPOST /login/otp/verifyTakes that identifier plus the entered OTP, and returns a session token if the code is correct.

The design did include limits, which is what made the result surprising:

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.

Trigger a passcode for the target number, obtaining a fresh identifier. The code behind it does not change.
Spend that identifier's allowance of guesses against the verify step, walking through candidate codes.
Pace the requests so the generate step stays under its throttle, then request another identifier for the same still-valid code.
Repeat until a guess matches. The verify step never locks out, so the only limit is how fast the space can be walked.
Receive a session token for the victim's account. Account takeover, from a phone number alone.

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:

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 causeEffectVulnerability class
New code issued only on useThe passcode was static until consumed, so it could be attacked at leisureAuth logic flaw
No rate limit on verificationUnlimited guesses against the code, with no lockoutMissing anti-automation
Short code spaceThe full set of values could be walked quicklyWeak OTP entropy
Limits on the wrong stepsThrottling the generate step and capping identifiers did nothing to protect the codeControl placement

06Remediation

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.