#5859·kanboard

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

Author: krishnextgencyberCreated Jul 26, 2026Updated Jul 26, 2026

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:

php
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.