“We’ve sent a six-digit code to your email address. Enter it below to login.”
We see them all the time while testing web applications. In order to verify your identity, the application sends a 6 digit numerical code to your registered email address or phone number. The purpose is to prove that the person performing the action is also in possession of the phone or mailbox attached to it. No access, no authorisation. It’s a useful second factor to apply a little extra security to a process.
If you try to guess the code it expires after 5 or so attempts. The odds of guessing correctly are 5/1,000,000, or 1/200,000 (0.0005%). Any guesses after the first 5 are invalid. This prevents you from brute-forcing all 1,000,000 combinations from 000000 to 999999.
Sounds pretty safe, right? Well, it can be. But sometimes developers make mistakes in their assumptions. Here’s one of my favourite bugs to test for and how to exploit it.
Standard Use of One Time Codes or Pins (OTCs or OTPs)
In normal usage, a user performs an action that generates a one time code which is sent to their email address or phone number. They pick up their phone, read the code and enter it correctly on the first attempt. Job done.
When an attacker tries to do the same thing on the victim’s account, the code is sent to the owner’s device. The attacker can’t see the code and their only option is to try to guess it. They enter 5 different codes and the server invalidates the generated OTC. No matter what happens from this point onwards, even if they try the remaining 999,995 possible combinations, none of them will work. If they generate another, different code, they still only get 5 attempts.
The rate limit on the number of attempts has worked.
The problem with the above is that it only rate-limits the number of guesses against a single code. It prevents you from making 1,000,000 guesses at a single code and brute-forcing your way in.
One of my favourite attacks is to brute-force the generation of the code, not the code itself. Here’s how it works.
All We Need is a Match
For OTC verifications, we don’t need the attacker to guess the code that the server has generated, we just need both the attacker and the server to agree on a match of the same 6 digit number.
OTC verifications are a two-step process on the server-side.
- Generate an OTC and send it to the account owner
- Receive an OTC from a user and compare it with the generated value
A standard brute-force attack would be to try all 1,000,000 six-digit codes against a single code on the server-side. By setting the generated code to expire after 5 attempts, the developer has applied a rate limit to step 2.
Instead, an attacker can invert this logic by using the same 5, static codes and forcing the server to generate a new code up to 200,000 times. This exploits a lack of rate-limiting on step 1.
For simplicity, an example with a single code, 645274, can be seen below:
Statistically, the odds of a match with 5 attempts against a 6 digit numerical code are 1 in 200,000. Therefore, by looping through anywhere up to 200,000 iterations we’ll probably see the attacker and server agree on a match, bypassing the verification of the second factor.
The loop for this attack looks like this:
While (NotAMatch){
GenerateNewCode()
SendCode1()
SendCode2()
SendCode3()
SendCode4()
SendCode5()
}
Computers, Randomness, and Selecting OTCs to use
Because computers suck at generating random numbers, the odds are probably much better than 1 in 200,000. When testing for this type of bug, I first identify how many attempts we get before the generated one-time code expires. Then I generate that same number of different one time codes, usually somewhere between 5 and 10. Then I use all of those codes for my static list.
By doing so, I know that I’m using codes that the computer has already generated. Computers suck at generating random numbers. In reality, math.random() is actually just a value from a really long, pre-determined list, possibly with some further calculations performed on it. If I know a code has been generated once, I know it can be generated again.
Impact
The impact of a bug like this depends on what the one-time code is protecting. If it’s to login to an account, you might have just found an MFA bypass. If it’s to verify your email address or phone number, it could be the bypass of a minor control only. If that ‘validated’ email address or phone number is later used to link one account to another, that’s a completely different scenario altogether and likely to be much more critical.
If you find a bug like this, be creative in exploring the potential impacts before submitting your report to a bug bounty program. Your description of impact could be the only difference between a low rated vulnerability and a critical report.
Leave a Reply