-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The configured CA is parsed, marked as a CA, and never read again\n\n`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.\n\n```go\n// Add CA check if provided.\nif sslCACert != \"\" {\n tlsCAder, _ := pem.Decode([]byte(sslCACert))\n if tlsCAder == nil {\n return nil, errors.New(\"Couldn't parse CA certificate\")\n }\n\n tlsCAcert, err := x509.ParseCertificate(tlsCAder.Bytes)\n if err != nil {\n return nil, err\n }\n\n tlsCAcert.IsCA = true\n tlsCAcert.KeyUsage = x509.KeyUsageCertSign\n\n clientCAPool := x509.NewCertPool()\n clientCAPool.AddCert(tlsCAcert)\n ...\n}\n```\n\n`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.\n\n## The verifier builds its root pool from the peer's chain\n\nThe 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.\n\n```go\ntlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, chains [][]*x509.Certificate) error {\n if len(rawCerts) < 1 {\n return errors.New(\"Missing server certificate\")\n }\n\n // Load the chain.\n roots := x509.NewCertPool()\n for _, rawCert := range rawCerts {\n cert, _ := x509.ParseCertificate(rawCert)\n if cert != nil {\n roots.AddCert(cert)\n }\n }\n\n // Load the main server certificate.\n cert, _ := x509.ParseCertificate(rawCerts[0])\n if cert == nil {\n return errors.New(\"Bad server certificate\")\n }\n\n // Validate.\n opts := x509.VerifyOptions{\n Roots: roots,\n }\n\n _, err := cert.Verify(opts)\n return err\n}\n```\n\n`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.\n\nThe 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.\n\nThe 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.\n\nAny 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.\n\n## The patch is three lines per file\n\nThe 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`:\n\n```diff\n // Load the chain.\n- roots := x509.NewCertPool()\n- for _, rawCert := range rawCerts {\n- cert, _ := x509.ParseCertificate(rawCert)\n- if cert != nil {\n- roots.AddCert(cert)\n+ intermediates := x509.NewCertPool()\n+ if len(rawCerts) > 1 {\n+ for _, rawCert := range rawCerts[1:] {\n+ cert, _ := x509.ParseCertificate(rawCert)\n+ if cert != nil {\n+ intermediates.AddCert(cert)\n+ }\n }\n }\n ...\n // Validate.\n opts := x509.VerifyOptions{\n- Roots: roots,\n+ Roots: clientCAPool,\n+ Intermediates: intermediates,\n }\n```\n\nThe 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.\n\nThree 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.\n\n## Copy-pasted across four handlers\n\n`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.\n\nThe fix commit's stat:\n\n```\n internal/server/network/ovn/ovn_icnb.go | 15 +++++++++------\n internal/server/network/ovn/ovn_icsb.go | 15 +++++++++------\n internal/server/network/ovn/ovn_nb.go | 15 +++++++++------\n internal/server/network/ovn/ovn_sb.go | 15 +++++++++------\n 4 files changed, 36 insertions(+), 24 deletions(-)\n```\n\nThe 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.\n\n## The CA-configured branch did not strengthen the no-CA branch\n\n`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.\n\nWithout 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.\n\nWith 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.\n\nThe 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.\n\n## Caller-chosen-key, in a TLS handshake\n\nThis is [caller-chosen-key](/patterns/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](/posts/nginx-ui-backup-signature-key-on-the-request) reads the HMAC key from the `security_token` form field that ships with the backup, [RestroPress's JWT issuer](/posts/restropress-token-is-issued-not-forged) signs with the `Authorization` header the caller chose, [Unity Catalog's verifier](/posts/unity-catalog-asks-the-token-where-its-key-lives) follows the JWT's `iss` claim to a JWKS the caller hosts. Same shape, three transports.\n\nIncus'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.\n\nThe 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.\n\n## What the OVN database is\n\nThe 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.\n\nPoC: [GHSA-c839-4qxr-j4x3](https://github.com/advisories/GHSA-c839-4qxr-j4x3), fix commit [lxc/incus@2b7d83a017](https://github.com/lxc/incus/commit/2b7d83a0178022a70091d43e6a208d3256af574f).","closing_line":"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.","hook_md":"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.\nTwenty-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.","post_id":330,"slug":"incus-cve-2026-40243-ovn-verifier-trusts-the-cert-it-verifies","title":"CVE-2026-40243: Incus's OVN Verifier Builds Its Root Pool From the Peer It's Verifying","type":"initial","unreadable_sentence":"The variable named `clientCAPool` was in scope of the verifier for twenty-eight months. The verifier did not read it."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaicN+AAKCRDeZjl4jgkQ JmycAP0b5CvNV4FLg0XGAx0EcHah9BVybEbjGoJZp4JrJ3Xq/QD9E2WOBlYSqm1u vGiEmhgJoZjMkrNGOBOQnaJrmFFRkgw= =5oux -----END PGP SIGNATURE-----