Voke Cyber Security Advisory

CVE-2026-48742: Cross-Team IDOR in Coolify Livewire Service Components — Container RCE via Image Substitution

Louis Sanchez Published June 24, 2026 11 min read
CVECVE-2026-48742
GHSAGHSA-4vv5-7vg4-3v6x
SeverityHigh  CVSS v3.1 base score 8.8
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
WeaknessesCWE-639 — Authorization Bypass Through User-Controlled Key (IDOR) • CWE-862 — Missing Authorization
ProductCoolify (open-source self-hostable PaaS, Laravel 12 / Livewire 3 / PHP 8.4)
AffectedAll v4.x through v4.1.0 (commit 49656aa)
Fixed inv4.1.1 (PR #10358, merged 2026-05-22); fix confirmed in v4.1.2 (released 2026-06-04)
Reported byLouis Sanchez — Voke Cyber
Vendor creditandrasbacsai accepted promptly, fixed within 4 days, requested CVE assignment, credited researcher in advisory
DisclosureCVE-2026-48742 assigned 2026-05-22. Advisory in draft; researcher write-up published 2026-06-24.

Summary

Coolify's Livewire service management component (Project/Service/Index.php) loaded service objects by UUID without applying a team-ownership scope. Coolify's entire Laravel policy layer had been temporarily disabled ("Authorization temporarily disabled" comments throughout app/Policies/), leaving per-query scopes as the sole access control. Any authenticated user on the instance who knew a victim's service UUID could navigate to that service's management page, read its configuration, modify it, delete it, or — via the child Heading component — trigger a pull-and-restart with a swapped container image, causing Coolify to SSH into the victim's server and run arbitrary containers. Fixed in v4.1.1 via PR #10358.

Background: Coolify's authorization architecture

Coolify is a multi-tenant self-hosted PaaS. A single instance hosts multiple teams; each team owns projects, environments, and resources (applications, databases, services). Resources run on team-owned servers via SSH. Teams are supposed to be isolated — Team A cannot read or act on Team B's resources.

Authorization is designed with two layers:

  1. Laravel policy classesapp/Policies/*.php (e.g., ServicePolicy::view(), ServiceApplicationPolicy::update()). These define who is allowed to perform which actions on which resource types.
  2. Per-query ownership scopes — model methods like ownedByCurrentTeam() that filter database queries to the current team's records.

At the time of discovery, the policy layer was entirely disabled. Every policy in app/Policies/ contained commented-out logic with the annotation "Authorization temporarily disabled." The CanUpdateResource middleware was similarly a pass-through (return $next($request) with the body commented out). Policies returned true unconditionally for every authorization check.

This meant authorization enforcement resided entirely in per-query ownership scopes. Any component that loaded a resource without the team-ownership scope had no effective access control.

Vulnerability 1 — Unscoped service lookup in Project/Service/Index.php

The mount() method of app/Livewire/Project/Service/Index.php (line 111 in the affected version) loaded the service model using a bare UUID lookup:

// VULNERABLE — no team-ownership scope applied
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();

// Policy check that returned true unconditionally:
$this->authorize('view', $this->service); // ServicePolicy::view() = true

The URL for this component includes project_uuid and environment_uuid parameters, but Index.php captured them from the route and never validated them against the loaded service. An attacker could supply their own project_uuid and environment_uuid (or random strings) in the URL — the loaded service was determined solely by the service_uuid parameter, which was not team-scoped.

The correct pattern existed in the sibling component, Configuration.php, which is rendered one level up in the same URL hierarchy:

// SAFE — team-scoped chain; each step anchored to the current team
$project = currentTeam()->projects()->select('id','uuid','team_id')
    ->where('uuid', $parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->select('id','uuid','name','project_id')
    ->where('uuid', $parameters['environment_uuid'])->firstOrFail();
$this->service = $environment->services()
    ->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);

Index.php was written alongside a component that had the correct pattern. The logic was not missing from the codebase — it was missing from this one component.

The same unscoped lookup was present in two additional components:

Vulnerability 2 — No authorization on Heading action methods

app/Livewire/Project/Service/Heading.php is a child component rendered inside Index.php. It receives the service model via Livewire binding (:service="$service" in the blade template). Once the parent loaded a victim's service into the Livewire snapshot, the child component operated on that model for all subsequent actions.

The action methods in Heading.php had no authorize() calls:

// No authorization check on any of these methods:
public function start()          { StartService::run($this->service); }
public function stop()           { StopService::dispatch($this->service); }
public function restart()        { RestartService::dispatch($this->service); }
public function forceDeploy()    { /* triggers deploy pipeline */ }
public function pullAndRestartEvent() { /* triggers SSH + docker compose up */ }

The Livewire snapshot is server-signed, so the attacker does not forge the model — they drive their browser to the URL that loads the victim model, and the signed snapshot contains the unscoped victim service.

Attack scenario

Prerequisite: Any authenticated account on the Coolify instance. Any team, any role — including the lowest-privilege team member. The attacker must know (or have seen) a victim service UUID and stack service UUID. These are 24-character Cuid2 values that appear in browser URLs and are routinely shared in support tickets, screenshots, and collaborative documentation.

Read: service configuration disclosure

Navigate to /project/[own_uuid]/environment/[own_uuid]/service/[victim_service_uuid]/[victim_stack_uuid]. The page loads the victim's service. The attacker can read the FQDN, container image, log-drain settings, public port exposure, and environment variables exposed in Alpine.js component state.

Modify: rewrite docker-compose on victim's server

Call Index::submitApplication() or Index::submitDatabase() to update the victim service's configuration. This writes directly to the victim's server — rewriting the image, FQDN, port exposure, or other service parameters in the docker-compose definition.

Escalate: run arbitrary container on victim's server

  1. Call submitApplication() to replace the container image with an attacker-controlled image (e.g., attacker/evil:latest).
  2. Call Heading::pullAndRestartEvent().
  3. Coolify SSHes into the victim's server and executes docker compose up with the attacker-supplied image.
  4. The attacker's container runs on the victim's server with access to the victim's docker network, shared volumes, and any data mounted to that service stack.

Destroy: delete victim service with attacker's own password

Call Index::deleteApplication($attackerPassword). The delete confirmation step validates the attacker's password — not the victim's — against the attacker's own account. The victim's resource is permanently deleted.

Disrupt: stop or restart victim's production service

Call Heading::stop() to take down the victim's running production service. No confirmation required.

CVSS vector rationale

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

The test gap

Coolify's test suite included dedicated cross-team IDOR coverage at the time of discovery:

None of these covered Project/Service/Index, Project/Service/Heading, or Project/Service/DatabaseBackups. The test infrastructure existed for exactly this bug class; the surface was not in scope. A regression test covering the cross-team service path was added as part of PR #10358.

The fix (PR #10358)

The fix was merged 2026-05-22 and shipped in v4.1.1. It replaces the unscoped lookup in Index.php with the team-scoped chain from Configuration.php:

// AFTER — scoped chain matching Configuration.php pattern
$project = currentTeam()->projects()->select('id','uuid','team_id')
    ->where('uuid', $parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->select('id','uuid','name','project_id')
    ->where('uuid', $parameters['environment_uuid'])->firstOrFail();
$this->service = $environment->services()
    ->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);

Additional changes in the same PR: AuthorizesRequests added to Heading.php action methods; start(), stop(), restart(), forceDeploy(), and pullAndRestartEvent() now include $this->authorize() calls before dispatching. Same fix applied to DatabaseBackups.php and Import.php.

Remediation

  1. Update to v4.1.1 or later immediately. v4.1.2 (2026-06-04) is the current release and contains the fix. All v4.x releases prior to v4.1.1 are vulnerable.
  2. If running a pinned version, verify PR #10358 is present. Check your git log for the merge commit or inspect app/Livewire/Project/Service/Index.php to confirm the team-scoped chain is in the mount() method.
  3. Audit team membership. Any user account on the instance was capable of reaching any service on the instance if they had a UUID. Review who has Coolify accounts and reduce to the minimum necessary.
  4. Review audit logs for unexpected cross-team service access. Look for service configuration changes from users who do not own the affected teams, or unexpected container restarts.

Disclosure timeline

References

Found and reported by Louis Sanchez, Founder & Principal Security Consultant at Voke Cyber (OSCP, OSWA, CISSP, CCSK). Prior advisories: CVE-2026-35198 (HeyForm), CVE-2026-48507 (Snipe-IT), CVE-2026-42318 (GLPI), and GHSA-jxgw-q79v-g84j (Leantime).

We find the bugs tools miss

Cross-tenant authorization flaws in multi-tenant applications do not surface in dependency scans. They surface when someone traces every resource lookup back to its team-ownership anchor. That is how we approach web application and API testing for clients across the Charlotte, NC area and nationwide.

Get a Quote