Password reset is a mechanism that almost all websites have. The most common way is to send a password reset link via email, and after clicking the link, the user can set a new password for the account. Although this mechanism is common, there are some small security details to pay attention to.
This time, I am going to write about an account takeover vulnerability caused by the password reset function that I reported at the end of June this year.
Matters News is a decentralized writing community platform that uses encrypted currency-related technology. I have written an article before, Preventing XSS May Be Harder Than You Think, sharing how I found their XSS vulnerability.
Before discussing this vulnerability, let’s take a look at how the general password reset function is designed.
By the way, if you are curious why you can only reset the password instead of “retrieve password” when you forget your password, you can refer to this article: Why can only reset password when forgetting password, not tell me the old password?
Typical password reset function
Basically, the process of forgetting the password is similar:
- The user enters the email used when registering the account.
- The system sends a password reset link to the email in step 1.
- The user clicks the link in the email to go to the password reset page.
- The user enters a new password and submits the form.
- The password is reset successfully, and the user can log in with the new password.
If this process is to be secure, it must ensure that:
- The destination of the email sent by the system is the user’s email.
- The password reset link cannot be guessed.
Let’s first talk about the first point. Some people may say, “Isn’t this basic? I enter user@example.com, and of course, the email will be sent to user@example.com!”
No, not necessarily. Some systems can receive an array as the email parameter, so you can enter: ["[email protected]", "[email protected]"]
, and then the attacker will receive the password reset email of the victim!
It sounds incredible, but it has indeed happened:
Next, let’s talk about the second point. If the password reset link can be guessed, it means that the attacker can reset the password on behalf of the user.
Or more precisely, the password reset token cannot be guessed.
For example, if the password reset link looks like this: https://example.com/[email protected]
, then I can reset the password for anyone, which is obviously insecure.
Therefore, in general, the token will generate a unique id, such as UUID v4, which looks like this: 2c59d26a-f99a-425e-bb69-91e7c6ffe54d
, with 128 bits, which is 2^128 combinations, and the probability of guessing it is very small.
If the strength of the generated token is not enough, it will increase the probability of successful brute force cracking.
However, it should be noted that even so, some system vulnerabilities are elsewhere, such as when sending emails, the password reset URL or host can be controlled! For example, as long as X-Forwarded-Host: abc.com
is added in the request header, the password reset link will become: https://abc.com/reset-password?token=...
. If the user clicks the link carelessly after receiving the email, the token will be sent to the attacker’s server, and he can still use this token to reset the password and take over the account.
This has also happened in actual cases:
Apart from these, there are many small details to pay attention to, such as:
- The reset password token should only be used once.
- The reset password token should have an expiration time.
- If the user generates a new reset password token, the old one should be invalidated.
These limitations are in place to reduce the feasibility of brute-force attacks.
If time were unlimited, theoretically brute-force attacks could guess the token, so the key to preventing brute-force attacks is twofold: one is to increase the time required for cracking, making it long enough to exceed a thousand years or more, and the second key is to limit the time. There are several ways to do this, such as:
- Increase the base, for example, the possibility of a six-digit number is only one million, but if it is changed to a six-digit alphanumeric, there are 2 billion possibilities, and the number of guesses increases by 2000 times, requiring more time.
- Reduce the guessable time, for example, the token will expire after 300 seconds. If there are 100 million possibilities, then 300,000 guesses per second must be made to ensure success.
Next, let’s take a look at what happened to Matters’ reset password mechanism.
Matters’ Reset Password Mechanism
The following image is the interface for resetting the password on Matters. You enter your email and then a link is sent to your mailbox:
The reset password request looks like this:
{
"operationName":"SendVerificationCode",
"variables":{
"input":{
"email":"[email protected]",
"type":"password_reset",
"redirectUrl":"https://matters.news/forget?email=user%40example.com"
}
}
}
This is the link I received: https://matters.news/forget?email=user%40example.com&code=UYBQ912rhd_9s3TfywZnk1kQl6PCaDjPlXuNX3Df&type=password_reset
From here, we can see the first problem, which is that the front half of the received link is controlled by redirectUrl
. If we intercept the request and modify the redirectUrl
parameter to https://cymetrics.io
, we will find that the link received in the mailbox does start with https://cymetrics.io
!
In this way, we have the vulnerability mentioned earlier. If the user clicks the link in the email, our server will receive the token and can reset the user’s password.
Next, let’s see if there is a brute-force attack problem. The token itself looks quite complex, with a length of 40 characters composed of uppercase and lowercase letters, numbers, and underscores.
Although it doesn’t seem to be a problem, Matters is open source, so we can directly see how SendVerificationCode
is implemented. The code is here: https://github.com/thematters/matters-server/blob/v3.19.0/src/mutations/user/sendVerificationCode.ts
We are concerned with where the code is generated, mainly this part:
// insert record
const { code } = await userService.createVerificationCode({
userId: viewer.id,
email,
type,
strong: !!redirectUrl, // strong random code for link
})
And the code for userService.createVerificationCode
is here: https://github.com/thematters/matters-server/blob/v3.19.0/src/connectors/userService.ts#L1500
createVerificationCode = ({
userId,
email,
type,
strong,
expiredAt,
}: {
userId?: string | null
email: string
type: string
strong?: boolean
expiredAt?: Date
}) => {
const code = strong ? nanoid(40) : _.random(100000, 999999)
return this.baseCreate(
{
uuid: v4(),
userId,
email,
type,
code,
expiredAt:
expiredAt || new Date(Date.now() + VERIFICATION_CODE_EXIPRED_AFTER),
},
'verification_code'
)
}
From here, we see a key point, which is that the code generation in the system is divided into two types: strong is nanoid(40)
, and not strong is a six-digit number from 100000 to 999999. The strong parameter is determined by whether redirectUrl
is passed in.
That is to say, if we remove the redirectUrl
parameter when creating the reset password verification code, the code will instantly drop from 40 characters to a six-digit number!
The verification code’s expiration time VERIFICATION_CODE_EXIPRED_AFTER
is five minutes, or 300 seconds. 900000/300 = 3000. If we can send 3000 requests per second to the server, we can brute-force the reset password token and then take over the user’s account.
But this statement is not very accurate because we can send 3000, but it does not mean that the server can handle 3000. Therefore, we also need to consider the number of requests the server can accept, and there is another limitation to overcome before that.
Rate Limiting
One way to increase the difficulty of brute-force attacks is rate limiting, which many websites or WAFs have to prevent a large number of requests in a short period.
Matters’ rate limit is handled by nginx, and the code is here: https://github.com/thematters/matters-server/blob/v3.19.0/.ebextensions/rate-limit-connections.config
limit_req_zone $http_x_forwarded_for zone=application:16m rate=5r/s;
limit_req zone=application burst=20 nodelay;
limit_req_status 429;
limit_conn_status 429;
# pass real IP from client to NGINX
real_ip_header X-Forwarded-For;
set_real_ip_from 0.0.0.0/0;
server {
# set error page for HTTP code 429
error_page 429 @ratelimit;
location @ratelimit {
return 429 '["Connection Limit Exceeded"]\n';
}
listen 80;
# 底下省略
}
Nginx’s rate limit is mainly based on IP. If you really want to bypass it, you can try IP rotate. A simple way is to open many API gateways on AWS as proxies, and then you have a bunch of different IPs that can be used in rotation.
But we don’t need this technique here because we can see from the settings that it uses the $http_x_forwarded_for
parameter. If it is not managed properly, you can pass in X-Forwarded-For
to forge any IP and bypass the rate limit.
Matters obviously didn’t set it up properly, so the rate limit is virtual.
By doing this, as long as we can send 3000 requests per second, we can make a POC to prove that the attack is indeed feasible. But are there any other ways to reduce this number?
Simultaneous Verification Codes
At the beginning, I mentioned that there are some details to pay attention to when resetting the password, such as:
- The reset password token should only be used once.
- The reset password token should have an expiration time.
- If the user generates a new reset password token, the old one should be invalidated.
Matters has done the first two points, but not the third. From the code, we can see that when a new verification code is created, the old one is not deleted or marked as invalid.
What impact will this have? Let’s do some simple math!
There are a total of 900,000 combinations of verification codes. We have 300 seconds to attack. If we can send 900,000 requests during this time and the server can handle them, we can definitely guess the reset password verification code.
If we change it to not guess first, but send 1000 reset password requests, because the old verification code is still valid, we can guess once, and the probability of guessing any one combination is 1000/900000 = 1/900, which is 1000 times the original probability.
If we guess 1000 times, the probability of guessing correctly is “1 - the probability of not guessing correctly each time”, which is approximately 1 - (899/900)^1000
= 67%. If we guess 5000 times, the probability of guessing correctly is 1 - (899/900)^5000
= 99.6%.
In other words, as long as we send 1000 reset password requests plus 5000 confirmation code requests, a total of 6000 requests, we have a 99.6% chance of correctly guessing at least one verification code!
We can write a simple program to verify our probability calculation:
const _ = require('lodash')
const rounds = 100000 // 跑十萬輪取平均
const guessRounds = 5000 // 猜 5000 次
const tokenCount = 1000 // 1000 個合法驗證碼
let winCount = 0
for(let r=0; r<rounds; r++) {
let ans = {}
for(let i=0; i<tokenCount; i++) {
ans[_.random(100000, 999999)] = true
}
let isWin = false
for(let i=0; i<guessRounds; i++) {
const guessNumber = _.random(100000, 999999)
if (ans[guessNumber] === true) {
isWin = true
break
}
}
if (isWin) winCount++
}
console.log(winCount*100 / rounds)
// 輸出:99.626,我們算出的機率差不多
Originally, we had to send 900,001 requests to have a 100% chance of guessing correctly. Now, by sacrificing a little accuracy and reducing the probability to 99.6%, we can reduce the number of requests to 6000, which is 150 times lower!
Originally, we had to send 3000 requests per second in five minutes, but now we only need 20 requests per second (in fact, this is only a rough calculation because there is a sequence, and we must wait for the 1000 verification code requests to end before we can start guessing, and these 1000 requests may take a few seconds, but for convenience, we ignore them here, which has little effect on the overall situation).
Just because of this small flaw in the reset password, not eliminating the previous verification code, we can generate multiple verification codes, greatly reducing the difficulty of brute force cracking. As long as we can send 6000 requests in five minutes, we have a 99.6% chance of changing the password of one account correctly.
Since this is the reset password function, after changing the password, you can directly log in to the system with their identity and achieve account takeover, making other people’s accounts all yours. If you want to expand your influence, you can take over the administrator’s account, and then you have the opportunity to enter the management background for more operations.
Suggested Fixes
The first thing to fix is the small flaw in resetting the password. When the user generates a new verification code, the old one should be eliminated to ensure that only the latest one can pass the verification, so the probability of the attacker guessing correctly is always 1/n, and it will not be like the example above, which can increase the probability by 1000 times or more.
The second is that the generation of verification codes should not be determined by the redirectUrl
parameter, but should be determined by the type of verification code. If it is a reset password, it must be strong, so nanoid(40)
will be used to generate it, and the probability of guessing correctly will become very small, greatly reducing the feasibility of brute force cracking.
The third is that the redirectUrl
should not be passed in from the front end, but should be written directly in the back end. If it really needs to be passed in from the front end, the back end should do a good job of comprehensive checks to ensure that the redirectUrl
passed in is a legal path, not allowing attackers to pass in any URL (but if the attacker can combine open redirect, it is another matter).
The last one is that the rate limit restriction of nginx should not be determined by X-Forwarded-For
. Even if it is really necessary to use this, make sure that its value cannot be passed in by the attacker.
Summary
The password reset mechanism seems simple, but it is still possible to create vulnerable mechanisms carelessly, which allows attackers to take advantage of them. There is a page on HackTricks that specifically discusses possible issues with reset password: Reset/Forgotten Password Bypass. In addition to the issues mentioned in this article, there are many more issues that are detailed and worth referring to.
If you think that only ordinary websites will have such problems, then you are wrong. A security researcher, Laxman Muthiyah, found that he could bypass the rate limiting of Instagram by using concurrent methods, successfully sending 200 requests, and generating 200,000 requests with 1,000 machines, with a 20% chance of success.
As long as there are 5,000 machines, any account can be taken down. 5,000 machines may sound like a lot, and the cost should be high, right? But if cloud services are used wisely, he estimates that it may only cost about $150 to achieve an attack (because it is charged by the hour, and only needs to be opened for one or two hours).
He also used the same method to bypass the rate limiting of Microsoft in July last year and won a $50,000 prize.
If this kind of vulnerability can be successfully exploited, it can directly take over someone else’s account, which has a significant impact and requires more attention to related security. Seeing this, everyone may also want to check whether their own password reset mechanism is secure.
Finally, after finding the vulnerability, it was also reported to Matters. The complete timeline is as follows:
2021-06-24
Reported the vulnerability to Matters2021-06-25
Received a reply from Matters confirming the existence of the vulnerability2021-08-20
Matters fixed some functions and will eliminate old verification codes when generating new ones2021-08-26
Matters confirmed the vulnerability rating as High and awarded a bounty of 150 USD2021-10-28
Inquired about the follow-up repair status and confirmed whether it was repaired2021-11-30
Matters strengthened the base of non-strong verification codes2021-12-02
Completed the initial draft of the article and confirmed with Matters whether it can be published2021-12-21
Matters confirmed that the issue has been fully repaired and the article can be published.
Comments