GHSA-CRMG-9M86-636R

Vulnerability from github – Published: 2026-03-04 20:18 – Updated: 2026-03-04 20:18
VLAI?
Summary
lxd's non-recursive certificate listing bypasses per-object authorization and leaks all fingerprints
Details

Summary

The GET /1.0/certificates endpoint (non-recursive mode) returns URLs containing fingerprints for all certificates in the trust store, bypassing the per-object can_view authorization check that is correctly applied in the recursive path. Any authenticated identity — including restricted, non-admin users — can enumerate all certificate fingerprints, exposing the full set of trusted identities in the LXD deployment.

Affected Component

  • lxd/certificates.gocertificatesGet (lines 185–192) — Non-recursive code path returns unfiltered certificate list.

CWE

  • CWE-862: Missing Authorization

Description

Core vulnerability: missing permission filter in non-recursive listing path

The certificatesGet handler obtains a permission checker at line 143 and correctly applies it when building the recursive response (lines 163-176). However, the non-recursive code path at lines 185-192 creates a fresh loop over the unfiltered baseCerts slice, completely bypassing the authorization check:

// lxd/certificates.go:139-193
func certificatesGet(d *Daemon, r *http.Request) response.Response {
    recursion := util.IsRecursionRequest(r)
    s := d.State()

    userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeCertificate)
    // ...

    for _, baseCert := range baseCerts {
        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {
            continue  // Correctly filters unauthorized certs
        }

        if recursion {
            // ... builds filtered certResponses ...
        }
        // NOTE: when !recursion, nothing is recorded — the filter result is discarded
    }

    if !recursion {
        body := []string{}
        for _, baseCert := range baseCerts {  // <-- iterates UNFILTERED baseCerts
            certificateURL := api.NewURL().Path(version.APIVersion, "certificates", baseCert.Fingerprint).String()
            body = append(body, certificateURL)
        }
        return response.SyncResponse(true, body)  // Returns ALL certificate fingerprints
    }

    return response.SyncResponse(true, certResponses)  // Recursive path is correctly filtered
}

Inconsistency with other list endpoints confirms the bug

Five other list endpoints in the same codebase correctly filter results in both recursive and non-recursive paths:

Endpoint File Filters non-recursive?
Instances lxd/instances_get.goinstancesGet Yes — filters before either path
Images lxd/images.godoImagesGet Yes — checks hasPermission for both paths
Networks lxd/networks.gonetworksGet Yes — filters outside recursion check
Profiles lxd/profiles.goprofilesGet Yes — separate filter in non-recursive path
Certificates lxd/certificates.gocertificatesGet No — unfiltered

The certificates endpoint is the sole outlier, confirming this is an oversight rather than a design choice.

Access handler provides no defense

The endpoint uses allowAuthenticated as its AccessHandler (certificates.go:45), which only checks requestor.IsTrusted():

// lxd/daemon.go:255-267
// allowAuthenticated is an AccessHandler which allows only authenticated requests.
// This should be used in conjunction with further access control within the handler
// (e.g. to filter resources the user is able to view/edit).
func allowAuthenticated(_ *Daemon, r *http.Request) response.Response {
    requestor, err := request.GetRequestor(r.Context())
    // ...
    if requestor.IsTrusted() {
        return response.EmptySyncResponse
    }
    return response.Forbidden(nil)
}

The comment explicitly states that allowAuthenticated should be "used in conjunction with further access control within the handler" — which the non-recursive path fails to do.

Execution chain

  1. Restricted authenticated user sends GET /1.0/certificates (no recursion parameter)
  2. allowAuthenticated access handler passes because user is trusted (daemon.go:263)
  3. certificatesGet creates permission checker for EntitlementCanView on TypeCertificate (line 143)
  4. Loop at lines 163-176 filters baseCerts by permission — but only populates certResponses for recursive mode
  5. Since !recursion, control reaches lines 185-192
  6. New loop iterates ALL baseCerts (unfiltered) and builds URL list with fingerprints
  7. Full list of certificate fingerprints returned to restricted user

