While auditing internal infrastructure for Radically Open Security, I discovered a weakness in the devise-two-factor Time-based One-time Password (TOTP) library. With the help of Chris MacNaughton, we confirmed the vulnerability and informed the upstream vendor of the library.
This article has some details about the vulnerability and disclosure.

Consulting

I’m a freelance Security Consultant and currently available for new projects. If you are looking for assistance to secure your projects or organization, contact me.

TL;DR

A popular Ruby TOTP two factor server-side library lacks built-in protections against brute force attacks of the verification step. Due to design limitations of the underlying standards, this allows bypassing the second factor in targeted attacks against some web applications. The brute force attacks are practical in common configurations and take a few days or less to break into an account for which the main password is known.

Introduction & Background

Two-factor authentication (2FA) mechanisms are security systems designed to make account takeovers harder to accomplish and more difficult to scale, while at the same time having a bearable overhead cost to the users they’re protecting.

As such, 2FA mechanisms often have a number of trade-offs which weaken their overall effectiveness in order to make them easier to use.

The Time-based One-time Password (TOTP) standard at the center of this vulnerability is a popular 2FA mechanism specified in RFC6238. TOTP is basically a time-based adaptation of the HMAC-based One-Time Password (HOTP) algorithm standard as specified in RFC4226, and has inherited most of HOTP’s general design. If you have a smartphone security app that shows you some simple secret code which changes every 30s and can be typed into websites to confirm your login, TOTP is probably the standard that you’ve been using.

For this article, the most relevant security design choice of TOTP/HOTP was to keep the “one time password” secret very short and low in complexity. The commonly used TOTP default parameters require just six numerical digits for this temporary password.

Most readers are likely aware that a 6-digit account password is very insecure under normal conditions. If there are no consequences for wrong guesses, an attacker can simply try every number combination between 000000 and 999999 and find the valid password - a so-called “brute force” attack. Compare this to the short numerical PIN on your banking card: if the bank’s ATM system enforces no limit on incorrect PIN attempts, a persistent thief with your card can circumvent that second factor fairly easily by trying combinations for a while. It’s only the restriction on a few wrong PIN attempts before blocking the card that makes this a reasonable system.

The authors of the HOTP standard were fully aware of this design limitation, which they described in section 7.3 of their RFC:

Truncating the HMAC-SHA-1 value to a shorter value makes a brute force attack possible. Therefore, the authentication server needs to detect and stop brute force attacks.

They described two potential countermeasures that implement a lockout or delay based defense:

We RECOMMEND setting a throttling parameter T, which defines the maximum number of possible attempts for One-Time Password validation. […]

Another option would be to implement a delay scheme to avoid a brute force attack. After each failed attempt A, the authentication server would wait for an increased T*A number of seconds, e.g., say T = 5, then after 1 attempt, the server waits for 5 seconds, at the second failed attempt, it waits for 5*2 = 10 seconds, etc.

This security recommendation was public since at least the October 2004 draft, almost two decades ago at this point. However, it is not always adopted correctly, making it a potential weak point for attacks that target the second factor.

TOTP Brute-Force Vulnerability in devise-two-factor

Radically Open Security, a non-profit computer security consultancy that I work with as a freelancer, runs an instance of the open source EyeDP identity provider software. EyeDP is used for single-sign on (SSO) login access to internal services. During some internal audit work, I discovered a suspicious absence of TOTP anti brute-force defenses in the EyeDP code.

After reaching out to EyeDP’s developer Chris MacNaughton, we were able to confirm together that EyeDP is susceptible to brute forcing the TOTP codes to bypass the 2FA. EyeDP is a Ruby application that uses the Ruby devise authentication framework for auth handling and the devise-two-factor library extension to implement its 2FA mechanisms. We quickly found out that the upstream devise-two-factor also does not have any protections against one time password (OTP) brute force attacks, which EyeDP implicitly relied upon.

Here is a relevant code excerpt from devise-two-factor:

def validate_and_consume_otp!(code, options = {})
    otp_secret = options[:otp_secret] || self.otp_secret
    return false unless code.present? && otp_secret.present?

    totp = otp(otp_secret)

    if self.consumed_timestep
        # reconstruct the timestamp of the last consumed timestep
        after_timestamp = self.consumed_timestep * otp.interval
    end

    if totp.verify(code.gsub(/\s+/, ""), drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift, after: after_timestamp)
        return consume_otp!
    end

    false
