Password reset code brute-force vulnerability in AWS Cognito

The password reset function of AWS Cognito allows attackers to change the account password if a six-digit number (reset code) sent out by E-mail is correctly entered. By using concurrent HTTP request techniques, it was shown that an attacker can do more guesses on this number than mentioned in the AWS documentation (1587 instead of 20). If the attack succeeds and the attacked accounts do not have multi-factor authentication enabled, a full take-over of the attacked AWS Cognito user accounts would have been possible. The issue was fixed by AWS on 2021-04-20.

Impact

An attacker who guessed the correct reset code can set a new password for the attacked AWS Cognito account. This allows attackers to take over the account that is not using additional multi-factor authentication.

Timeline

  • 2021-03-17: Discovery of the issue by Pentagrid

  • 2021-03-22: Initial contact of AWS security according to the vulnerability reporting website

  • 2021-03-23: AWS responds the issue is being investigated, Pentagrid responds with vulnerability details

  • 2021-03-24: AWS requests more details, Pentagrid responds with more vulnerability details

  • 2021-03-25: AWS requests more details about late invalidation of password-reset codes

  • 2021-03-26: Pentagrid responds with more details regarding late invalidation of password-reset codes

  • 2021-04-01: AWS responds the issue is still being investigated

  • 2021-04-08: AWS responds the issue is still being investigated and asks for a phone call

  • 2021-04-13: to 2021-04-19: More E-mails regarding organisation of the phone call

  • 2021-04-22: Phone call between AWS security and Pentagrid AG: Issue has been fixed for all AWS customers on 2021-04-20

  • 2021-04-27: Pentagrid verifies the fix, only 20 code mismatches could be provoked.

  • 2021-04-30: Advisory published

Affected Components

AWS Cognito user accounts are affected.

Technical details

AWS Cognito is an AWS Service that provides user account management. This includes a password reset functionality as a self-service for Cognito end-users.

The attack described here assumes that the AWS Cognito service is directly exposed to the web without custom filtering in the program code of the AWS customer. This is likely to be a common configuration. In the setup that was tested, an AWS Lambda instance forwarded requests to the AWS Cognito service by using the Python Chalice library.

If no attack happens, the user's workflow is as following:

  1. The user enters his account's E-mail address in the password forgotten web form

  2. The user receives an E-mail that includes a six digit password reset code (from now on this is referred to as "code")

  3. By entering them into forms, the user sends the code, his account's E-mail address and a new password to the password forgotten functionality. This triggers the confirm_forgot_password function shown below.

As the code is the only secret value, it is crucial that an attacker can not guess its value. The codes have six digits, therefore the key space is 1'000'000, which corresponds to around 20 bits of security. As this is not sufficient from a generic security perspective because an attacker could potentially brute-force the code, AWS has to limit the number of times it can be entered.

After the three-step process is done, the server will respond with one of the following responses:

  1. If the code is correct, the password for the account is immediately changed to the new value (HTTP 200 response)

  2. If the code is incorrect, the server responds with a cognito_client.exceptions.CodeMismatchException, further referenced as code mismatch (HTTP 500 response)

  3. If the confirm_forgot_password function was called too many times, the server responds with a cognito_client.exceptions.LimitExceededException, further referenced as limit exceeded (HTTP 500 response)

  4. An internal server error if something went wrong, the source of this error is unknown to Pentagrid (HTTP 500 response)

An attacker would like to trigger the code mismatch as many times as possible until the password is changed, without triggering the limit exceeded response. The AWS documentation about password resets of AWS Cognito accounts states:

In a given hour, we allow between 5 and 20 attempts for a user to request or enter a password reset code as part of forgot-password and confirm-forgot-password actions. The exact value depends on the risk parameters associated with the requests. Please note that this behaviour is subject to change.

There is no more information from AWS about how exactly the rate limiting is implemented. During our testing the behaviour of this rate limiting was non-deterministic in two ways:

  1. The amount of times the code mismatch message could be triggered before a limit exceeded message was received was non-deterministic. In our tests we were able to trigger between 20 and 1587 code mismatch responses with the used concurrency technique.

  2. Even after the limit exceeded message was received, this does not mean that the code was invalidated. Although an attacker has to wait until the AWS rate limiting cool-down is expired, he can then repeat the attack until a limit exceeded value is received again. In our tests, the limit exceeded could be received at least twice for the same code before the entire attack had to be restarted.

We were able to show that, using a concurrency technique that sends requests simultaneously, it was possible to try up to 1587 codes before being blocked with a limit exceeded message for the first time. 1587 attempts corresponds to a 0.16 % chance to guess the correct code in one attack. As the attacks conducted allowed anywhere between 20 and 1587 guesses, it is unclear how successful an attacker would be if multiple user accounts would be attacked simultaneously.

The approach we used is based on opening several hundred TCP connections simultaneously, send all bytes except the last byte in each of the HTTP requests and then try to simultaneously send the last byte of the requests at once. This technique was developed by James Kettle of PortSwigger and is described in the corresponding blog post "Embracing the billion-request attack". The attack was conducted by using the Turbo Intruder tool created by James Kettle. More specifically, the race.py script of Turbo Intruder was used.

The Turbo Intruder setup is shown in the following picture:

Screenshot of the Burp Turbo Intruder showing the attack configuration.

