Introduction

Hi everyone! In this note, we will cover two vulnerabilities with the impact of account takeover that I found when testing the security mechanism implemented as part of authentication using a one-time password. The tested Bug Bounty program is private and doesn’t allow public disclosure. Nevertheless, the developers agreed to anonymous disclosure without mentioning the company.

Before proceeding with the bug’s explanation, let’s learn about the related part of the application. The application uses a classic authentication using login and password. Still, when it comes to password reset, we need to enter a 6-digit code that comes to our email box or phone number after entering our data. After sending a code 41 times, I stumbled upon a strict rate limit that can’t be bypassed using basic methods such as IP rotation or case modification.

Bug #1: Account takeover w/o user interaction via gradual brute-force of password recovery code due to a lack of code/session expiration and short rate limit

After getting started with testing, I discovered that although there is a rate limit, it lasts only a few minutes. Also, there is no password reset session expiration even after 4 hours, and the application doesn’t request a new OTP code and doesn’t invalidate the old one. Using these omissions, I started brute-force the OTP value with throttling and a reduced number of threads. To prove the vulnerability exists, the results of a successful brute force attack in (the number of valid code - 200 + 1) attempts using one thread with 3 seconds of delay were added to the report. As I learned later, on average, the (former) throttle implementation allows account takeover without user interaction in 3.5 days of constant and slow brute force.

Suggested mitigations

  1. Add a code invalidation after X attempts.
  2. Set specific expiration boundaries for the session used for code acceptance and validation. Plenty of websites do this with login, password recovery, or other functionalities, for example, the NY times:

Untitled

(Not compulsory but recommended). Add a more complex code that includes letters to reduce brute force attack risks.

Impact

Account takeover without user interaction via gradual brute force. An attacker can brute a password recovery code without the risk of invalidation. The code’s life span is likely more than 4 hours, so an attacker can brute-force a code for a long time on a low amount of threads to avoid the rate limit, which finally gets them a valid code and instant access for password setting. Once the password is changed, the attacker will get direct access to the account in no time, considering a lack of conditional or other types of MFA.

After providing the report, a bug was assigned with a Critical severity, fixed, and rewarded:

Untitled

Bug #2: Account takeover w/o user interaction via brute force due to a lack of correlation between organization token and OTP code and linear growth of attempts

As a result of the fix for my previous report, developers improved the rate limit in the OTP verification request, which now allows for only five attempts in 60 seconds. It was also noted that the code’s lifetime was reduced, but I couldn’t confirm the exact duration. I conducted additional studies and applied other attacks to verify OTP in the password discharge function but couldn’t detect serious vulnerabilities in the functionality after introducing new security measures. And yet, after expanding the attack’s surface, I noticed that OTP authentication is also used in the subdomains of thousands of organizations, which serve as landing, informational, donations pages, etc., and any user can initiate login process on any of them. The login flow in an organization’s subdomain looks like the following:

  1. User first visits an organization’s instance, and the following request for the generation of the organization’s hash token is automatically executed:
POST /session/tokens HTTP/1.1
Host: example-org.unknown.com

It returns an organization token in the response:

{"type": "org_token", "id": "86161846", "attributes" :{ "token": "df7bf48a61292d571ce11175052921ce12fa1713383f9e1a16f3b2f01eb9732b", "expires_at": "-", "created_at": "-", "updated_at": "2021-03-03T21:24:56Z"}}

The token value will accompany any subsequent requests related to OTP and is provided in the Authorization: OrgToken VALUE header. Such a header is compulsory, and requests will fail if it’s not provided or an incorrect value is provided.

  1. User inputs and sends their phone number or email to log in, and the application executes the following request:
POST /api/v3/verified_sessions HTTP/1.1
Host: api.unknown.com
Authorization: OrgToken df7bf48a61292d571ce11175052921ce12fa1713383f9e0a16f3b2f01eb9732b
Content-Type: application/json
Accept: application/json

{"data":{"type":"verified_session","attributes":{"[email_address":"w2w@wearehackerone.com](mailto:email_address%22:%22w2w@wearehackerone.com)"}}}

