phpmyfaq/phpmyfaq: 2FA-removal endpoint lacks brute-force lockout (residual gap, incomplete fix of CVE-2026-76213)
What fixes it
- 4.1.9phpmyfaq/phpmyfaq, composerGitHub
composer require "phpmyfaq/phpmyfaq:^4.1.9"
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
CVE-2026-76213's fix (commit 59593a013) added proper brute-force lockout (isTwoFactorLockedOut() / twoFactorFailure()) to three login-flow 2FA verification entry points:
Controller/Frontend/AuthenticationController.php(/check)Controller/Administration/AuthenticationController.phpController/Frontend/Api/WebAuthnController.php
A fourth endpoint that also calls TwoFactor::validateToken() — Controller/Frontend/Api/UserController.php::removeTwofactorConfig() (POST /api/user/remove-twofactor) — was not included in that hardening. It has no lockout at all: an already-authenticated session can submit an unlimited number of guesses for the 2FA code and permanently disable the account's 2FA once the correct one is found.
This endpoint's validateToken() call was itself only added later (commit 9d31dd73, fixing GHSA-h96g-59xp-7r5m's "no code verification at all"), which is why the July lockout hardening never touched it — the code check this report is about didn't exist yet when that hardening landed.
Affected / Environment
- Component:
Controller/Frontend/Api/UserController.php::removeTwofactorConfig(),
User/TwoFactor.php::validateToken().
- Affected versions: verified on
main, HEADe623c505(2026-09-20), i.e. post-dating
both CVE-2026-76213's fix and the most recent hardening commit (03d3372540, 2026-09-13, which added replay-slice tracking to validateToken() but did not add rate limiting to this call site). The 4.1.x maintenance branch shares the same controller/service structure and is plausibly affected too, but was not independently re-verified — flagging this so triage isn't slowed down by an unconfirmed claim.
- Reproduced against a fresh install built from the project's own
docker-compose.yml
(mariadb + apache profiles), installed through the real HTTP setup wizard (POST /setup/index.php/install).
Root cause
removeTwofactorConfig():
if ((int) $this->currentUser->getUserData('twofactor_enabled') === 1) {
$code = trim(Filter::filterVar($data->code ?? null, FILTER_SANITIZE_SPECIAL_CHARS, ''));
if (!$twoFactor->validateToken($code, $this->currentUser->getUserId())) {
return $this->json(['error' => ...], Response::HTTP_FORBIDDEN);
}
// ... disables 2FA
}validateToken() (post the Sept-13 hardening) only rejects a code already accepted in the same 30-second time slice (replay prevention) — it has no attempt counter and no lockout:
if (!$this->twoFactorAuth->verifyCode($secret, $token, self::VERIFY_DISCREPANCY)) {
return false;
}
$slice = intdiv(time(), self::PERIOD);
if ($slice <= $this->lastAcceptedSlice($userId)) {
return false;
}
return $this->rememberAcceptedSlice($userId, $slice);Neither this method nor its caller ever calls isTwoFactorLockedOut() / twoFactorFailure(), the mechanism the three sibling endpoints use.
We also checked whether a generic layer covers this gap: EventListener/ApiRateLimiterListener.php explicitly exempts any route whose name starts with api.private., and removeTwofactorConfig()'s route is named api.private.user.remove-twofactor — so it is structurally excluded from the one rate limiter this codebase has.
Reproduction (fresh install, verified 2026-09-21)
- Installed phpMyFAQ from a clean clone via the real setup wizard
(POST /setup/index.php/install, permLevel=medium), MariaDB backend.
- Enabled real TOTP 2FA on the admin account (RFC 6238 secret), logged in with the correct
password + correct TOTP code once to confirm the baseline flow works end-to-end.
- Control group — reset the failure counter, then sent 7 consecutive wrong-code
POST /check requests (the login-flow 2FA step CVE-2026-76213 hardened). The Location header on each response was tracked directly:
attempt 1-6: Location: ./token?user-id=1 (login_attempts incrementing 1→6 in the DB) attempt 7: Location: ./login (lockout fired — MAX_FAILED_LOGIN_ATTEMPTS=5 exceeded)
- Target — logged back in successfully (correct password + correct TOTP), fetched the
real pmf-csrf-token-remove-twofactor value from the authenticated UCP page, then sent 20 consecutive wrong-code POST /api/user/remove-twofactor requests:
$ for i in 1..20: POST /api/user/remove-twofactor {"csrfToken":"<real>","code":"<random 6 digits>"}
→ every single one: HTTP 403 {"error":"You entered a wrong code!"}No lockout, no throttling, no degradation across all 20 attempts — direct contrast with the control group's 6-attempt lockout above.
- Rate-limiter liveness check — hammered a public API route
(POST /api/voting, default rate-limit config 100 req / 3600s) from the same client: got a real HTTP 429 at exactly request 101, confirming the limiter is live (not dead code) and is what specifically exempts api.private.* routes like our target.
- Endgame — computed the real TOTP code for the current 30-second slice and submitted
it to the target endpoint:
$ POST /api/user/remove-twofactor {"csrfToken":"<real>","code":"<real TOTP>"}
→ HTTP 200 {"success":"The deletion of the current 2-factor configuration was successful."}Confirmed via direct DB query that faquserdata.twofactor_enabled flipped 1 → 0. A subsequent password-only login (POST /authenticate) went straight to Location: ./ with no 2FA prompt at all — full, persistent 2FA downgrade, reproduced end-to-end on a brand-new install, not inferred from code reading.
Impact
Threat model, stated explicitly: this is not an unauthenticated bypass. It requires isLoggedIn() — an already-authenticated victim session — plus the remove-twofactor CSRF token (obtainable by an attacker who already has that session, e.g. via XSS or a stolen cookie). Given that precondition, it is a persistence / downgrade attack: an attacker with a temporary foothold can permanently strip 2FA from the account with no rate limit on the guess, converting a transient session compromise into a durable one that survives password resets and outlasts the original session. The vendor's own commit history shows this class of attack (brute-forcing a 2FA code) is already treated as worth guarding against — it was simply missed on this one call site. This is also not an opt-in/insecure-configuration issue: 2FA is a security feature the account owner turns *on*, and the gap reproduces on a fresh install with default settings (rate limiter included).
Suggested patch
Reuse the existing isTwoFactorLockedOut() / twoFactorFailure() mechanism (already present in AuthenticationController.php and WebAuthnController.php) in removeTwofactorConfig(), keyed the same way (per-user or per-session, matching whatever the sibling endpoints use) before calling validateToken():
if ((int) $this->currentUser->getUserData('twofactor_enabled') === 1) {
+ if ($this->currentUser->isTwoFactorLockedOut()) {
+ return $this->json(['error' => Translation::get(key: 'msgTwofactorLockedOut')],
+ Response::HTTP_TOO_MANY_REQUESTS);
+ }
$code = trim(Filter::filterVar($data->code ?? null, FILTER_SANITIZE_SPECIAL_CHARS, ''));
if (!$twoFactor->validateToken($code, $this->currentUser->getUserId())) {
+ $this->currentUser->twoFactorFailure();
return $this->json(['error' => ...], Response::HTTP_FORBIDDEN);
}Related history
GHSA-h96g-59xp-7r5m removeTwofactorConfig() required no code verification at all
↓ fixed by (9d31dd73, Aug 15)
required a validateToken() call before disabling 2FA
CVE-2026-76213 login-flow 2FA verification had a session-scoped, easily-reset
brute-force counter
↓ fixed by (59593a013, Jul 31)
proper isTwoFactorLockedOut()/twoFactorFailure() lockout added to
3 login-flow entry points — predates 9d31dd73, so this 4th call
site didn't exist yet when the lockout logic was written
↓ this report
removeTwofactorConfig() still has zero lockout on validateToken()Disclosure
Happy to follow your coordinated-disclosure process (90-day default per your policy).
Credit: please use "baeseungwon1010" (github.com/baeseungwon1010).
AI Use Disclosure
AI (Claude) was used as a tool under my direction for code review, code summarization, and script development. I reviewed and verified the AI-generated results before submission.
Text from GitHub Advisory Database
Affected versions 1
- phpmyfaq/phpmyfaq, composer>= 4.1.0, < 4.2.0GitHub