CVE-2026-112816 Explained: The LXD and MicroCloud Authentication Bypass That Scored a Perfect CVSS 10.0

The CyberSec Guru

CVE-2026-112816

If you like this post, then please share it:

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Why your support matters: Zero paywalls: Keep the main content 100% free for learners worldwide.

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

On October 30, 2026, Canonical disclosed a maximum-severity authentication bypass in LXD and MicroCloud, tracked as CVE-2026-112816 (companion advisory GHSA-99jp-jwmc-65p4) with a CVSS v4.0 base score of 10.0. The flaw lets an unauthenticated attacker with network access to the LXD or MicroCloud HTTPS control plane impersonate any trusted client without holding that client’s private key. Once impersonated, the attacker inherits whatever permissions that certificate carries, which can include full control over containers, VMs, and storage volumes, and root execution on the host if the impersonated identity had administrative rights.

What makes this bug interesting to infrastructure and cryptography engineers isn’t exploit complexity. There’s no heap spray, no kernel race condition. It’s a protocol-layer semantic bug, classified under CWE-295 (Improper Certificate Validation), caused by a mismatch between what Go’s crypto/tls guarantees during a mutual TLS handshake and what LXD’s authentication code assumed about the resulting r.TLS.PeerCertificates slice. Instead of checking only the leaf certificate at index zero, the one certificate a client cryptographically proves ownership of via the CertificateVerify message, the LXD daemon looped over the entire unverified chain the client sent. An attacker who appends a stolen or publicly observed certificate from an existing administrator as a fake “intermediate” entry can walk straight through that loop.

How LXD Handles Identity, and Where tls.RequestClientCert Comes In

Most cloud control planes layer bearer tokens or session cookies on top of standard server-authenticated HTTPS. LXD and MicroCloud instead treat mutual TLS as the primary authentication mechanism. When an administrator first runs the lxc client against a remote daemon, the client generates a key pair and wraps the public key in a self-signed X.509 certificate, stored at ~/.config/lxc/client.crt (or the snap equivalent). The LXD daemon keeps an internal trust store, indexed by certificate fingerprint, mapping each trusted certificate to a role: unrestricted admin, project-scoped access, or metrics-only access for cluster members.

Because LXD needs to support initial client registration via trust tokens, public image server endpoints, and OIDC browser logins, its HTTPS listener can’t simply require every connection to chain up to a known CA at the transport layer. Doing so would reject self-signed clients and trust-token flows before the request ever reached LXD’s own routing logic. So the listener’s tls.Config uses ClientAuth: tls.RequestClientCert.

In Go, that setting tells the server to ask for a client certificate during the handshake but not to reject the connection if the client sends nothing, or sends a chain that fails standard path validation. Even in “PKI mode,” where an administrator drops a server.ca file into the daemon directory expecting all client certs to be signed by a central CA, the listener has historically kept tls.RequestClientCert so unauthenticated discovery endpoints can still respond. That defers the real authorization decision to the HTTP layer: (*Daemon).Authenticate and its helper util.CheckTrustState.

This design moves the whole burden of identity verification off Go’s crypto/x509 path verifier and onto LXD’s own code. When a request arrives, Go populates r.TLS (a *tls.ConnectionState) on the http.Request. Two fields matter here: VerifiedChains, which is only populated if Go itself performed CA path verification, and PeerCertificates, the raw list of X.509 structures exactly as the client sent them. Because LXD runs with tls.RequestClientCert, VerifiedChains stays nil, so the daemon reads r.TLS.PeerCertificates directly.

Why Only the First Certificate in the Chain Proves Anything

To see why looping over PeerCertificates is dangerous, it helps to look at what actually happens on the wire during a TLS handshake that requests client authentication.

After the server sends CertificateRequest, the client responds with two separate payloads: a Certificate message and a CertificateVerify message. The Certificate message carries an ordered list of DER-encoded X.509 certificates. Both TLS 1.2 (RFC 5246) and TLS 1.3 (RFC 8446) require the client’s own leaf certificate to come first, at index 0, with each later certificate in the chain certifying the one before it. But at the point Go parses this message, it hasn’t validated any of that structure. It calls x509.ParseCertificate on each blob and appends the result to c.peerCertificates in the order received, without checking whether certificate 1 actually signed certificate 0.

The CertificateVerify message is where the real proof happens. In TLS 1.3 it’s a signature over the handshake transcript so far. Go’s server verifies that signature using the public key from exactly one certificate: c.peerCertificates[0]. If that check passes, the server has cryptographic proof that whoever holds the private key for PeerCertificates[0] is on the other end of the connection.

Every other entry in the array, PeerCertificates[1] through N, gets no verification at all under tls.RequestClientCert. The client never signs anything with those keys. In normal PKI usage, a client doesn’t even possess the private key for its own intermediate CA. Those entries are only meaningful if the server runs x509.Certificate.Verify() to check the issuer-to-subject chain. If it doesn’t, everything past index 0 is just unauthenticated bytes riding along with the real certificate.

📬 Stay Ahead of Cyber Threats

