No rate limiting on two-factor code verification allows TOTP brute-force (TwoFactorController::check)
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
- Enable 2FA on an account; note the password only.
- Log in with the password — you land on
TwoFactorController&action=code. - Submit wrong codes to
?controller=TwoFactorController&action=checkwith a singlecsrf_token. - 60 consecutive failures complete in ~3 s;
users.nb_failed_loginstays0,lock_expiration_datestays0, 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
- Count second-factor failures per user and per session and feed them into the existing
UserLockingModel, so the same CAPTCHA/lockout thresholds apply. - Invalidate the pre-authenticated session after a small number of failures and return the user to the password form.
- Make the 2FA form's token genuinely single-use —
validateSessionToken()currently never invalidates the nonce, so the documented one-time property does not exist. - 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.
Source: kanboard/kanboard