end

two_factor_authenticatable.rb L36-L52

As you can see, if the totp.verify() call does not succeed, the function returns false to signal failure but doesn’t change any state in memory or in the database to count the failed attempt. Some form of bookkeeping would be necessary to recognize that a subsequent failed attempt has exceeded some threshold and enforce any delay or lockout countermeasure scheme.

In case of a successful login, there is a protection mechanism which tracks the last used OTP code self.consumed_timestep to prevent its re-use after a successful login:

# An OTP cannot be used more than once in a given timestep
# Storing timestep of last valid OTP is sufficient to satisfy this requirement
def consume_otp!
    if self.consumed_timestep != current_otp_timestep
        self.consumed_timestep = current_otp_timestep
        return save(validate: false)
    end

    false
end

two_factor_authenticatable.rb L79-L88

This solves previous security issues in devise-to-factor, namely CVE-2015-7225 and CVE-2021-43177 concerning OTP reuse that is forbidden by the standard. However, the OTP reuse detection for the last login unfortunately does not provide any protection against brute force attacks on new logins, so this defense is basically irrelevant here.

Prior Art & References

The brute force attack vector against OTP standards has been publicly known for as long as the standards existed. Unsurprisingly, a lot of public references and writeups for it exist.

Here are some that are worth taking a look at:

Additionally, the issue #19799 in GitLab from 2016-07-06 came very close to publicly expressing this exact issue in devise-two-factor. GitLab is a Ruby application which uses devise-to-factor, and proper defenses in the library would have helped to prevent this. As far as we’re aware, this issue wasn’t raised upstream, and the GitLab TOTP handling was fixed with custom logic.

Security Implications

This vulnerability allows an attacker who knows the correct primary login credentials of a victim user to repeatedly guess the second factor OTP verification code until they randomly succeed, without getting locked out or delayed.

Without additional protections such as rate limiting, an attacker testing possible OTP codes at the maximum rate that the server can process them is able to break the 2FA protection of an account within a few days or even hours, under default conditions.

Since the attack is probabilistic, the attacker may get lucky and succeed on the first try, or try the whole number range and still not succeed yet. In this regard, the issue behaves differently from normal password brute force since the changing OTP code is a moving target. To account for with this, it helps focusing on the “average” length of attack required to have a 50% chance of getting in.

Additional Attack Considerations

There are some configuration options which significantly affect the complexity of practical attacks:

  1. TOTP codes are typically 6-digit, but can be longer. An 8-digit code is 100x more time intensive to break than a 6-digit code, on average. Using the a-z alphabet or alpha-numerical codes would be dramatically more complex, but is not common.
  2. TOTP requires correct time synchronization between client and server so that the legitimately generated codes match up correctly on both sides. Since drifting clocks are an issue, some servers configure the verification logic with grace periods that account for time drift ahead/behind the actual time. If in use, this can multiply the attacker’s chances of guessing one of the correct OTPs and shorten the attack by making multiple OTP codes valid at once.
  3. Accidental Denial-of-Service/Resource Exhaustion via login endpoint. On some web applications, each OTP code verification triggers login checks that cause a significant amount of system load (for example due to repeating expensive computations for password hashing). By issuing an unusually high number of repeated logins as fast as the server can process them, the attacker’s attempts may be limited by the available free CPU resources of the application server and cause noticeable CPU load that affects the rest of the application as well.
  4. Depending on the application configuration, each failed OTP attempt will create a log entry, which makes continued attacks very visible to administrators (but likely not to the targeted user).

Guessing the OTP code right once doesn’t help the attacker for future attempts against the same account, but this may not be necessary. Depending on the application, the attacker may be able to use the granted authentication session to make the 2FA protections ineffective by disabling 2FA on the account or adding new attacker-controlled 2FA tokens.

Example Attack Time Calculation

Let’s take the following example:

  • TOTP with 6-digit code
  • The server allows a time drift of one extra code ahead and behind (= 3 codes valid at any given time)
  • The attacker is able to test 4 codes per second

Borrowing the python-based calculation method from Michael Fincham’s article:

from scipy.stats import binom
1 - binom.pmf(k=0, n=4 * 3600 * 24, p=3 / 1000000)
0.6454130028943306

After a day of OTP testing, chances for success are already 64.54% in this scenario. This is bad!

Mitigations