Get the latest cybersecurity news, critical vulnerabilities, threat intelligence, tutorials, and exclusive giveaways delivered straight to your inbox. No spam. Unsubscribe anytime.

Subscribe to the Newsletter →

Inside the Bug: How Authenticate and CheckTrustState Got It Wrong

Inside the Bug: How Authenticate and CheckTrustState Got It Wrong
Inside the Bug: How Authenticate and CheckTrustState Got It Wrong

The flaw sits in (*Daemon).Authenticate and util.CheckTrustState. Instead of checking identity against r.TLS.PeerCertificates[0] alone, the code looped over the whole slice:

// Vulnerable pattern in LXD / pre-7.6.0 Incus authentication logic
if r.TLS != nil {
for i := range r.TLS.PeerCertificates {
trusted, certFingerprint := util.CheckTrustState(
*r.TLS.PeerCertificates[i],
trustedCerts,
networkCert,
pkiCA,
)
if trusted {
// Grant access under the identity of certFingerprint!
return true, certFingerprint, ...
}
}
}

For each certificate in the list, CheckTrustState checks whether it was signed by the configured CA (in PKI mode) and compares its fingerprint against the trust store. The first match wins, and the session gets authenticated as that certificate’s identity, regardless of which index it came from.

This pattern traces back to LXD’s PKI mode and trust-state refactor from June 2016, and since Incus forked from LXD in 2023, both codebases inherited it.

Here’s how an attacker exploits it:

  1. Generate a throwaway leaf certificate. openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -keyout attacker.key -out attacker_leaf.crt -days 1 -nodes -subj "/CN=untrusted" produces a fresh key pair and a self-signed cert.
  2. Get a public certificate from an already-trusted client. No private key needed, just the PEM or DER file.
  3. Build the fake chain. Pass tls.Certificate{Certificate: [][]byte{attackerLeafDER, adminTargetDER}, PrivateKey: attackerPrivKey} or concatenate the two PEM files into one bundle.
  4. Complete the handshake. The attacker signs CertificateVerify with attacker.key, which checks out against PeerCertificates[0], so the TLS connection opens.
  5. Trigger the loop. On i = 0, CheckTrustState sees the unknown attacker certificate and returns false. Instead of stopping there, the loop moves to i = 1, finds the admin’s certificate fingerprint in the trust store, and authenticates the request as that administrator.

The attacker’s leaf certificate was never signed by the admin’s certificate; there’s no cryptographic relationship between them at all. Because tls.RequestClientCert skips chain verification entirely, and CheckTrustState only checks each certificate against the trust store in isolation, that missing signature goes unnoticed.

The fix in Incus 7.6.0 and Canonical’s patched LXD builds removes the loop: identity is resolved from r.TLS.PeerCertificates[0] only, after confirming the slice isn’t empty.

How an Attacker Gets a Trusted Client’s Public Certificate

A public certificate is, by design, not a secret, so the harder question is how an attacker gets one in the first place. In LXD and MicroCloud environments, there are several ways:

MicroCloud cluster joins. When a node joins a MicroCloud cluster, it transmits its public certificate so existing members can add it to their trust store. Anyone on the local network segment, or capable of reading provisioning traffic, Ansible or Terraform state, or cloud-init payloads, can capture that certificate. Since cluster nodes need unrestricted admin privileges to coordinate storage and VM migration, a captured node certificate used as PeerCertificates[1] hands over the whole cluster.

TLS 1.2 cleartext handshakes. TLS 1.3 encrypts the client’s Certificate message after the initial key exchange, but TLS 1.2 sends it in cleartext. Any connection negotiated over TLS 1.2, or passing through a TLS-inspecting proxy that logs client certificates, exposes the full certificate to anyone watching.

Shared repos and the trust-store API itself. Administrators treat client.crt like an SSH public key, so it ends up committed to GitOps repos, pasted into tickets, or sitting world-readable on shared jump hosts. And once any low-privilege user has authenticated at all, calling GET /1.0/certificates?recursion=1 returns the full PEM-encoded certificates of every trusted client, including admins. A read-only monitoring account can pull an admin’s certificate from that endpoint and reconnect as them.

From API Bypass to Root on the Host

Canonical’s advisory notes that if the impersonated identity has privileged access, the attacker gets root on the host, not just control inside LXD. That’s because an unrestricted LXD client is functionally root on the hypervisor. Three API calls get there:

  1. Create a privileged container (POST /1.0/instances) with "security.privileged": "true" and "raw.lxc": "lxc.apparmor.profile=unconfined". In a privileged container, UID 0 maps directly to host UID 0.
  2. Mount the host filesystem by attaching a disk device pointing "source": "/" on the host to a path inside the container.
  3. Run commands via POST /1.0/instances/<name>/exec, chroot into the mounted host filesystem, and plant an SSH key or a systemd service.

An admin-level API client doesn’t even need a new instance; it can exec into any existing running container or VM across the cluster, or read raw storage volumes directly. That’s why CVSS v4.0 scores both the vulnerable-system and subsequent-system impacts as High across confidentiality, integrity, and availability, producing the full 10.0: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H.

