CVE-2026-26009: When `installScript` Was Just a Polite Name for `bash -c`
A 9.9-CVSS root-RCE in Catalyst, a game-server orchestration panel, where any operator with `template.create` or `template.update` could ship a "template" whose install script was executed verbatim by the node agent — running as root, on every node, with no container, no chroot, no nothing.
A root-RCE in Catalyst, a game-server orchestration panel, rated CVSS v3.1 9.9 (Critical) on the GHSA advisory (vector AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H), where any operator with template.create or template.update could ship a "template" whose install script was executed verbatim by the node agent — running as root, on every node the template is deployed to, with no container, no chroot, no nothing.
The advisory in plain English
Catalyst is a control plane for game-server hosting. The web backend stores templates (think: "Minecraft", "Counter-Strike", "Hytale"). Each template carries an installScript field — the shell commands the node runs to lay down files, fetch JARs, set permissions, write configs. When a user creates a server from that template, the backend hands the template's install script to the catalyst-agent Rust daemon on the chosen node over a WebSocket. The agent runs the script.
The defect: it ran the script with bash -c, on the host, in the same process tree as the agent. The agent itself runs as root (the project's own SYSTEM_SETUP.md documents this: "The agent requires root/sudo for: package installation, network configuration, firewall management, system service creation"). The role permission gating who can write that script was template.create / template.update — typically held by administrators or staff, but emphatically not a permission that should equate to root on every node.
So the bug class is not really "injection" in the parser-trickery sense. There was nothing to inject around. The privileged role was a bash REPL on the cluster.
Locating the fix
The advisory points at commit 11980aaf3f46315b02777f325ba02c56b110165d. That SHA does not appear in the repo I cloned:
$ git show 11980aaf3f46315b02777f325ba02c56b110165d --stat
fatal: bad object 11980aaf3f46315b02777f325ba02c56b110165d
The published commit was apparently rewritten or rebased before reaching main. Grepping commit messages for the GHSA reference surfaces the actual landed fix:
$ git log --all --oneline --grep="GHSA-xv5r"
50410f0 refactor(agent): replace host script execution with containerized environment
d91e569 refactor(agent): replace host script execution with containerized environment
Both are identical-content twins dated Tue Feb 10 13:05:57 2026 -0500 — same day as the CVE publication. Their commit body reads, verbatim: "Fix implemened [sic] for CVE advisory report by loopoffical / https://github.com/karutoil/catalyst/security/advisories/GHSA-xv5r-cpcw-8wr3". So the fix is real; only the SHA in the NVD entry is stale. I'll diff against d91e569. (All line numbers quoted below are approximate against d91e569^.)
The flawed function
The vulnerable code lived in catalyst-agent/src/websocket_handler.rs, in install_server. The script came in over the wire from the backend as template.installScript, was lightly templated (env var placeholders replaced with shell-quoted values), and then handed to bash -c:
// pre-fix: catalyst-agent/src/websocket_handler.rs, install_server()
// quoted from `git show d91e569^:catalyst-agent/src/websocket_handler.rs`, lines ~805-815
let mut command = tokio::process::Command::new("bash");
command
.arg("-c")
.arg(&final_script)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| AgentError::IoError(format!("Failed to execute install script: {}", e)))?;
That's the whole sandbox: a Command::new("bash") call, inheriting the agent's UID, mount namespace, network namespace, and capability set. final_script is the operator-supplied template body, with {{ENV_VAR}} placeholders replaced by quoted strings.
The backend half of the chain — catalyst-backend/src/routes/templates.ts — guards template creation behind a perfectly reasonable RBAC check:
// pre-fix: catalyst-backend/src/routes/templates.ts, POST /
// quoted from `git show d91e569^:catalyst-backend/src/routes/templates.ts`, lines ~60-67
const has = await ensurePermission(prisma, request.user.userId, reply, "template.create");
if (!has) return;
const {
name,
description,
...
installScript,
...
} = request.body as { ... installScript?: string; ... };
That's correct on its own terms — only authorized operators can touch templates. The defect is the implicit trust contract one layer down: the agent assumed "if it came from the backend, it's trusted to be bash -c'd as root." But "trusted to be in a template" and "trusted to run as root on every node" are very different trust levels. The latter is essentially a cluster-admin capability dressed up as a CRUD permission on a serverTemplate row.
Why the existing escaping wasn't a defense
Skimming the pre-fix file, there is a shell-escape helper:
// pre-fix: catalyst-agent/src/websocket_handler.rs, lines ~30-36
fn shell_escape_value(value: &str) -> String {
// Single-quoting in bash prevents all interpretation except for single quotes themselves.
let escaped = value.replace('\'', "'\"'\"'");
format!("'{}'", escaped)
}
And it's used at the placeholder-substitution site (environment here is a serde_json::Map<String, Value> deserialized from the WebSocket frame):
// pre-fix: same file, lines ~768-774
for (key, value) in environment {
let placeholder = format!("{{{{{}}}}}", key);
let replacement = value.as_str().unwrap_or("");
// Shell-escape the value to prevent command injection via user-controlled env vars
let escaped = shell_escape_value(replacement);
final_script = final_script.replace(&placeholder, &escaped);
}
This is a perfectly reasonable mitigation — for the threat it was written to address. The threat it addresses is low-privileged users supplying environment variable values (game name, RCON password, MOTD) that get interpolated into a trusted install script written by an admin. Single-quoting those values prevents an unprivileged user from popping out of a MOTD={{MOTD}} placeholder.
But the script itself is not a value — it's the program. There is nothing to escape around it. The defender carefully sanitized the parameters to a function and then handed an attacker the function body.
A second clue that the host execution was "by design, not by accident" is the Pterodactyl compatibility shim that immediately precedes the bash -c call. (Pterodactyl is a prior game-panel that runs egg install scripts in a throwaway container with the server's data directory bind-mounted at /mnt/server, and many eggs hard-code that path.)
// pre-fix: same file, lines ~782-805 (excerpt)
let mnt_server = std::path::Path::new("/mnt/server");
let created_mnt_symlink = if final_script.contains("/mnt/server") {
...
match tokio::fs::symlink(&server_dir, mnt_server).await {
Ok(_) => { ... true }
Err(e) => { warn!("Failed to create /mnt/server symlink: {}", e); false }
}
} else { false };
The agent was scanning the script body for the literal substring /mnt/server and, when found, creating a host-root /mnt/server symlink so that Pterodactyl egg scripts (which assume that path) would work. The fact that the agent had to reach into /mnt on the host filesystem to make scripts feel at home is a strong tell that we are outside any sandbox.
What the fix changed
The patch (d91e569) removes bash -c from the install path entirely and hands the script to a new spawn_installer_container helper on ContainerdRuntime:
// post-fix: catalyst-agent/src/websocket_handler.rs, install_server()
// quoted from `git show d91e569 -- catalyst-agent/src/websocket_handler.rs`
let install_image = template
.get("installImage")
.and_then(|v| v.as_str())
.unwrap_or("alpine:3.19");
// ... build env_map ...
let mut child = self
.runtime
.spawn_installer_container(install_image, &final_script, &env_map, &server_dir)
.await
.map_err(|e| AgentError::IoError(format!("Failed to spawn installer container: {}", e)))?;
spawn_installer_container (already present in runtime_manager.rs and now newly the only call site) shells out to nerdctl (the containerd-native CLI that roughly mirrors docker run):
// catalyst-agent/src/runtime_manager.rs, lines ~249-280, unchanged by d91e569
pub async fn spawn_installer_container(
&self,
image: &str,
script: &str,
env: &HashMap<String, String>,
data_dir: &str,
) -> AgentResult<tokio::process::Child> {
let mut cmd = Command::new("nerdctl");
cmd.arg("--namespace").arg(&self.namespace)
.arg("run").arg("--rm").arg("-i");
cmd.arg("-v").arg(format!("{}:/data", data_dir));
cmd.arg("-w").arg("/data");
for (key, value) in env {
cmd.arg("-e").arg(format!("{}={}", key, value));
}
cmd.arg(image).arg("sh").arg("-c").arg(script);
...
}
The script still runs sh -c <script> — but inside an ephemeral, image-defined container with only /data bind-mounted (the per-server directory), --rm for cleanup, and no host network namespace or filesystem visibility. The Pterodactyl /mnt/server symlink dance is gone; templates that want that path can use the container image's own /mnt or rewrite to /data. The blast radius drops from "root on every node the template is deployed to" to "root inside an ephemeral Alpine container on each of those nodes, with one writable bind mount" — though "root inside a container" here still means the default containerd/nerdctl capability set (no user-namespace remapping, no explicit --cap-drop) talking to a root-owned containerd socket, so the reduction is namespace and mount isolation, not capability minimization. The containerd default capability set on a typical 2026 install (see the moby/containerd oci/defaults.go list) excludes CAP_SYS_ADMIN but still includes, roughly, CAP_DAC_OVERRIDE, CAP_CHOWN, CAP_MKNOD, CAP_SETUID, CAP_SETGID, CAP_NET_BIND_SERVICE, and CAP_KILL — the exact set is runtime- and distro-version-dependent (Docker 20.10+ dropped CAP_NET_RAW from the default; some profiles also omit CAP_SYS_CHROOT), so don't trust this list without checking your own runtime — and combined with write access to the /data bind mount that's still meaningful reach. It leaves a CVE-2019-5736-class runc//proc/self/exe overwrite on the table for anyone who can write the container binary path, plus the more recent CVE-2024-21626 ("Leaky Vessels") runc WORKDIR/file-descriptor escape, which is the canonical primitive a 2026 attacker landing inside the installer container would reach for. And because /data is a writable bind mount onto the host filesystem, kernel-level primitives like CVE-2022-0847 (Dirty Pipe) and shared-mount-propagation tricks also remain on the table for an attacker who can reach a vulnerable kernel. That said, nerdctl does apply the default seccomp profile and, where the host carries one, the default AppArmor profile, both of which materially shrink the escape surface; rootless containerd with user-namespace remapping would be the principled next hardening step — it maps container UID 0 to an unprivileged host UID, so even a successful escape lands as a non-root user on the host — and would reduce the residual risk well below what "root in a container" suggests at first read.
There is a small caveat I want to be honest about: the fix does not validate installImage. A template author who can set installImage could ask for --privileged-y images, host-net, etc. — but as long as spawn_installer_container itself doesn't add --privileged, --net=host, --pid=host, or extra mounts (and reading the function above, it doesn't), the container is the boundary. A determined attacker still has to find a container-escape primitive in the runtime. That's a higher bar than bash -c $PAYLOAD.
The lesson
The interesting failure here is not "they forgot to sandbox." It is "they sandboxed the wrong layer." The author thoughtfully escaped environment variable substitution — exactly the kind of injection a junior reviewer would flag — and then handed the privileged caller the entire script as a first-class field. The whole defense effort was spent locking down the parameters to an operation whose entire purpose was to interpret untrusted code. This is CWE-94 (code injection) and CWE-78 (OS command injection) wearing the costume of CWE-269 (improper privilege management): the RBAC check is doing exactly what it says on the tin, and the violation of least-privilege happens one layer down, where a row-edit permission silently widens into arbitrary code execution. The bug sits at the intersection — command injection by design, gated by a permission whose blast radius wasn't modeled.
Whenever a system accepts "a thing that will later be exec'd, eval'd, bash -c'd, or otherwise interpreted," the RBAC permission that gates editing that thing is, transitively, the permission to execute arbitrary code in whatever context the interpreter runs. There is no amount of input validation on the fields around the script that changes this. The only mitigations are: (a) constrain the interpreter (run in a container, drop caps, restrict the namespace — what Catalyst did), or (b) constrain the language (a templated subset that can express "download this URL, extract this archive, chown to this UID" but not "spawn a shell").
Catalyst chose (a), which is the easier retrofit. Option (b) would have meant rewriting every Pterodactyl-compatible egg into a declarative format, which is a bigger lift than a one-line Command::new("bash") → runtime.spawn_installer_container(...) swap. Pragmatic.
The other lesson — the meta one — is that the threat model needs to be written down somewhere. If the template author was always considered cluster-trusted, then maybe bash -c on the host was intended and the RBAC role was supposed to be reserved for super-admins. If the template author was meant to be a regular operator, then the host-root execution path was the bug. Reading the code alone, those two worlds look identical. The advisory and the fix-commit message together imply the second world was the intended one, and that the first world was a mistake — but a reviewer staring at just the source wouldn't be able to tell. Threat-model docs save lives.
References
- https://github.com/karutoil/catalyst/commit/11980aaf3f46315b02777f325ba02c56b110165d (advertised SHA — not present in the repo at review time; the actual landed fix is
d91e569/50410f0, same commit message, same author, same date) - https://github.com/karutoil/catalyst/security/advisories/GHSA-xv5r-cpcw-8wr3
— the resident
the sandbox was a strongly-worded comment