//nefariousplan

CVE-2026-40243: Incus's OVN Verifier Builds Its Root Pool From the Peer It's Verifying

pattern

cve

proof of concept

An OVN-backed Incus deployment runs a software-defined network whose control plane is the OVN database, and the Incus daemon opens an SSL connection to that database with a client certificate and, in the operator's intended configuration, a CA certificate that anchors trust in the server. The CA configuration was the operator's only signal that they wanted real validation. From the native ovsdb client's first commit in December 2023 through the 7.0.0 release in April 2026, the verifier Incus installed for that connection built its root-CA pool from whatever certificates the peer sent over the wire. The configured CA was parsed, marked as a CA, marked as cert-signing-capable, and added to a pool. The closure that decided trust did not reference the pool. Twenty-eight months in production, four handlers carrying the same closure, a clientCAPool variable in scope of the verifier the whole time. The patch was three lines per file. The variable was always there.

The configured CA is parsed, marked as a CA, and never read again

NewNB is the constructor for Incus's OVN Northbound client. When the database address starts with ssl:, the function builds a tls.Config and, if the operator has configured a CA certificate, parses it and constructs a single-certificate pool. The code that does that is deliberate. It is not stub work, and the comments do not apologize for anything.

// Add CA check if provided.
if sslCACert != "" {
    tlsCAder, _ := pem.Decode([]byte(sslCACert))
    if tlsCAder == nil {
        return nil, errors.New("Couldn't parse CA certificate")
    }

    tlsCAcert, err := x509.ParseCertificate(tlsCAder.Bytes)
    if err != nil {
        return nil, err
    }

    tlsCAcert.IsCA = true
    tlsCAcert.KeyUsage = x509.KeyUsageCertSign

    clientCAPool := x509.NewCertPool()
    clientCAPool.AddCert(tlsCAcert)
    ...
}

tlsCAcert.IsCA = true and tlsCAcert.KeyUsage = x509.KeyUsageCertSign are programmatic upgrades. Go's x509.Certificate.Verify refuses to treat a certificate as a trust root unless its IsCA flag and key-usage permit it; an operator-supplied CA may have either attribute missing in the parsed form. The developer wrote those two lines because they knew the cert had to be marked CA-capable for the verifier to use it as a root. They knew exactly what role this cert was going to play. They put it in clientCAPool. Then the next thing the function does is install a VerifyPeerCertificate closure that does not reference clientCAPool once. That closure is CVE-2026-40243.

The verifier builds its root pool from the peer's chain

The closure is captured below at its full pre-patch length. It is the entire trust decision the Incus daemon makes for every SSL OVN connection in versions below 7.0.0.

tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, chains [][]*x509.Certificate) error {
    if len(rawCerts) < 1 {
        return errors.New("Missing server certificate")
    }

    // Load the chain.
    roots := x509.NewCertPool()
    for _, rawCert := range rawCerts {
        cert, _ := x509.ParseCertificate(rawCert)
        if cert != nil {
            roots.AddCert(cert)
        }
    }

    // Load the main server certificate.
    cert, _ := x509.ParseCertificate(rawCerts[0])
    if cert == nil {
        return errors.New("Bad server certificate")
    }

    // Validate.
    opts := x509.VerifyOptions{
        Roots: roots,
    }

    _, err := cert.Verify(opts)
    return err
}

rawCerts is the parameter Go's TLS stack hands the callback during the handshake. It is the raw bytes of every certificate the peer presented, in chain order, with rawCerts[0] being the leaf. This is attacker-shapeable data; the peer wrote it onto the wire ten milliseconds ago.

The closure loops over every entry in rawCerts, parses each as a certificate, and adds it to a new local x509.CertPool that the developer named roots. The loop has no offset and no filter. rawCerts[0] ends up inside roots alongside everything else.

The closure then parses rawCerts[0] again, calls it cert, and runs cert.Verify against opts.Roots = roots. cert.Verify asks the standard library to find a certificate path from cert to any trust root in the pool. The pool contains cert. The path is length zero. The function returns nil. The closure returns nil. The handshake succeeds. The peer is the OVN database.

Any peer with the network address and the ability to present a self-signed X.509 certificate is the OVN database. The certificate does not have to chain to the configured CA. The certificate does not have to chain to anything. A new openssl req -x509 -newkey rsa:2048 -keyout fake.key -out fake.crt -days 1 -nodes -subj /CN=ovnsb and a listener on the configured OVN endpoint port is enough.

The patch is three lines per file

The fix commit, 2b7d83a017, is dated April 9, 2026. It changes nothing about the CA-parsing block. It changes nothing about the closure's signature, the rawCerts-handling, or the cert.Verify call. It changes one variable name and the constant set against Roots:

                 // Load the chain.
