New MCP server — Bring vulnerability intelligence into your AI
Back to feed

CVE-2026-3351

ExploitedNVDCIRCLENISA

EUVD-2026-9286 · Published Mar 4, 2026

lxd's non-recursive certificate listing bypasses per-object authorization and leaks all fingerprints

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.go — certificatesGet (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.go — instancesGet Yes — filters before either path
Images lxd/images.go — doImagesGet Yes — checks hasPermission for both paths
Networks lxd/networks.go — networksGet Yes — filters outside recursion check
Profiles lxd/profiles.go — profilesGet Yes — separate filter in non-recursive path
Certificates lxd/certificates.go — certificatesGet 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.

Technologies

Linux Linux KernelCanonical Lxd

Weaknesses

CWE-862

MITRE ATT&CK

Inferred

Where this vulnerability fits in an attack

Initial Access Persistence Stealth Defense Impairment Credential Access

References

Potentially impacted assets

See if this affects your attack surface

Latest trending attack

Criticalyesterday

Google Brotli decoder - Remote Code Execution via compressed requests (Exploitation Check)

This control is the second step of the Patrowl response to CVE-2026-111497 (Node2Shell). It complements the first control of 22 October 2026, which focused on the Node.js runtime, and does not replace it. Why a second control CVE-2026-111497 is an out-of-bounds write in the Google Brotli decoder, the library that decompresses Brotli ("br") data. It was first published as a Node.js vulnerability, because Node.js bundles this decoder. Later publications showed that the flaw is in the library itself. Every product that embeds an affected version of the decoder is concerned, whatever its language or vendor. A public proof of concept demonstrates code execution. Any server that decompresses request bodies sent with "Content-Encoding: br" exposes the decoder to unauthenticated attackers. Known cases include Express 5 and NestJS 11 on Node.js, Koa and Fastify setups, Bun, Envoy and Istio with request decompression enabled, and any application or reverse proxy that decodes Brotli request bodies with an affected library. The flaw also works in the other direction. A malicious server can compromise an unpatched client that decodes a Brotli response, for example Node.js fetch(), Python clients (requests, httpx, aiohttp, Scrapy) with the Brotli or brotlicffi package, or libcurl builds with Brotli support. What this control checks Patrowl sends small, valid Brotli-compressed requests to each asset and observes whether the server decodes them. The check does not depend on the technology: it detects the behaviour, whatever the stack behind it. These requests are what any legitimate client could send. They never reach the vulnerable code path and cannot affect the availability of the service. How to read the result Potentially impacted means the asset decodes Brotli request bodies: the attack surface is exposed. It does not confirm that the asset is vulnerable. The version of the Brotli decoder in use is not visible from the outside. This check covers applications that parse request bodies on their root path. A Not impacted result does not rule out exposure on other routes, nor on the client side. Checking the versions on the servers remains the only exhaustive verification. Affected and fixed versions Google Brotli 1.0.3 to 1.2.0 are affected, fixed in 1.0.10, 1.1.1 and 1.2.1. Fixed releases of the main products embedding it: Node.js 22.23.4, 24.22.1 and 26.12.1 (end-of-life lines 20.x and older and 25.x receive no fix), Bun 1.4.4, Envoy 1.37.8, 1.38.6, 1.39.3 and 1.40.0, Istio 1.29.9, 1.30.6 and 1.31.2, Python Brotli 1.2.1 and brotlicffi 1.2.1.0. Products that link against the system library depend on the Brotli package of the Linux distribution. Recommendations Update every component that embeds the Brotli decoder (only real fix). Start with the assets flagged by this control. Restart the processes after the update. Rebuild containers, single-executable applications and appliances that bundle an affected runtime or library. Reduce the exposure until the update is deployed. Reject requests with "Content-Encoding: br" at the reverse proxy, load balancer or WAF, case-insensitively and including lists such as "gzip, br". On Envoy and Istio, disable request decompression for Brotli. Review outbound calls. List the features that fetch content from servers you do not control, and patch the services that host them first.

Take 15 minutes to discover our platform with our experts

Patrowl Intel platform screenshot