Since the vendor decided not to include any targeted defenses against TOTP brute force attacks, this responsibility falls on the projects using the library.

Wherever possible, we recommend defense strategies that count failed attempts primarily by targeted account, and after the attacker has passed some initial form of authentication barrier. This design ensures the inherent new attack vector towards locking out genuine users stays as minimal as possible. Additionally, this pattern cannot be bypassed by distributing an attack against the same user onto many source addresses (IPv4 / IPv6).

Rack::Attack is a common Ruby library to block and throttle requests, which may be of help if user session data is available during a multi-stage login flow. Alternatively, it can be used to implement some additional secondary defense which limits login requests per IP address or IP subnet (with obvious limitations).

Devise has the Devise::Models::Lockable mechanism to block user accounts after some number of incorrect login attempts. We see this as an inferior solution that creates new issues. Attackers who know neither the correct TOTP code nor the correct account password can still trigger account lockouts, which is undesirable.

High-Level Recommendations

For users:

  • If possible, switch exclusively to more modern 2FA mechanisms such as WebAuthn.
  • Unfortunately, many popular websites do not support WebAuthn yet, or enforce the use of TOTP as a fallback mechanism.
  • If TOTP is your only option for 2FA, using it is still strictly better than not using it.
  • If you’re concerned about your account security, use this opportunity to ensure you’re using unique, complex, impossible-to-guess passwords everywhere and have a decent password manager. If the attackers don’t know your password to a given site, this 2FA attack doesn’t matter.

Coordinated Disclosure

The disclosure process had several busier-than-usual phases, since we had to work on coordinating the issue shortly before and after the Christmas holidays. Additionally, the embargo timeline changed from 60 days -> 90+ days -> 30 days. From the start, we supported a shortened embargo to get this information out sooner, but things still got unexpectedly busy with last-minute writeup and coordination work.

Credits and Sponsoring

I discovered this issue during internal audit work for Radically Open Security (ROS). Radically Open Security supported the disclosure and sponsored the worktime for most steps such as initial analysis, triage, and disclosure to the vendor Synopsys.

Thanks go to Chris MacNaughton (Centauri Solutions) who was heavily involved in the analysis steps, and to the team at Radically Open Security who helped with coordination efforts.

This article was written on my own time.

Confirmed Affected Projects

Project Source Likely Affected Version Fix References
Synopsys devise-two-factor GitHub >1.0.0 Not planned GHSA-chcr-x7hc-8fp8 advisory, CVE-2024-0227
Centauri Solutions EyeDP GitHub <= 1.0.16, < 1.1 1.0.17, 1.1.0-rc4 GHSA-qrqh-v2j6-3g7w advisory

Potentially Affected Projects

To be determined.

Related lookups:

Projects with Mitigations

The following projects use devise-two-factor, but have mitigations that can be effective under at least some conditions:

Project Source Comment
GitLab GitLab Uses dedicated layers of rate-limit / lockout mechanisms.
Mastodon GitHub Uses rate limits on login endpoint, if accessed via normal network paths.

Please note that we have not analyzed the defenses closely and do not vouch for their effectiveness.

Detailed Timeline

Date Information
2023-12-12 Initial discovery of issue in local ROS EyeDP installation
2023-12-12 Triage of vulnerability together with EyeDP developer
2023-12-12 Brief exposure of EyeDP mitigation patch on GitHub
2023-12-12 Rollout of mitigations for local ROS EyeDP installation
2023-12-13 Coordinated disclosure of vulnerability to Synopsys PSIRT
2023-12-20 Status request to Synopsys PSIRT
2023-12-20 Synsopsys PSIRT responds, asks us to re-send the disclosure
2023-12-20 Repeated transmission of vulnerability to Synopsys PSIRT
2023-12-20 Synsopsys PSIRT confirms receipt of disclosure, announces goal of 90d embargo starting 2023-12-20
2023-12-21 Followup with more information to Synsopsys PSIRT
2024-01-08 Status request to Synopsys PSIRT
2024-01-09 Synopsys PSIRT provides some updates, CVE ID, and announces embargo end in less than 7d
2024-01-10 Coordination with Synopsys PSIRT
2024-01-11 Coordination with Synopsys PSIRT
2024-01-11 Synopsys publishes advisory
2024-01-11 Publication of this article

Please note: additional steps after initial article publication are not covered in this timeline.

Bug Bounty

There was no bug bounty involved.