A Familiar Pattern: CVE-2024-6156

This isn’t the first time tls.RequestClientCert and LXD’s custom verification logic produced an authentication bypass. On December 2, 2024, Canonical disclosed CVE-2024-6156 (GHSA-4c49-9fpc-hc3v), a CA sign-check bypass in PKI mode. There, LXD advertised an acceptable CA list during the handshake but kept ClientAuth set to tls.RequestClientCert. The standard lxc client respected that CA hint and refused to send a non-matching certificate, but curl --cert client.pem ignored it and sent the certificate anyway, reaching CheckTrustState with a certificate that should have been rejected.

That bug was low severity and limited to PKI mode. CVE-2026-112816 is the same authentication boundary failing in a much more serious way: because tls.RequestClientCert accepts arbitrary unverified chains, any deployment with a network listener was exposed to unauthenticated impersonation, PKI mode or not.

Which Versions Are Affected

A default, non-clustered LXD install doesn’t bind to a network port at all. It listens on a local Unix socket, access to which is controlled by POSIX group membership rather than TLS. The vulnerable HTTPS listener only comes into play once an administrator runs lxc config set core.https_address <ip>:<port>, typically on TCP 8443. But every clustered LXD deployment and every MicroCloud install requires that listener, since cluster nodes communicate over it.

Check whether a host is exposed:

lxc config get core.https_address
# Or for Incus:
incus config get core.https_address
# Verify active socket bindings:
sudo ss -tulpn | grep -E 'lxd|incus|8443'

Affected and fixed versions, according to Canonical’s advisory:

  • LXD 6.x: through 6.9, fixed in 6.10.1
  • LXD 5.21 LTS: through 5.21.8, fixed in 5.21.9
  • LXD 5.0 LTS: through 5.0.10, fixed in 5.0.11
  • LXD 4.0 LTS: through 4.0.14, fixed in 4.0.15
  • MicroCloud: fixed in 3.4.1 (3/stable) and 2.1.5 (2/stable)
  • Incus: fixed in 7.6.0, released October 30, 2026

Ubuntu’s .deb packages of LXD are either unaffected or no longer shipped; the vulnerable code is in the snap packages.

How to Patch and Verify

There’s no config flag that disables the vulnerable chain iteration. The only fix is upgrading the daemon:

sudo snap refresh lxd
sudo snap refresh microcloud

Canonical’s advisory warns that at disclosure time, some patched releases were still moving from candidate to stable snap channels. A snap refresh that reports “no updates available” doesn’t necessarily mean you’re patched if your channel hadn’t synced yet. Confirm the actual version:

snap list lxd microcloud
lxc version

You want 6.10.1, 5.21.9, 5.0.11, or 4.0.15 for LXD, and 3.4.1 or 2.1.5 for MicroCloud. If your local mirror’s stable channel hasn’t caught up, you can pull from candidate directly, for example sudo snap refresh lxd --channel=5.21/candidate, once you’ve confirmed it’s actually fixed.

As of disclosure, CISA’s Vulnrichment assessment recorded no known active exploitation, and the bug wasn’t yet in CISA’s Known Exploited Vulnerabilities catalog. The Cloud Security Alliance did note that an unverified proof-of-concept repository was indexed by exploit aggregators within hours. Given how little code it takes to build a two-element certificate chain, it’s worth assuming scanners are already probing port 8443.

While you wait for a patching window, restrict access to port 8443 at the firewall level, allowing only known cluster nodes and bastion hosts. If you suspect exposure before patching, check /var/snap/lxd/common/lxd/logs/lxd.log for unexpected API calls, review lxc config trust list for certificates you don’t recognize, and look for instances with newly attached disk devices or security.privileged: "true" set.

The Broader Lesson for Anyone Building Custom mTLS

CVE-2026-112816 is a clean example of what goes wrong when application code uses low-level TLS hooks like tls.RequestClientCert without replicating the invariants a standard PKI verifier enforces. In crypto/tls, PeerCertificates is a mixed bag: index 0 carries a cryptographic proof of possession, and everything after it is unverified client input unless VerifiedChains has actually been populated.

Two rules follow from that, for anyone implementing their own certificate-based authentication:

Bind identity checks to r.TLS.PeerCertificates[0] only, never to the whole slice. And treat a public certificate as a public identifier, not a secret; only a signature from the matching private key proves ownership.

A ten-year-old for-loop, iterating over data that was never meant to be trusted past its first element, turned into a CVSS 10.0 cluster takeover.

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Your contribution powers free tutorials, hands-on labs, and security resources.

Why your support matters:
  • Writeup Access: Get complete writeup access within 12 hours
  • Zero paywalls: Keep the main content 100% free for learners worldwide

Perks for one-time supporters:
☕️ $5: Shoutout in Buy Me a Coffee
🛡️ $8: Fast-track Access to Live Webinars
💻 $10: Vote on future tutorial topics + exclusive AMA access

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

If you like this post, then please share it:

Exploits

Discover more from The CyberSec Guru

Subscribe to get the latest posts sent to your email!

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.