Proof of Concept

# Preconditions: restricted (non-admin) trusted client certificate
HOST=target.example
PORT=8443

# 1) Non-recursive list: returns ALL certificate fingerprints (UNFILTERED)
curl -sk --cert restricted.crt --key restricted.key \
  "https://${HOST}:${PORT}/1.0/certificates" | jq '.metadata | length'

# 2) Recursive list: returns only authorized certificates (FILTERED)
curl -sk --cert restricted.crt --key restricted.key \
  "https://${HOST}:${PORT}/1.0/certificates?recursion=1" | jq '.metadata | length'

# Expected: (1) returns MORE fingerprints than (2), proving the authorization bypass.
# The difference reveals fingerprints of certificates the restricted user should not see.

Impact

  • Identity enumeration: A restricted user can discover the fingerprints of all trusted certificates, revealing the complete set of identities in the LXD trust store.
  • Reconnaissance for targeted attacks: Fingerprints identify specific certificates used for inter-cluster communication, admin access, and other privileged operations.
  • RBAC bypass: In deployments using fine-grained RBAC (OpenFGA or built-in TLS authorization), the non-recursive path completely bypasses the intended per-object visibility controls.
  • Information asymmetry: Restricted users gain knowledge of the full trust topology, which the administrator explicitly intended to hide via per-certificate can_view entitlements.

Recommended Remediation

Option 1: Apply the permission filter to the non-recursive path (preferred)

Replace the unfiltered loop with one that checks userHasPermission, matching the pattern used in the recursive path and in all other list endpoints:

// lxd/certificates.go — replace lines 185-192
if !recursion {
    body := []string{}
    for _, baseCert := range baseCerts {
        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {
            continue
        }
        certificateURL := api.NewURL().Path(version.APIVersion, "certificates", baseCert.Fingerprint).String()
        body = append(body, certificateURL)
    }
    return response.SyncResponse(true, body)
}

Option 2: Build both response types in a single filtered loop

Restructure the function to build both the URL list and the recursive response in the same permission-checked loop, eliminating the possibility of divergent filtering:

err = d.State().DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
    baseCerts, err = dbCluster.GetCertificates(ctx, tx.Tx())
    if err != nil {
        return err
    }

    certResponses = make([]*api.Certificate, 0, len(baseCerts))
    certURLs = make([]string, 0, len(baseCerts))
    for _, baseCert := range baseCerts {
        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {
            continue
        }

        certURLs = append(certURLs, api.NewURL().Path(version.APIVersion, "certificates", baseCert.Fingerprint).String())

        if recursion {
            apiCert, err := baseCert.ToAPI(ctx, tx.Tx())
            if err != nil {
                return err
            }
            certResponses = append(certResponses, apiCert)
            urlToCertificate[entity.CertificateURL(apiCert.Fingerprint)] = apiCert
        }
    }
    return nil
})

Option 2 is structurally safer as it prevents the two paths from diverging in the future.

Credit