with a similar JSON body in the response:

"type": "verified_session", "id": "50840108", "attributes" :{" [code_sent_at": "-", "email_address": "w2w@wearehackerone.com](mailto:code_sent_at%22:%222023-01-05T09:24:52Z%22,%22email_address%22:%22w2w@wearehackerone.com)", "kind": "email", "matches_in_organization":true, "phone_number":null, "verified":false}, "relationships" :{" organization ":{" data ":{" type": "Organization", "id": "-" }}},

This specific request triggers a creation of the verified_session value in the response, which is an identifier of the temporary “login session,” and sends an OTP to an email or phone number. In addition, if we try to perform the above request multiple times, a new OTP will be generated every time.

  1. For the OTP check, the following request is used:
POST /api/v2/verified_sessions/50840108/verify HTTP/1.1
Host: api.unknown.com
Accept: application/json
Authorization: OrgToken 72b05ba41dd3099fc5c2cbc3abafd3fee6de576d32e377ef02d5d0e78243476d
Content-Type: application/json

{"data":{"attributes":{"code":"123456"}}}

After five requests in the 60 secs timeframe in the request above, the rate limit is triggered, and API returns a 429 error. It was found that the rate limit is based only on the OrgToken value, and if another value that was previously generated is used, the rate limit will be reset. I couldn’t find a correlation between OrgToken and generated OTP code based on the token, so the last sent OTP code will successfully correlate with any generated OrgToken. In this case, we can create and tie a large amount of OrgToken values and use them to bypass a rate limit, resulting in an account takeover eventually.

Steps to reproduce (the easy way)

  1. Visit https://example-org.unknown.com in 2 different tabs, and turn on burp suite - you’ll see two different OrgToken values are generated.
  2. Enter your phone number/email in both tabs, and skip the phone specifying if prompted.
  3. In the tab you used for sending a code a second time, enter the incorrect code 6 times and observe a response with 429 code (UI won’t show an error).
  4. In another tab, enter the incorrect code - you’ll see the rate limit has gone.
  5. Enter the actual code from the email box.

Result: You confirmed a lack of correlation between OrgToken and the OTP code if you used a new code with the old token.

Suggested mitigations

  1. Add a rate limit per user (userID), not per OrgToken - it’s more reliable.
  2. If the first option can’t be applied, add a correlation between OrgToken and OTP.

Impact

Due to a lack of correlation between OrgToken'and OTP, an attacker can avoid rate-limit, which finally gets them a valid code and instant account access. After the additional check, it was confirmed that the expiration for the OTP session was added. At first, I thought there was a lifespan of 40 minutes. However, when I started an intruder attack with 30 secs delay and enumeration time surpassed 1 hour, it was discovered that the expiration timer begins when values are not used. Thus, if constantly sending requests with the OrgTokenandverified_session` values to maintain their validity, there’s an opportunity to use these pairs for login code brute-force.

When the report was delivered to the program, and the Hackerone analyst successfully reproduced the behavior, it turned out that additional protection activates the rate limit for generating the Verified_Session value. During the check of a report by the company’s developer, we began a discussion regarding the bug’s reproduction and exploitation.

Developer:

@w2w, The organization token that was used to create the verified_session must be used to verify it. Further, rate limiting is based on the verified_session ID – not on any other factors. We cannot reproduce the claims made in this report. I even attempted to make a POC. If you can get that POC to work, or build your own, then I’ll be happy to accept the report.

Me:

Hi @username. Indeed, the rate limit is tied not to the user itself or the OrgToken but to the pair verified_session and OrgToken, which are dynamic values. I still see the problem persists, and it’s possible to bypass the rate limit by rotating verified_session and OrgToken in the request. I see that the requests performed and their sequence in the written POC are correct. However, I can’t confirm that there is no place for a minor bug that spoils the process since I’m unfamiliar with Ruby. I did a quick test on UI and recorded a video, which may clarify something.

Developer:

