CVE-2026-44849: Portainer Swarm Endpoint Security Bypass
Overview
| CVE | CVE-2026-44849 |
| Advisory | GHSA-5fxq-qcf3-244w |
| Component | api/http/proxy/factory/docker/services.go and api/http/proxy/factory/docker/transport.go |
| Affected | Portainer 2.33.x before 2.33.8, 2.39.x before 2.39.2, and 2.40.x before 2.41.0 |
| Fixed | Portainer 2.33.8, 2.39.2, and 2.41.0 |
| Class | CWE-862 (Missing Authorization) |
| CVSS v4.0 | 9.4 (Critical) -- CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H |
| Found by | Lukas Johannes Moeller (@JohannesLks) and route2shell |
TL;DR
Portainer lets administrators restrict what non-admin users may run on Docker endpoints: privileged containers, host namespaces, Linux capabilities, sysctls, seccomp/AppArmor settings, device mappings, and bind mounts. Those restrictions were enforced on the normal container creation path, but not consistently on Docker Swarm services. Service creation parsed only a narrow subset of the request body, and service update reused a generic ownership check without inspecting the new service spec at all. A non-admin user who already had access to a Swarm endpoint could therefore create or update services with settings the administrator had explicitly disabled for regular users.
Root Cause
Portainer enforces seven EndpointSecuritySettings restrictions on the regular container-create path. On the Swarm service API, coverage was incomplete since the feature was introduced:
| Path | Checks applied | Gap |
|---|---|---|
POST /services/create |
1 of 7 (bind mounts only) | CapabilityAdd, CapabilityDrop, Sysctls, Privileges not parsed |
POST /services/{id}/update |
0 of 7 | Generic ownership check only; no body inspection, no fetchEndpointSecuritySettings() |
The service-create proxy path used a partial struct to inspect only mount type information before forwarding the original request to Docker:
type PartialService struct {
TaskTemplate struct {
ContainerSpec struct {
Mounts []struct {
Type string
}
}
}
}
Fields such as CapabilityAdd, CapabilityDrop, Sysctls, and Privileges were absent from that validation view. They survived in the forwarded JSON body but were invisible to Portainer's policy check.
The service-update path was broader: requests matching /services/*/* were dispatched to a generic restricted-resource operation. That function verified that the user was allowed to operate on the service resource, but it did not fetch EndpointSecuritySettings, parse the submitted service spec, or compare the requested container settings against the endpoint policy.
Bind-Style Volume Variant
The bind-mount check also trusted only the top-level mount type. Docker's local volume driver can materialize a bind-equivalent mount through driver options, while the request still appears as Type: "volume" at the top level:
Mounts: [{
Type: "volume",
VolumeOptions: {
DriverConfig: {
Name: "local",
Options: {
type: "none",
o: "bind",
device: "<host path>"
}
}
}
}]
Because the old partial struct did not include VolumeOptions.DriverConfig.Options, this shape bypassed the Type != "bind" test and reached the Docker daemon unchanged. The same policy gap existed for direct /volumes/create requests, which did not apply the regular-user bind-mount restriction on the affected branches.
Impact
- Required position: authenticated non-admin Portainer user with access to a Docker Swarm endpoint through Portainer RBAC.
- Bypassed controls: capability allow/drop restrictions, sysctl restrictions, seccomp/AppArmor restrictions, and bind-mount restrictions on Swarm service create/update paths.
- Practical effect: workloads could be launched or modified with host-impacting container settings that the endpoint administrator had disabled for regular users.
- In combination, elevated capabilities plus host filesystem access can collapse the intended boundary between a restricted Portainer account and the Swarm host.
Admins and users without Swarm endpoint access were not affected by this specific path. The bug is still a security-boundary issue because the vulnerable role is exactly the user group EndpointSecuritySettings are meant to constrain.
Fix
The fix brings the Swarm paths back under the same policy model as regular container creation:
- extend the service-create validation view to include the security-sensitive fields that Docker accepts in
ContainerSpec; - inspect service-update request bodies instead of relying only on resource ownership checks;
- treat local-driver
type=none/o=bindvolume options as bind-equivalent for policy purposes; - apply volume body restrictions to direct volume creation as well.
The important invariant is that Portainer must validate the same semantic container configuration that the Docker daemon will execute. Partial JSON views are safe only when every security-relevant alias and nested representation is included.
Credits
Reported independently by Lukas Johannes Moeller (@JohannesLks) and route2shell through Portainer's coordinated disclosure process.