This vulnerability was discovered and reported by bugbunny.ai.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/canonical/lxd"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.0.0-20260224152359-d936c90d47cf"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-3351"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-862"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-04T20:18:56Z",
    "nvd_published_at": "2026-03-03T13:16:21Z",
    "severity": "MODERATE"
  },
  "details": "## Summary\nThe `GET /1.0/certificates` endpoint (non-recursive mode) returns URLs containing fingerprints for all certificates in the trust store, bypassing the per-object `can_view` authorization check that is correctly applied in the recursive path. Any authenticated identity \u2014 including restricted, non-admin users \u2014 can enumerate all certificate fingerprints, exposing the full set of trusted identities in the LXD deployment.\n\n## Affected Component\n- `lxd/certificates.go` \u2014 `certificatesGet` (lines 185\u2013192) \u2014 Non-recursive code path returns unfiltered certificate list.\n\n## CWE\n- **CWE-862**: Missing Authorization\n\n## Description\n\n### Core vulnerability: missing permission filter in non-recursive listing path\n\nThe `certificatesGet` handler obtains a permission checker at line 143 and correctly applies it when building the recursive response (lines 163-176). However, the non-recursive code path at lines 185-192 creates a fresh loop over the unfiltered `baseCerts` slice, completely bypassing the authorization check:\n\n```go\n// lxd/certificates.go:139-193\nfunc certificatesGet(d *Daemon, r *http.Request) response.Response {\n    recursion := util.IsRecursionRequest(r)\n    s := d.State()\n\n    userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeCertificate)\n    // ...\n\n    for _, baseCert := range baseCerts {\n        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n            continue  // Correctly filters unauthorized certs\n        }\n\n        if recursion {\n            // ... builds filtered certResponses ...\n        }\n        // NOTE: when !recursion, nothing is recorded \u2014 the filter result is discarded\n    }\n\n    if !recursion {\n        body := []string{}\n        for _, baseCert := range baseCerts {  // \u003c-- iterates UNFILTERED baseCerts\n            certificateURL := api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String()\n            body = append(body, certificateURL)\n        }\n        return response.SyncResponse(true, body)  // Returns ALL certificate fingerprints\n    }\n\n    return response.SyncResponse(true, certResponses)  // Recursive path is correctly filtered\n}\n```\n\n### Inconsistency with other list endpoints confirms the bug\n\nFive other list endpoints in the same codebase correctly filter results in both recursive and non-recursive paths:\n\n| Endpoint | File | Filters non-recursive? |\n|----------|------|----------------------|\n| Instances | `lxd/instances_get.go` \u2014 `instancesGet` | Yes \u2014 filters before either path |\n| Images | `lxd/images.go` \u2014 `doImagesGet` | Yes \u2014 checks `hasPermission` for both paths |\n| Networks | `lxd/networks.go` \u2014 `networksGet` | Yes \u2014 filters outside recursion check |\n| Profiles | `lxd/profiles.go` \u2014 `profilesGet` | Yes \u2014 separate filter in non-recursive path |\n| **Certificates** | **`lxd/certificates.go` \u2014 `certificatesGet`** | **No \u2014 unfiltered** |\n\nThe certificates endpoint is the sole outlier, confirming this is an oversight rather than a design choice.\n\n### Access handler provides no defense\n\nThe endpoint uses `allowAuthenticated` as its `AccessHandler` (`certificates.go:45`), which only checks `requestor.IsTrusted()`:\n\n```go\n// lxd/daemon.go:255-267\n// allowAuthenticated is an AccessHandler which allows only authenticated requests.\n// This should be used in conjunction with further access control within the handler\n// (e.g. to filter resources the user is able to view/edit).\nfunc allowAuthenticated(_ *Daemon, r *http.Request) response.Response {\n    requestor, err := request.GetRequestor(r.Context())\n    // ...\n    if requestor.IsTrusted() {\n        return response.EmptySyncResponse\n    }\n    return response.Forbidden(nil)\n}\n```\n\nThe comment explicitly states that `allowAuthenticated` should be \"used in conjunction with further access control within the handler\" \u2014 which the non-recursive path fails to do.\n\n### Execution chain\n\n1. Restricted authenticated user sends `GET /1.0/certificates` (no `recursion` parameter)\n2. `allowAuthenticated` access handler passes because user is trusted (`daemon.go:263`)\n3. `certificatesGet` creates permission checker for `EntitlementCanView` on `TypeCertificate` (line 143)\n4. Loop at lines 163-176 filters `baseCerts` by permission \u2014 but only populates `certResponses` for recursive mode\n5. Since `!recursion`, control reaches lines 185-192\n6. New loop iterates ALL `baseCerts` (unfiltered) and builds URL list with fingerprints\n7. Full list of certificate fingerprints returned to restricted user\n\n## Proof of Concept\n\n```bash\n# Preconditions: restricted (non-admin) trusted client certificate\nHOST=target.example\nPORT=8443\n\n# 1) Non-recursive list: returns ALL certificate fingerprints (UNFILTERED)\ncurl -sk --cert restricted.crt --key restricted.key \\\n  \"https://${HOST}:${PORT}/1.0/certificates\" | jq \u0027.metadata | length\u0027\n\n# 2) Recursive list: returns only authorized certificates (FILTERED)\ncurl -sk --cert restricted.crt --key restricted.key \\\n  \"https://${HOST}:${PORT}/1.0/certificates?recursion=1\" | jq \u0027.metadata | length\u0027\n\n# Expected: (1) returns MORE fingerprints than (2), proving the authorization bypass.\n# The difference reveals fingerprints of certificates the restricted user should not see.\n```\n\n## Impact\n\n- **Identity enumeration**: A restricted user can discover the fingerprints of all trusted certificates, revealing the complete set of identities in the LXD trust store.\n- **Reconnaissance for targeted attacks**: Fingerprints identify specific certificates used for inter-cluster communication, admin access, and other privileged operations.\n- **RBAC bypass**: In deployments using fine-grained RBAC (OpenFGA or built-in TLS authorization), the non-recursive path completely bypasses the intended per-object visibility controls.\n- **Information asymmetry**: Restricted users gain knowledge of the full trust topology, which the administrator explicitly intended to hide via per-certificate `can_view` entitlements.\n\n## Recommended Remediation\n\n### Option 1: Apply the permission filter to the non-recursive path (preferred)\n\nReplace the unfiltered loop with one that checks `userHasPermission`, matching the pattern used in the recursive path and in all other list endpoints:\n\n```go\n// lxd/certificates.go \u2014 replace lines 185-192\nif !recursion {\n    body := []string{}\n    for _, baseCert := range baseCerts {\n        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n            continue\n        }\n        certificateURL := api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String()\n        body = append(body, certificateURL)\n    }\n    return response.SyncResponse(true, body)\n}\n```\n\n### Option 2: Build both response types in a single filtered loop\n\nRestructure the function to build both the URL list and the recursive response in the same permission-checked loop, eliminating the possibility of divergent filtering:\n\n```go\nerr = d.State().DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {\n    baseCerts, err = dbCluster.GetCertificates(ctx, tx.Tx())\n    if err != nil {\n        return err\n    }\n\n    certResponses = make([]*api.Certificate, 0, len(baseCerts))\n    certURLs = make([]string, 0, len(baseCerts))\n    for _, baseCert := range baseCerts {\n        if !userHasPermission(entity.CertificateURL(baseCert.Fingerprint)) {\n            continue\n        }\n\n        certURLs = append(certURLs, api.NewURL().Path(version.APIVersion, \"certificates\", baseCert.Fingerprint).String())\n\n        if recursion {\n            apiCert, err := baseCert.ToAPI(ctx, tx.Tx())\n            if err != nil {\n                return err\n            }\n            certResponses = append(certResponses, apiCert)\n            urlToCertificate[entity.CertificateURL(apiCert.Fingerprint)] = apiCert\n        }\n    }\n    return nil\n})\n```\n\nOption 2 is structurally safer as it prevents the two paths from diverging in the future.\n\n## Credit\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).",
  "id": "GHSA-crmg-9m86-636r",
  "modified": "2026-03-04T20:18:56Z",
  "published": "2026-03-04T20:18:56Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/canonical/lxd/security/advisories/GHSA-crmg-9m86-636r"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-3351"
    },
    {
      "type": "WEB",
      "url": "https://github.com/canonical/lxd/pull/17738"
    },
    {
      "type": "WEB",
      "url": "https://github.com/canonical/lxd/commit/d936c90d47cf0be1e9757df897f769e9887ebde1"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/canonical/lxd"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "lxd\u0027s non-recursive certificate listing bypasses per-object authorization and leaks all fingerprints"
}


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…