Using Session Puzzling to Bypass Two-Factor Authentication

Sessions are an essential part of most modern web applications. This is why session-related vulnerabilities often have a sizable impact on the overall security of a web application. They frequently allow the impersonation of other users and can have other dangerous side effects.

What Are Session Variables?

For those not familiar with session variables, they are server-side variables whose value is tied to the current session. This means that if a user visits the website, you could store their username in the session variable as they log in and it will be available until the session expires or the user logs out. If another user logs in, that triggers a new session and the session variable will return a different username for that particular user.

Session Variable Example

Let's take a look at an example of how session variables work. Be aware that this example uses stripped-down pseudocode to help illustrate an otherwise complicated concept. Do not use anything like this in production!

Login

user = getUser(input['username']);

if(compare_hash(input['password'], user.hash) === true) {

session['username'] = user.name;

session['logged_in'] = true;

return true;

} else {

session['logged_in'] = false;

return false;

}

Index

if(session['logged_in'] === true) {

print('Hello ' + sanitize(session['username']))

}

If a user called Alice logged in, she would be greeted with "Hello Alice". If Bob was logged in at the same time and opened the same page, he would see "Hello Bob" instead. The session variable is available across different files and isn't restricted to file it is declared in. This can lead to a complication.

Session Puzzling

In this example, we're going to look at three different files. Try to spot the problem in the code before you continue reading the article. Also, not that this example contains vulnerable pseudocode. Do not use anything like this in production!

Login (snippet)

01 // check if phone number is confirmed

02 if (user.phone_number_confirmed === true ) {

03 // set the `confirmed` session variable to true since we need to check it later

04 session[ 'confirmed' ] = true ;

05 }

06 // the user wants to get notified if somebody logs in to their account?

07 if (user.notify_on_login === true ) {

08 // we need to check this as well

09 session[ 'notify_on_login' ] = true ;

10 }

Index (snippet)

01 // we handle the login notifications here

02 if (session[ 'notify_on_login' ] === true ) {

03 var message = 'Somebody just logged into your account.' ;

04 // if the phone number is confirmed...

05 if (session[ 'confirmed' ] === true ) {

06 // we send an SMS text message

07 sendTextMessage(message);

08 // if the phone number is not confirmed

09 } else {

10 // we send an email instead

11 sendEmail(message);

12 }

13 }

Admin (snippet)

01 // if the user submitted a password

02 if (input[ 'password' ] !== null ) {

03 // check if it matches the one that's required to access the admin panel

04 var result = check_admin_password(input[ 'password' ]);

05 // if it's correct...

06 if (result === true ) {

07 // confirm that the user was logged in

08 session[ 'confirmed' ] = true ;

09 } else {

10 // set the confirmation to false

11 session[ 'confirmed' ] = false ;

12 }

13 }

14 // if the user didn't supply the correct password

15 if (session[ 'confirmed' ] !== true ) {

16 print( 'You must proof that you are allowed to visit the admin section.' )

17 print( 'Please type in the password.' );

18 // generate a password form and just exit

19 generatePasswordForm();

20 exit();

21 // if the user supplied the right password....

22 } else {

23 // load the admin section and grant access

24 loadAdminSection();

25 }

Did you spot the vulnerability? If you found it, then congratulations! But don't worry if you couldn't spot it right away. We'll explain what went wrong in the above code.

Let's summarize the login snippet. What happens here is that we check whether or not the user wants to be notified whenever someone logs into their account. It also checks whether or not the phone number has been confirmed in line 2. If that's the case, the confirmed session variable is assigned the value 'true'.

In the index snippet, we see why this session variable is used. If the user wants to get notified when someone logs into their account, it checks the confirmed variable in line 5 and sends an SMS in line 7 if the user has confirmed their phone number. If there is no confirmed phone number, it will send an email instead.

