GHSA-c67f-gmxw-mj93 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 protectionSummary`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 isN ≈ 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 componentFile: `Core/Controller/Login.php`Method: `twoFactorValidationAction()` (lines 317–328 in the repository at commit `7392b489b`, master branch as of 2026-05-13).Related: `Core/Lib/TwoFactorManager.php:30` (`VERIFICATION_WINDOW = 8`).Vulnerable code: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 concept1. Brute force when only the victim's nick is knownThis 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`:Observed result against the same install (victim user has 2FA enabled,
attacker knows only the nick `victim`):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.ImpactFor 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.RemediationFour 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_WINDOW` from 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):`loginAction` then needs the matching nonce write where it currently
sets `$this->two_factor_user`:ReproductionTested on a clean install built from `master` at commit `7392b489b`:
CVSS v4.0 score: 9.3