No rate limiting on two-factor code verification allows TOTP brute-force (TwoFactorController::check) · Issue #5859 · kanboard/kanboard · GitHub
Skip to content

No rate limiting on two-factor code verification allows TOTP brute-force (TwoFactorController::check) #5859

Open

Description

@krishnextgencyber

Summary

The password form is protected by UserLockingModel (CAPTCHA after BRUTEFORCE_CAPTCHA=3, lockout after
BRUTEFORCE_LOCKDOWN=6). TwoFactorController::check() has no equivalent: no attempt counter, no CAPTCHA, no
lockout, no delay. An attacker holding a valid password can brute-force the 6-digit TOTP.

Affected code

app/Controller/TwoFactorController.php:144 — the failure branch records nothing:

public function check()
{
    $user = $this->getUser();
    $this->checkCurrentUser($user);
    $values = $this->request->getValues();

    $provider = $this->authenticationManager->getPostAuthenticationProvider();
    $provider->setCode(empty($values['code']) ? '' : $values['code']);
    $provider->setSecret($user['twofactor_secret']);

    if ($provider->authenticate()) {
        $this->userSession->setPostAuthenticationAsValidated();
        ...
    } else {
        $this->flash->failure(t('The two factor authentication code is not valid.'));
        $this->response->redirect($this->helper->url->to('TwoFactorController', 'code'));
    }
}

AuthFailureEvent is dispatched from AuthenticationManager::passwordAuthentication(), which the second factor never
calls, so the locking subsystem is simply not wired to this path.

The CSRF token is no obstacle either: despite the docblock on Token::validateCSRFToken() stating "a token can be used
only one time"
, Token::validateSessionToken() only recomputes an HMAC and never consumes the nonce, so one token
harvested from the 2FA page is replayable for the whole run.

Steps to reproduce

  1. Enable 2FA on an account; note the password only.
  2. Log in with the password — you land on TwoFactorController&action=code.
  3. Submit wrong codes to ?controller=TwoFactorController&action=check with a single csrf_token.
  4. 60 consecutive failures complete in ~3 s; users.nb_failed_login stays 0, lock_expiration_date stays 0, the
    account stays active, and no CAPTCHA appears.

Measured

On a default Docker instance: ~1,760 requests/second, no throttling or errors. Otp::checkTotp() is called with the
library default $timedrift = 1, so three codes are valid at any instant → hit probability 3 × 10⁻⁶, ≈333,000 expected
attempts. Four runs of uniformly random codes: run 1 hit after 846,175 attempts in 480 s, run 3 after 563,604 in
1,155 s (runs 2 and 4 did not hit within their windows). Both hits returned 302 Location: /dashboard, which is emitted
only by the success branch.

Impact

Possession of the password alone is sufficient to enter a 2FA-protected account within minutes, from one host. The
protection 2FA exists to provide — resistance to a compromised password — does not hold. Because nothing is counted,
there is also nothing for an administrator to alert on.
CVSS 3.1: 7.4 (AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N), CWE-307.

Fix

  1. Count second-factor failures per user and per session and feed them into the existing UserLockingModel, so the same
    CAPTCHA/lockout thresholds apply.
  2. Invalidate the pre-authenticated session after a small number of failures and return the user to the password form.
  3. Make the 2FA form's token genuinely single-use — validateSessionToken() currently never invalidates the nonce, so the
    documented one-time property does not exist.
  4. Optionally reject re-use of an already-accepted TOTP code.

Verified against v1.2.52 and the current release v1.2.53 (2026-07-24) — file byte-identical in both. Reported as a regular bug per SECURITY.md.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions