Portainer GitOps Bypass Enables Host Takeover
Portainer is a web-based platform for deploying and managing containerized applications. A single server can run applications belonging to several users or teams. Administrators can let users manage their own applications while restricting access to the server itself.
Sable found a bug in Portainer CE 2.45.0 that broke that separation. A standard user with permission to manage a stack and modify its backing Git repository could gain effective root control of the underlying Docker server. That level of access could let them read or change host files, steal credentials, tamper with other teams’ applications, or stop workloads across the server. The compromise extended beyond the user’s own application to the shared machine running it.
This worked even when the administrator had explicitly forbidden the container settings that enabled host access. Portainer rejected the dangerous configuration during manual deployment, but its automatic Git updates deployed it anyway. Both webhook-triggered updates and interval polling bypassed the security checks.
The target: Portainer’s GitOps deployment workflow
Portainer provides a management interface for Docker environments. Teams can deploy a stack, an application defined in a Docker Compose file, without being given unrestricted access to the underlying Docker engine. On a shared server, administrators can grant users permission to manage their own applications while blocking container settings that would expose the host or other workloads.
In Sable’s test environment, the distinction was explicit. The managed endpoint permitted regular users to manage stacks, but its security settings included:
The GitOps workflow is what made this interesting. Portainer can take a stack’s Compose definition from a Git repository, check for new commits through a webhook or scheduled polling, and redeploy when that definition changes. This lets teams ship configuration changes without manually redeploying each revision. It also means newly fetched code reaches the Docker engine automatically, so the security restrictions must apply to every revision, not just the first one.
Sable began with the running Portainer instance, confirming version 2.45.0 through /api/status. A crawler returned mostly frontend assets and UI templates, while the expected API documentation endpoints returned 404. The useful map of the application was its JavaScript bundle.
I’ll start by getting a baseline understanding of the target, then run the enumeration tools. First, a quick firewall fingerprint and initial probe.
Sable downloaded main.js to map the frontend's API routes, but its first extraction found only one match.
Only one hit with that strict pattern — the frontend likely builds URLs via template literals. Broadening the extraction.
The broader search recovered 38 API base paths. After removing the leading quotation marks captured by the extraction pattern, two examples were:
These are frontend-extracted route fragments, not necessarily complete endpoint URLs. The actual GitOps webhook trigger used later was POST /api/stacks/webhooks/{uuid}. Together, the routes suggested a useful place to investigate: Portainer could manage a Git-backed stack through its API, then redeploy that stack through a webhook when the repository changed. Sable needed to establish whether the ordinary deployment path enforced the administrator’s restrictions before comparing it with automatic updates.
Sable began by checking the expected behavior: manual deployment of a stack requesting prohibited host privileges failed. The same standard user could still deploy a benign Git-backed stack, as intended. That raised a question about later revisions to the same stack.
Sable inspected autoUpdate handling in the frontend bundle and identified how webhook updates were configured:
The webhook UUID is client-generated and passed in autoUpdate.webhook. Creating a webhook-enabled stack (benign), then flipping the repo to malicious and firing the webhook unauthenticated.
Sable deployed a benign stack, then committed the following Compose payload, reproduced from the interval-validation test in the session:
Benign stack deployed. Now push the malicious compose and fire the unauthenticated webhook.
A GitOps webhook returned HTTP 204. The resulting deployment was not merely accepted by the API. Docker inspection confirmed that it was running with the forbidden settings:
The running container is confirmed privileged with the host root mounted. Now the proof of compromise — user-a execs into their own stack’s container and reads the Docker host filesystem.
Sable independently verified host filesystem access in the webhook test. But the agent suspected the bug extended beyond webhooks, which require a working webhook URL.
I’ll test whether the interval-based GitOps auto-update path skips compose security validation like the webhook path did. First, let me check the state of my existing artifacts (local git server, JWTs) from the previous task.
The first polling test initially showed no change:
First poll shows still-benign state (expected — the push happened ~30s ago). Waiting for the scheduler tick.
Further checks revealed that Portainer had stored the requested interval without activating a scheduled job through the stack-create API. Sable investigated the missing Git fetches, enabled polling on the Git source itself, and repeated the test against a fresh benign stack.
Interval armed, benign container confirmed unprivileged. Now commit the malicious compose and poll for the scheduler tick.
This time, the scheduler replaced a benign container running commit 96f0f6e with a privileged container from commit 37be33c:
Status flipped to 3 (deploying) — the scheduler picked up the malicious commit. Waiting for completion.
Container-root alone would not prove host takeover. Using the standard user’s Portainer credentials, Sable executed this exact command sequence inside the GitOps-deployed container:
The trace recorded host-file contents followed by uid=0 after entering the host's PID 1 namespaces (selected output, with Docker's stream framing omitted):
A standard user, authorized for a single application, now had root over the shared host and every other team running on it.
Host root proven as user-a (including nsenter into host PID 1 namespaces). Now control B: manual git redeploy of the same commit must be rejected.
Sable called PUT /api/stacks/47/git/redeploy?endpointId=1 as the same user, with the repository still pointing to commit 37be33c. This time the deployment failed:
Control B confirmed — the identical commit is rejected by the validated manual path. Let me confirm the scheduler-deployed container survived and capture the poll evidence from the git server log.
The privileged container deployed by the scheduler was still running afterward. The security policy had not changed. Only the deployment path had.
Manual Git redeployment validated the fetched Compose content against the user’s permissions and endpoint restrictions. Source inspection showed a different path for automatic updates: both webhooks and polling reached redeployWhenChangedSecondStage in api/stacks/deployments/deploy.go.
Both webhook and interval paths funnel into deployments.redeployWhenChanged (api/stacks/deployments/deploy.go), which lacks the validator.
Here is the Docker Compose branch extracted from the actual Go source. It is a fragment of a larger switch statement, not standalone compilable Go:
That automatic-update path reached the deployer without the stack-security validation used by manual redeployment. Portainer knew how to reject the configuration. It simply did not apply the same check when new Git content arrived.
The webhook URL was incidental to the finding; exploiting it still required control of the backing repository, and scheduled polling reproduced the same behavior without a webhook. The tests confirmed this behavior in Portainer CE 2.45.0. Other released versions were not runtime-tested.
Patch: Portainer 2.45.2 added ValidateStackForUser before automatic GitOps redeployment, addressing the missing validation check. Users should upgrade to 2.45.2 or later. See the 2.45.2 release and patched Go source.
What other GitOps systems should take from this
The solution is not to disable automatic updates. It is to treat every update as a new deployment, even when the stack was previously approved. Resolve the exact configuration that will run, then enforce the current user’s permissions and the environment’s security policy immediately before execution. Manual updates, webhooks, scheduled polling, and retries should all pass through the same validation step, rather than implementing separate checks in each handler.
Teams operating similar systems should also limit what their deployment credentials can do, require review for changes that introduce host-level privileges, and test forbidden configurations through every deployment trigger. Where possible, enforce restrictions independently of the management UI at the container or orchestration boundary. A benign initial commit is not permission for every revision that follows.
Find and patch critical zero days in your environment: vulnetic.ai