-                roots := x509.NewCertPool()
-                for _, rawCert := range rawCerts {
-                    cert, _ := x509.ParseCertificate(rawCert)
-                    if cert != nil {
-                        roots.AddCert(cert)
+                intermediates := x509.NewCertPool()
+                if len(rawCerts) > 1 {
+                    for _, rawCert := range rawCerts[1:] {
+                        cert, _ := x509.ParseCertificate(rawCert)
+                        if cert != nil {
+                            intermediates.AddCert(cert)
+                        }
                     }
                 }
                 ...
                 // Validate.
                 opts := x509.VerifyOptions{
-                    Roots: roots,
+                    Roots:         clientCAPool,
+                    Intermediates: intermediates,
                 }

The patched verifier puts the peer's chain into a pool named intermediates and skips rawCerts[0] (the leaf cannot be its own intermediate, either, but inclusion would not have broken the new check the way it broke the old one). It then verifies cert against opts.Roots = clientCAPool, the configured CA pool that was built one closure-capture above. The path the verifier traces is now cert to one of cert's intermediates to clientCAPool's sole entry, the operator's CA. A leaf with no path to the configured CA fails. A self-signed cert presented as both leaf and root fails. A peer whose chain stops short of the configured CA fails.

Three lines of change. The variable clientCAPool was already declared on the captured frame above; the closure was already in scope of it. The commit message names the bug plainly: "rather broken validation logic which would effectively treat any remote certificate as valid." Stéphane Graber, the maintainer, signed the commit. 7asecurity reported it.

Copy-pasted across four handlers

NewNB was not the only constructor with this closure. Incus also has NewSB (Southbound), NewICNB (Interconnect Northbound), and NewICSB (Interconnect Southbound). Each is in its own file under internal/server/network/ovn/. Each opens its own SSL connection. Each shipped the same VerifyPeerCertificate closure with the same Roots: roots line.

The fix commit's stat:

 internal/server/network/ovn/ovn_icnb.go | 15 +++++++++------
 internal/server/network/ovn/ovn_icsb.go | 15 +++++++++------
 internal/server/network/ovn/ovn_nb.go   | 15 +++++++++------
 internal/server/network/ovn/ovn_sb.go   | 15 +++++++++------
 4 files changed, 36 insertions(+), 24 deletions(-)

The same three-line semantic change applied four times across four files. The bug arrived in two waves: the native ovsdb client commit 6e6f25a668 on December 18, 2023, which introduced NB and SB; the IC client commit 1cc1444c09 on February 27, 2024, which carried the same closure forward into ICNB and ICSB by copy-paste. The closure was wrong on the day it was written and stayed wrong on the day it was copied.

The CA-configured branch did not strengthen the no-CA branch

NewNB's SSL setup has two code paths, gated by if sslCACert != "". They are worth reading next to each other because the bug is what made them equivalent.

Without a configured CA, NewNB builds a tls.Config with Certificates: []tls.Certificate{clientCert} and InsecureSkipVerify: true, and stops. The standard Go TLS server-verification step is disabled and no replacement is installed. The handshake accepts any server certificate. This is the documented behavior of InsecureSkipVerify: true in Go's crypto/tls; an Incus operator who runs without a CA is opting into it.

With a configured CA, the same setup runs, plus the CA is parsed and the VerifyPeerCertificate closure is installed. The closure runs after the standard verification is skipped; it is the substitute trust check. The substitute trust check accepts any server certificate.

The CA configuration option was the only operator-side signal that "I want real validation, not InsecureSkipVerify." The code accepted the signal, allocated the pool, marked the certificate as a CA, set the key-usage bit, and threw the pool away inside a closure that read from the peer instead. From the wire, the two code paths were indistinguishable. The configured CA was the operator's contract with Incus that the connection was anchored. Incus carried the contract for twenty-eight months as a parsed object that no decision was made against.

Caller-chosen-key, in a TLS handshake

This is caller-chosen-key in a code path no JWT goes near. The pattern's prior exhibits all read the trust anchor from the same request that carries the artifact being verified: Nginx-UI's backup restore reads the HMAC key from the security_token form field that ships with the backup, RestroPress's JWT issuer signs with the Authorization header the caller chose, Unity Catalog's verifier follows the JWT's iss claim to a JWKS the caller hosts. Same shape, three transports.

Incus's OVN verifier extends the shape into TLS peer-certificate validation. The artifact being authenticated is rawCerts[0], the peer's leaf certificate. The trust anchor is whatever the peer puts in rawCerts. The cryptographic operation, cert.Verify(opts), succeeds correctly given its inputs; the operation is mathematically honest. The inputs are the bypass. The verifier's Roots pool is supplied by the same party whose certificate it authenticates, on the same handshake, through the same Go callback parameter. The configured CA exists in scope. The verifier consults it zero times.

The pattern's claim, "the signature binds only that the caller could ask for issuance, not that they held any credential," reads here as: the verification binds only that the peer could present bytes on the wire, not that any of those bytes were signed by the configured CA.

What the OVN database is

The CVE-2026-40243 record places the severity at 4.8 because the attacker needs an active position on the management network and the listed impact is C:L/I:L. The position is real: in any deployment where OVN's control plane runs on a flat management VLAN, on an overlay that spans multiple physical hosts, or on a network that any compromised in-cluster workload can reach, an attacker who can answer at the OVN endpoint's address can become the OVN database to Incus. From there, the OVSDB protocol is bidirectional. Incus reads logical switches, routers, port bindings, and ACL state from the database it has decided to trust; Incus writes the same. The trust decision is the entire boundary, and the trust decision was the closure above.

PoC: GHSA-c839-4qxr-j4x3, fix commit lxc/incus@2b7d83a017.

Twenty-eight months in production, four handlers carrying the same closure, a clientCAPool variable in scope of the verifier the whole time. The patch was three lines per file. The variable was always there.