The actual problem arises in the admin snippet. In the lines 2-13, it checks whether or not a password was supplied and sets a session variable to 'true' if the password matches the one that's needed to access the admin section of the website. However, if you take a closer look you will see that this session variable has the same name ('confirmed') as the one that's being used to check whether or not the user confirmed their phone number. In line fifteen, it checks whether or not the confirmed session variable is 'false' (or undefined). And if it's set to 'true' it will load the admin section in line twenty-four.

We don't need to know the password here. When we confirm our phone number, the confirmed session variable will be set to 'true' in line 4 of the login snippet. So if our phone number is confirmed, we can also access the admin panel without typing in a password. The problem here is that session variables are valid across files and that we use the same variable name for different functionality. This is called Session Puzzling.

Bypassing Two-Factor Authentication by Taking Advantage of Missing Access Controls

Two-Factor Authentication (2FA) is a security feature that prevents your account from being stolen if an attacker knows your password. The website you're logging into requires you to provide a second code, in addition to your normal password. Ideally this code has been generated by using a Time-based One-Time Password (TOTP) algorithm. In most cases, if you enable 2FA, the website provides you with a string of letters and numbers, or a QR code that you need to scan or type into an app on your phone. It will also provide you with some backup code, in case you lose access to your phone.

The app will then continuously generate a new, additional password based on the secret code and the current UNIX timestamp. Usually, these additional passwords are regenerated every 30 seconds (think Google Authenticator). The idea behind this is that it may be possible for an attacker to retrieve your password by various means, but it's often infeasible for them to gain possession of the device on which your second code (2FA) is generated. In addition to smartphones, there are also dedicated hardware devices that can be used for generating these codes.

The question for an attacker is: can 2FA be bypassed?

In a lot of cases, the answer is 'yes'. TOTP is not the only method websites use to implement 2FA. Some use emails that contain the code, while others use an SMS or a phone call. Because consumers reuse passwords, the website password and the email account password are often the same word. Therefore, an attacker can simply log into the email account and read the code. Using different techniques and tricks, attackers can also intercept SMS text messages and phone calls. My conclusion is that TOTP is the way to go.

What About the Server-Side Implementation?

However, it also depends on the server side implementation of both the algorithm and the 2FA prompt. The possibility of bypassing 2FA is not a totally new concept, but it was once again proven by Nikhil Mittal. He found a way to bypass it without even touching the underlying token generation algorithm. Instead, he used a server-side bug that was present due to careless session handling. In this instance, it lead to an access control problem.

Since it was a private bug bounty program, Nikhil Mittal was unable to disclose its name. What he was allowed to tell was how he was able to bypass Two Factor Authentication. First he outlined what a typical 2FA login flow on the website looked like:

The user provides an email address and a password A valid 2FA code is sent to the user's registered telephone number The website asks for the 2FA code The user types in the code The user is logged in

We briefly mentioned that sometimes there are backup codes. Users who lose their device or SIM card have an alternative: select the backup code option in Step 3 and use one of their backup codes.

Nikhil noticed that the websites session basically has two states:

Username and password have been supplied correctly, but the 2FA code has not yet been provided Username, password and the 2FA code have all been supplied correctly

Obviously, you have unlimited access to all settings if are in the second state. You can regenerate your backup codes and edit other settings too. But are users in the first state really limited to typing in their 2FA token?

Nikhil Mittal was curious about whether he would be able to access other functionality in the first state. So, he issued a request to the website that would return the backup codes if he were in the second state. The expected behaviour is that the application would throw an error due to missing privileges. The surprise was that it returned the backup codes! That meant that he was simply able to log in with the correct username and password, retrieve the user's backup codes, select the option to use them instead of the actual 2FA code and then supply one of the stolen ones. This immediately granted him access to the account – Two Factor Authentication was bypassed.

For further information about the vulnerability he found, and what requests he issued in order to retrieve the backup tokens, we highly recommend reading Nikhil Mattal's writeup, How I bypassed 2-Factor Authentication in a bug bounty program.