Oh, I think I see what you’re saying. You’re using a different verified_session ID to bypass the rate limit in place on the first one. Indeed, the rate limit is per verified_session. So one could get a fresh rate limit by creating a new verified_session (i.e. starting with a new verification process, i.e. a new window), however, we also rate limit creation of the verified_session itself to 2 every 30 seconds per email/phone number. What I’m saying is, in the video you provided, your second window will be rate limited after 5 attempts. And you would not be able to start a third window until another 30 seconds have elapsed. I hope that helps clear things up. If you are able to show POC with Python, that would be fantastic. As it stands now, I cannot think of a way to bypass the rate limits in place.

Me:

@username, now that’s interesting. I didn’t see an additional rate limit on the verified_session creation initially, only the usual rate limit, which is 5 per minute. I crafted a POC to check your statement and got an error after the second iteration (in some cases, an app allowed to send three valid requests). By abiding by the rate limit restrictions, it’s possible to send 10 requests per 30 secs - 5 requests per each verified_session, which eventually exceeds the limit you set for code check - 20 vs. 5 per 60 seconds. The length of the OTP is six digits, which means 999 999 combinations:

  • in 30 secs, we will generate 2 pairs and make 10 requests;
  • in another 30 secs, we will generate additional 2 pairs and send 20 requests in total;
  • 30 secs later, the rate limit for the code’s check for the first two pairs will be reset (for the second 2 we need to wait 30 more secs), which means we can use the first 2 pairs, generate and use 2 new pairs and make 40 requests in total. It looks similar to exponential growth.

I’ve written a POC for iterations, including all generated pairs:

import requests
import re
import time
 
url1 = "https://test-org.unknown.com/sessions/tokens"
 
url2 = "https://api.unknown.com/api/v3/verified_session"
 
orgttoken_and_sessionid_list = []
iteration_number = 0
 
for a in range(200):
     for b in range(2):
         request1 = requests.post(url1)
 
         orgttoken = str(re.findall('token"\:"(.*?)"\,"expires_', request1.text)).replace("['", "").replace("']", "")
         request2 = requests.post(url2, headers={'Authorization': 'OrgToken %s' % orgttoken,
                                                 'Content-Type': 'application/json'},
                                  data='{"data":{"type":"VerifiedContact","attributes":{"email_address":"w2w@wearehackerone.com"}}}')
 
         sessionid = str(re.findall('"id"\:"(.*?)"\,"attributes"', request2.text)).replace("['", "").replace("']", "")
 
         orgttoken_and_sessionid_list.append(orgttoken + ":" + sessionid)
 
     for value in orgttoken_and_sessionid_list:
         iteration_number += 1
 
         orgttoken_for_the_loop = str(re.findall("^(.*):", value)).replace("['", "").replace("']", "")
         sessionid_for_the_loop = str(re.findall(":(.*)$", value)).replace("['", "").replace("']", "")
 
         for c in range(5):
             url3 = "https://api.unknown.com/api/v2/verified_sessions/%s/verify" % sessionid_for_the_loop
             request3 = requests.post(url3, headers={'Authorization': 'OrgToken %s' % orgttoken_for_the_loop,
                                                     'Content-Type': 'application/json'},
                                      data='{"data":{"attributes":{"code":"123456"}}}')
             print("iteration %s ,"%iteration_number + request3.text + "\n" + "OrgToken :" + orgttoken_for_the_loop + ", contactid: " + contactid_for_the_loop + "\n")
 
     time.sleep(30)

More and more requests are sent on every iteration using all the pairs accumulated in the list, + new ones are coming.

Developer:

We were able to confirm this on our end. As you suggested, the number of attempts grows linearly with time. After a few hours, I was able to make 1500 OTP attempts per minute. By my rough estimation, one could, on average, brute force a login code within 4-5 hours. I set Privileges Required: None in the CVSS Calculator and that bumped the severity to Critical.

After providing the report, a bug was promptly fixed and rewarded:

Untitled

I hope this blog post was interesting for you. Take care and until next time! 🫡