For demonstration purposes, in some of the guesses sent during the attacks, the known correct code was sent which returned an HTTP 200 response, changing the password of the attacked account immediately, which proofed that the attack did work.

The likelihood that an attack succeeds depends on how the undeterministic parts of AWS Cognito would behave during the attack, but we assume successful attacks would have been possible. First, a single attack with a 0.16 % chance means that every 625th attack would succeed. Second, the attack could be repeated at least once for the same code after the cool-down, meaning every 312th attack could succeed. Moreover, the entire attack could be repeated and additionally applied to different AWS Cognito user accounts in parallel to increase the likelihood to succeed at all.

Preconditions

  • The attacker's requests have to be forwarded to AWS Cognito quickly without disturbing the attack (e.g. no delays or heavy processing)

  • The attacker has to know valid E-mail addresses with an account on the AWS Cognito instance.

  • The attacker has to be able to turn the attack into a feasible attack, for example by attacking many accounts at the same time or find a way to trigger the attacker's best-case situation more often and therefore improving the probabilities for the attacker.

  • The attacked accounts cannot have multi-factor authentication enabled, otherwise the attack fails (although the password will be changed, a login is not possible without the second factor).

  • The attacked user will get at least one E-mail and depending on how many times the attack is repeated will get several E-mails that include reset codes. The attacked user has to ignore the reset E-mails sent to his mailbox and not alert the AWS customer or AWS about the suspicious E-mails the user did not trigger himself.

Recommendation

  • The issue was fixed by AWS on 2021-04-20. According to AWS the issue was not actively exploited.

  • It is recommended to all AWS Cognito customers to require multi-factor authentication on AWS Cognito accounts to improve security.

  • AWS customers can implement their own rate-limiting before sending requests to AWS Cognito.

Steps to reproduce

As the issues is fixed, it is not reproducible any more. But for future reference we include the steps here:

  1. Setup AWS Lambda with the Chalice library and the necessary AWS Cognito components. The Python program code used in the setup was similar to what is explained on this blog post about AWS cognito with Python but instead of the boto3 library the Chalice library was used. The concurrency and speed of the library might be important for the attack, as concurrency issues in the AWS Cognito service will be exploited. The attack was only tested with the Chalice library. Here is some pseudo-code that shows the most important function that was exploited during our research:

from chalicelib.common.app import app
from chalicelib.common.auth import (cognito_client, get_hmac_digest)

# route the HTTP request to this function
def confirm_forgot_password():
    req = app.current_request
    body = req.json_body
    username = body['username']
    password = body['password']
    code = body['code']
    digest = get_hmac_digest(username)
    resp = cognito_client.confirm_forgot_password(
        ClientId=APP_CLIENT_ID,
        SecretHash=digest,
        Username=username,
        Password=password,
        ConfirmationCode=code)
    # Now make sure that the response from the AWS Cognito server is sent back in the response to the client of this API
  1. Setup an AWS Cognito user account.

  2. Request a password reset via E-mail for the AWS Cognito user account.

  3. Prepare BurpSuite that allows you to repeat HTTP requests.

  4. Send an HTTP request to the confirm_forgot_password AWS Lambda function with an incorrect code, some new password you would like to set and the correct username of the AWS Cognito user account (for example the E-mail address). Make sure you also see the request in BurpSuite. You get an HTTP response that includes code mismatch.

  5. Send an HTTP request to the confirm_forgot_password AWS Lambda function with a correct code. You should get an HTTP response with no error message in it.

  6. Send the incorrect code HTTP request from point 5 about 30 times (e.g. Repeater functionality in BurpSuite). At one point you should get the error cognito_client.exceptions.LimitExceededException.

  7. Wait several hours or use a different AWS Cognito user account, so the limit exceeded does not apply any more.

  8. Request a password reset via E-mail for the AWS Cognito user account

  9. Install Turbo Intruder in BurpSuite.

  10. Send the HTTP request that is sent to the confirm_forgot_password AWS Lambda function to Turbo Intruder.

  11. Choose the race.py preinstalled script of Turbo Intruder.

  12. Replace the code in the HTTP request (for example 123123) with %s to tell Turbo Intruder where to inject

  13. Replace the race.py script part with the following script:

def queueRequests(target, wordlists):
    tries = 800
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=tries+10,
                           requestsPerConnection=1000,
                           pipeline=True
                           )

    # the 'gate' argument blocks the final byte of each request until openGate is invoked
    area = 100100
    for i in range(area - int(float(tries)/2), area + int(float(tries)/2)):
        engine.queue(target.req, str(i).zfill(6), gate='race1')

    # wait until every 'race1' tagged request is ready
    # then send the final byte of each request
    # (this method is non-blocking, just like queue)
    engine.openGate('race1')

    engine.complete(timeout=60)

def handleResponse(req, interesting):
    table.add(req)
  1. Click the "Attack" button.

  2. You should see all the HTTP responses after a while. Usually there are at least 20 answers that include the message code mismatch. If you are lucky you can see up to 800 code mismatch responses.

  3. The attack can be repeated until one limit exceeded response is triggered. When it is received, repeat the attack after waiting some time (for example an hour). If you get an answer saying the reset code expired, you have to start the entire attack from scratch.

  4. If you get an HTTP 200 answer, you successfully attacked the user account and reset the password.

Credits

This issue was found by Tobias Ospelt of Pentagrid AG. Pentagrid AG would also like to thank the AWS Security team for the collaboration on this issue.