Turning the Rate Limit Against the User: The Invisible Vulnerability That Locks Out Any Account at Will
The control was present, configured, and working exactly as designed. That was the finding. It counted requests against the customer being messaged rather than against whoever was asking - so anybody who knew a customer’s number could spend that customer’s allowance from anywhere, and the platform would then refuse to let its own customer in.
- The system
- A consumer platform where accounts are keyed on a mobile number and sign-in uses a one-time code sent by message.
- In scope
- The authentication and account recovery flows.
- What was at risk
- Every customer’s ability to sign in. One cheap request per target, repeatable indefinitely, scaling to a list as easily as to one person - with the platform enforcing the lockout on the attacker’s behalf.
Anonymised. An assessment carried out by our testers, with client-identifying detail withheld under the terms of the engagement. Full statement
What we were given
A consumer platform with accounts keyed on a mobile number. Signing in sends a one-time code to that number; entering the code creates the session. Account recovery works the same way. This is a design used by a very large number of consumer services, and most of what follows applies to all of them.
The authentication and recovery flows were the scope. Rate limiting was already in place on them, which is what makes this one worth writing up: a control that exists is the control nobody goes back to test.
What we found
The code-sending endpoint was rate limited. The limit counted requests against the number the code was being sent to.
So the allowance did not belong to the caller. It belonged to the customer. Anyone who knew a customer’s number could spend that customer’s allowance, from any source, and the platform would then refuse to send that customer a code. The customer could not sign in. Nothing had been compromised, and they were locked out of their account.
It could be held open
There was no cap over a longer period, only the short window. Repeat the requests as the window resets and the account stays unreachable for as long as the attacker cares to continue. This is the difference between an inconvenience and an outage for a named individual.
And the input was not what the platform issues
The number field accepted values that the platform’s own rules should have rejected before anything was spent on them. That is a cost problem, since each attempt sends a message, and it is a reconnaissance problem, because responses differed in ways that indicated whether a number corresponded to an account.
How far it went
A rate limit exists to stop an attacker consuming a resource. This one let an attacker consume the customer’s resource, and the consequences are worse than they first appear for three reasons.
- It scales. A list of mobile numbers is not difficult to obtain, and the request that locks one account locks ten thousand with no more effort than a loop.
- It is invisible. Nothing in the logs describes an attack. It describes a series of code requests for a number, followed by a customer who cannot sign in, which is indistinguishable from a customer having trouble.
- It is unfixable by the victim. There is no action available to the account holder. They cannot change what an attacker sends, and support cannot reproduce a fault, because the control is doing precisely what it was configured to do.
The failure mode is the finding
A security control that fails closed against your own customer is an availability defect wearing a control’s clothes. When you design a limit, decide what happens to a legitimate user who hits it - and if the answer is that they are refused service, the limit is now something an attacker can aim.
The fix
Removing the limit is not the fix. Uncapped code sending is a direct cost and an easy way to make a customer’s phone unusable. The fix is separating the two jobs the single limit was trying to do.
- Put the enforcing limit on the caller: source, session, device, account context. That is the party you are trying to slow down, and it is the only key where refusing service refuses it to the right person.
- Keep a per-recipient cap, because message cost is real, but make its failure mode a delay rather than a refusal. Slow the next code down; do not decline to send one.
- Add a longer window on top of the short one, so the pattern cannot be held open by waiting for a reset.
- Validate the number against the format the platform actually issues before spending a message on it.
- Alert on the shape rather than only enforcing against it. Repeated code requests for one number from many sources is a pattern worth surfacing, and it is the signal the original design threw away.
The general version of this, including the account lockout variant that hits password logins in exactly the same way, is in the guide on rate limiting that locks out users.
The findings, in one place
- The one-time code limit counted requests per destination number, not per caller, so exhausting a customer allowance locked that customer out.
- Nothing capped the total over a longer window, so the lockout could be held open indefinitely by repeating it.
- It scales without effort: the same request that shuts one account shuts a list of them.
- It is close to invisible in monitoring, because the control is functioning as configured and the only symptom is a customer who cannot sign in.
- The fix is not removing the limit. It is making the per-recipient cap slow down rather than refuse, and putting the enforcing limit on the caller.
What to check on your own system
Go and look at what your own limits are keyed on. If the counter is attached to the account, the phone number, the email address or anything else chosen by the person on the receiving end, then somebody who is not that person can drain it. The question to ask of every threshold is not how high it is set. It is who suffers when it is reached.
Confidentiality
Anonymised. This describes an assessment carried out by our testers. The client, the dates, the sector detail and any figure that could identify the organisation are withheld or generalised under the confidentiality terms of the original engagement, and no endpoint, parameter or payload from the assessment appears here.
Keep reading
- Rate limiting that locks out usersThe general case: where to key a limit, and how a defence becomes an attack.
- Step-up authentication for sensitive actionsWhat a one-time code does and does not prove, and where it belongs.
- What a penetration test actually findsThe classes of flaw that come up most across our reports.
- How we run an engagementScoping, testing, reporting and the retest that confirms the fix.