{"schema":"threatline.record/2","record":{"id":"GHSA-7pjw-6v4g-f837","aliases":["GHSA-7pjw-6v4g-f837"],"summary":"2FA-removal endpoint lacks brute-force lockout (residual gap, incomplete fix of CVE-2026-76213)","details":"### Summary\r\n\r\nCVE-2026-76213's fix (commit `59593a013`) added proper brute-force lockout\r\n(`isTwoFactorLockedOut()` / `twoFactorFailure()`) to three login-flow 2FA verification entry\r\npoints:\r\n\r\n- `Controller/Frontend/AuthenticationController.php` (`/check`)\r\n- `Controller/Administration/AuthenticationController.php`\r\n- `Controller/Frontend/Api/WebAuthnController.php`\r\n\r\nA fourth endpoint that also calls `TwoFactor::validateToken()` —\r\n`Controller/Frontend/Api/UserController.php::removeTwofactorConfig()`\r\n(`POST /api/user/remove-twofactor`) — was not included in that hardening. It has no lockout at\r\nall: an already-authenticated session can submit an unlimited number of guesses for the 2FA\r\ncode and permanently disable the account's 2FA once the correct one is found.\r\n\r\nThis endpoint's `validateToken()` call was itself only added later (commit `9d31dd73`, fixing\r\nGHSA-h96g-59xp-7r5m's \"no code verification at all\"), which is why the July lockout hardening\r\nnever touched it — the code check this report is about didn't exist yet when that hardening\r\nlanded.\r\n\r\n### Affected / Environment\r\n\r\n- **Component:** `Controller/Frontend/Api/UserController.php::removeTwofactorConfig()`,\r\n  `User/TwoFactor.php::validateToken()`.\r\n- **Affected versions:** verified on `main`, HEAD `e623c505` (2026-09-20), i.e. post-dating\r\n  both CVE-2026-76213's fix and the most recent hardening commit (`03d3372540`, 2026-09-13,\r\n  which added replay-slice tracking to `validateToken()` but did not add rate limiting to\r\n  this call site). The `4.1.x` maintenance branch shares the same controller/service\r\n  structure and is plausibly affected too, but was not independently re-verified — flagging\r\n  this so triage isn't slowed down by an unconfirmed claim.\r\n- Reproduced against a fresh install built from the project's own `docker-compose.yml`\r\n  (`mariadb` + `apache` profiles), installed through the real HTTP setup wizard\r\n  (`POST /setup/index.php/install`).\r\n\r\n### Root cause\r\n\r\n`removeTwofactorConfig()`:\r\n\r\n```php\r\nif ((int) $this->currentUser->getUserData('twofactor_enabled') === 1) {\r\n    $code = trim(Filter::filterVar($data->code ?? null, FILTER_SANITIZE_SPECIAL_CHARS, ''));\r\n    if (!$twoFactor->validateToken($code, $this->currentUser->getUserId())) {\r\n        return $this->json(['error' => ...], Response::HTTP_FORBIDDEN);\r\n    }\r\n    // ... disables 2FA\r\n}\r\n```\r\n\r\n`validateToken()` (post the Sept-13 hardening) only rejects a code already accepted in the\r\nsame 30-second time slice (replay prevention) — it has no attempt counter and no lockout:\r\n\r\n```php\r\nif (!$this->twoFactorAuth->verifyCode($secret, $token, self::VERIFY_DISCREPANCY)) {\r\n    return false;\r\n}\r\n$slice = intdiv(time(), self::PERIOD);\r\nif ($slice <= $this->lastAcceptedSlice($userId)) {\r\n    return false;\r\n}\r\nreturn $this->rememberAcceptedSlice($userId, $slice);\r\n```\r\n\r\nNeither this method nor its caller ever calls `isTwoFactorLockedOut()` / `twoFactorFailure()`,\r\nthe mechanism the three sibling endpoints use.\r\n\r\nWe also checked whether a generic layer covers this gap: `EventListener/ApiRateLimiterListener.php`\r\nexplicitly exempts any route whose name starts with `api.private.`, and\r\n`removeTwofactorConfig()`'s route is named `api.private.user.remove-twofactor` — so it is\r\nstructurally excluded from the one rate limiter this codebase has.\r\n\r\n### Reproduction (fresh install, verified 2026-09-21)\r\n\r\n1. Installed phpMyFAQ from a clean clone via the real setup wizard\r\n   (`POST /setup/index.php/install`, `permLevel=medium`), MariaDB backend.\r\n2. Enabled real TOTP 2FA on the admin account (RFC 6238 secret), logged in with the correct\r\n   password + correct TOTP code once to confirm the baseline flow works end-to-end.\r\n3. **Control group** — reset the failure counter, then sent 7 consecutive wrong-code\r\n   `POST /check` requests (the login-flow 2FA step CVE-2026-76213 hardened). The `Location`\r\n   header on each response was tracked directly:\r\n\r\n   ```\r\n   attempt 1-6: Location: ./token?user-id=1   (login_attempts incrementing 1→6 in the DB)\r\n   attempt 7:   Location: ./login             (lockout fired — MAX_FAILED_LOGIN_ATTEMPTS=5 exceeded)\r\n   ```\r\n\r\n4. **Target** — logged back in successfully (correct password + correct TOTP), fetched the\r\n   real `pmf-csrf-token-remove-twofactor` value from the authenticated UCP page, then sent 20\r\n   consecutive wrong-code `POST /api/user/remove-twofactor` requests:\r\n\r\n   ```\r\n   $ for i in 1..20: POST /api/user/remove-twofactor {\"csrfToken\":\"<real>\",\"code\":\"<random 6 digits>\"}\r\n   → every single one: HTTP 403 {\"error\":\"You entered a wrong code!\"}\r\n   ```\r\n\r\n   No lockout, no throttling, no degradation across all 20 attempts — direct contrast with\r\n   the control group's 6-attempt lockout above.\r\n\r\n5. **Rate-limiter liveness check** — hammered a public API route\r\n   (`POST /api/voting`, default rate-limit config `100 req / 3600s`) from the same client:\r\n   got a real `HTTP 429` at exactly request **101**, confirming the limiter is live (not dead\r\n   code) and is what specifically exempts `api.private.*` routes like our target.\r\n\r\n6. **Endgame** — computed the real TOTP code for the current 30-second slice and submitted\r\n   it to the target endpoint:\r\n\r\n   ```\r\n   $ POST /api/user/remove-twofactor {\"csrfToken\":\"<real>\",\"code\":\"<real TOTP>\"}\r\n   → HTTP 200 {\"success\":\"The deletion of the current 2-factor configuration was successful.\"}\r\n   ```\r\n\r\n   Confirmed via direct DB query that `faquserdata.twofactor_enabled` flipped `1 → 0`. A\r\n   subsequent password-only login (`POST /authenticate`) went straight to `Location: ./`\r\n   with no 2FA prompt at all — full, persistent 2FA downgrade, reproduced end-to-end on a\r\n   brand-new install, not inferred from code reading.\r\n\r\n### Impact\r\n\r\n**Threat model, stated explicitly:** this is not an unauthenticated bypass. It requires\r\n`isLoggedIn()` — an already-authenticated victim session — plus the `remove-twofactor` CSRF\r\ntoken (obtainable by an attacker who already has that session, e.g. via XSS or a stolen\r\ncookie). Given that precondition, it is a **persistence / downgrade** attack: an attacker with\r\na temporary foothold can permanently strip 2FA from the account with no rate limit on the\r\nguess, converting a transient session compromise into a durable one that survives password\r\nresets and outlasts the original session. The vendor's own commit history shows this class of\r\nattack (brute-forcing a 2FA code) is already treated as worth guarding against — it was simply\r\nmissed on this one call site. This is also not an opt-in/insecure-configuration issue: 2FA is\r\na security feature the account owner turns *on*, and the gap reproduces on a fresh install\r\nwith default settings (rate limiter included).\r\n\r\n### Suggested patch\r\n\r\nReuse the existing `isTwoFactorLockedOut()` / `twoFactorFailure()` mechanism (already present\r\nin `AuthenticationController.php` and `WebAuthnController.php`) in\r\n`removeTwofactorConfig()`, keyed the same way (per-user or per-session, matching whatever the\r\nsibling endpoints use) before calling `validateToken()`:\r\n\r\n```diff\r\n if ((int) $this->currentUser->getUserData('twofactor_enabled') === 1) {\r\n+    if ($this->currentUser->isTwoFactorLockedOut()) {\r\n+        return $this->json(['error' => Translation::get(key: 'msgTwofactorLockedOut')],\r\n+            Response::HTTP_TOO_MANY_REQUESTS);\r\n+    }\r\n     $code = trim(Filter::filterVar($data->code ?? null, FILTER_SANITIZE_SPECIAL_CHARS, ''));\r\n     if (!$twoFactor->validateToken($code, $this->currentUser->getUserId())) {\r\n+        $this->currentUser->twoFactorFailure();\r\n         return $this->json(['error' => ...], Response::HTTP_FORBIDDEN);\r\n     }\r\n```\r\n\r\n### Related history\r\n\r\n```\r\nGHSA-h96g-59xp-7r5m   removeTwofactorConfig() required no code verification at all\r\n     ↓ fixed by (9d31dd73, Aug 15)\r\n                      required a validateToken() call before disabling 2FA\r\nCVE-2026-76213        login-flow 2FA verification had a session-scoped, easily-reset\r\n                      brute-force counter\r\n     ↓ fixed by (59593a013, Jul 31)\r\n                      proper isTwoFactorLockedOut()/twoFactorFailure() lockout added to\r\n                      3 login-flow entry points — predates 9d31dd73, so this 4th call\r\n                      site didn't exist yet when the lockout logic was written\r\n     ↓ this report\r\n                      removeTwofactorConfig() still has zero lockout on validateToken()\r\n```\r\n\r\n### Disclosure\r\n\r\nHappy to follow your coordinated-disclosure process (90-day default per your policy).\r\n\r\nCredit: please use \"baeseungwon1010\" (github.com/baeseungwon1010).\r\n\r\n### AI Use Disclosure\r\n\r\nAI (Claude) was used as a tool under my direction for code review, code summarization, and\r\nscript development. I reviewed and verified the AI-generated results before submission.","categories":["Unclassified"],"product":"phpmyfaq/phpmyfaq","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-7pjw-6v4g-f837","source_modified_at":"2026-10-03T11:28:32Z","fetched_at":"2026-10-23T21:10:45.942435Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"range":">= 4.1.0, < 4.2.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-7pjw-6v4g-f837","source_modified_at":"2026-10-03T11:28:32Z","fetched_at":"2026-10-23T21:10:45.942435Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"version":"4.1.9"}],"cvss":6.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-03T11:28:32Z","modified_at":"2026-10-03T11:28:32Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-7pjw-6v4g-f837","fetched_at":"2026-10-23T21:10:45.942435Z","source_modified_at":"2026-10-03T11:28:32Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-03T11:28:32Z","content_hash":"327028c2c4e38434c4c9e8b3059d24f05f956244a7e17a8b81723c0f237e37f4"}}
