ANT-2026-2FETJP49 · randombit/botan
improper-cert-validation high
CVE-2026-34580 GHSA-v782-6fq4-q827
Severity Claude high · Security research firm - · Maintainer -
Anthropic's analysis of this finding, sealed at approval.
ANT-2026-2FETJP49: [security] certificate_known(): Complete x509 bypass
Certificate_Store::certificate_known() calls find_cert(DN, SKI) and returns true if any result exists, discarding the returned object instead of comparing it to the input certificate. DN and SKI are non-cryptographic, attacker-chosen fields (and find_cert falls back to DN-only when SKI is absent). The path builder (x509path.cpp:691) calls this on the attacker-supplied end-entity cert and, on a match, emits a length-1 chain containing only the attacker's cert. All downstream checks (signature, CA constraint, hostname, revocation, name constraints) then validate the attacker's cert against itself, so the real root's public key is never consulted. A MITM attacker can therefore forge a certificate for any hostname that any default Botan TLS client will accept.
Target
Project: randombit/botan
Version: HEAD (commit 70da513, 2026-03-26)
Location: src/lib/x509/certstor.h:80-82 (Certificate_Store::certificate_known); src/lib/x509/x509path.cpp:691-693 (build_all_certificate_paths)
Technical Details
certificate_known() answers "does the store contain some cert with this DN+SKI?" instead of "does the store contain this cert?" — it checks only find_cert(...).has_value() and never compares the found certificate's key/bytes to the input. Because DN and SKI are freely chosen by the certificate creator (RFC 5280 §4.2.1.2: SKI is a path-construction hint, not required to be verified), an attacker can copy a known root's DN+SKI onto a self-signed cert and be treated as that trust anchor.
Reproduction
- Pick any root CA in the victim's trust store.
- Copy its Subject DN and SubjectKeyIdentifier into a new certificate (or omit SKI to collide on DN alone).
- Set SAN=DNS: and self-sign with a fresh keypair.
- Present this certificate as the server cert from a MITM position.
- Botan's path builder marks it as a known trust anchor, builds a length-1 chain, and all checks (self-signature, CA, hostname, revocation, name constraints) pass against the attacker's own cert.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
In certificate_known(), compare the retrieved certificate to the input using X509_Certificate::operator== (which compares signature, sig algorithm, and TBS bytes): auto found = find_cert(cert.subject_dn(), cert.subject_key_id()); return found.has_value() && (*found == cert);. Alternatively, have build_all_certificate_paths insert the store's certificate object into the chain instead of the attacker-supplied one. The method is non-virtual, so fixing the base class covers all backends (In_Memory, Flatfile, System, Windows, MacOS, In_SQL).
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-2FETJP49.
Reference: ANT-2026-2FETJP49
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
Dates from discovery through public reveal.
- 2026-03-27 Sent to maintainer
- 2026-03-27 Maintainer acknowledged
- 2026-04-06 Patch released
- 2026-05-14 Reported to tracker
- 2026-06-02 Publicly revealed
SHA-3-512 hash:
bb69f26dbf3aa6b9daedc114ff293e1a77d8bdc3d816b35f7d853d3a3025c77dc796801458bc85a0b0b93bbd74034a64521b578efde757910291d5d21aae843c
Committed 2026-03-28 09:56 PT
Revealed 2026-06-02 11:00 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-2FETJP49",
"bug_class": "Improper Certificate Validation",
"claude_severity": "high",
"commit_sha": null,
"created_at": "2026-05-14T22:41:48+00:00",
"description": "Certificate_Store::certificate_known() calls find_cert(DN, SKI) and returns true if any result exists, discarding the returned object instead of comparing it to the input certificate. DN and SKI are non-cryptographic, attacker-chosen fields (and find_cert falls back to DN-only when SKI is absent). The path builder (x509path.cpp:691) calls this on the attacker-supplied end-entity cert and, on a match, emits a length-1 chain containing only the attacker's cert. All downstream checks (signature, CA constraint, hostname, revocation, name constraints) then validate the attacker's cert against itself, so the real root's public key is never consulted. A MITM attacker can therefore forge a certificate for any hostname that any default Botan TLS client will accept.",
"discovered_at": null,
"location": "src/lib/x509/certstor.h:80-82 (Certificate_Store::certificate_known); src/lib/x509/x509path.cpp:691-693 (build_all_certificate_paths)",
"poc_sha256": null,
"preimage_version": 1,
"project": "randombit/botan",
"reproduction": [
"1. Pick any root CA in the victim's trust store.",
"2. Copy its Subject DN and SubjectKeyIdentifier into a new certificate (or omit SKI to collide on DN alone).",
"3. Set SAN=DNS:<target-hostname> and self-sign with a fresh keypair.",
"4. Present this certificate as the server cert from a MITM position.",
"5. Botan's path builder marks it as a known trust anchor, builds a length-1 chain, and all checks (self-signature, CA, hostname, revocation, name constraints) pass against the attacker's own cert."
],
"technical_details": "Hi Jack --\n\nI'm a security researcher at Anthropic. I believe I've found a\n\ncertificate-verification bypass in Botan's X.509 path validator that\n\nlets a network attacker forge a certificate that any default Botan TLS\n\nclient accepts for any hostname. I've attached a demo that crafts a\n\nself-signed cert with a copied DN+SKI and connects with Botan's own\n\ncert_verify tool; OpenSSL rejects the same chain.\n\n(Up-front disclosure: an LLM found this bug. I validated it myself\n\nagainst Botan HEAD. I stand by this report personally and vouch for\n\nits correctness.)\n\nThe bug is in Certificate_Store::certificate_known():\n\n // src/lib/x509/certstor.h:80-82\n\n bool certificate_known(const X509_Certificate& cert) const {\n\n return find_cert(cert.subject_dn(), cert.subject_key_id()).has_value();\n\n }\n\nThe method answers the question \"does the store contain some cert with\n\nthis DN+SKI?\" rather than the correct question \"does the store contain\n\n*this* cert?\". It discards the certificate that find_cert() returns\n\nand only checks .has_value(). DN and SKI are both non-cryptographic\n\nfields the certificate holder chooses freely---RFC 5280 section\n\n4.2.1.2 describes SKI as a means \"to facilitate certification path\n\nconstruction\" and states that \"applications are not required to verify\n\nthat key identifiers match when performing certification path\n\nvalidation.\"\n\nfind_cert() itself (certstor.cpp:57-75) makes this worse by falling back\n\nto DN-only when the caller's SKI is empty:\n\n if(!key_id.empty()) {\n\n const std::vector<uint8_t>& skid = cert.subject_key_id();\n\n if(!skid.empty() && skid != key_id) { continue; }\n\n }\n\n if(cert.subject_dn() == subject_dn) { return cert; }\n\nSo an attacker who omits the SKI extension entirely collides on DN\n\nalone.\n\nThe consuming site is the path builder:\n\n // src/lib/x509/x509path.cpp:691-693\n\n auto cert_in_any_trusted_store = [&](const X509_Certificate& cert) {\n\n return std::ranges::any_of(trusted_certstores,\n\n [&](const Certificate_Store* store) {\n\n return store->certificate_known(cert); });\n\n };\n\nThis lambda is called on the end-entity certificate -- the first cert\n\nin the server-supplied chain. Every every byte of this certificate is,\n\nby construction, attacker-controlled. Once it returns true, the DFS\n\npath builder emits a length-1 chain of just the attacker's cert as a\n\nvalid path.\n\nAll of the downstream check then passes, because the attacker's cert is\n\nvalidated against itself:\n\n - Signature verification: at_trust_anchor=true so issuer=cert_path[0]\n\n (the attacker's cert). Verifies attacker's self-signature against\n\n attacker's own public key. Passes.\n\n - CA constraint: is_end_entity_trust_anchor=true for length-1 chains,\n\n so the check short-circuits.\n\n - Hostname: checked against cert_path[0]'s SAN -- which the attacker\n\n set to the target hostname.\n\n - Revocation: the trust-anchor loop runs chain_status.size()-1\n\n iterations; for size 1 that's zero.\n\n - Name constraints: issuer is the attacker's own cert; the real root's\n\n constraints are never loaded.\n\nThe real trust anchor's public key is never read into memory during\n\nvalidation.\n\nThe attack, then, is trivial:\n\n 1. Pick any root CA in the victim's trust store (Mozilla bundle,\n\n system store -- all public).\n\n 2. Copy its Subject DN and SubjectKeyIdentifier into a new cert.\n\n 3. Set SAN=DNS:target-hostname, self-sign with a fresh keypair.\n\n 4. Present as the server certificate from a MITM position.\n\nRunning the attached PoC against Botan HEAD (70da513, 2026-03-26):\n\n $ ./botan cert_verify forged_ee.pem trust_anchor.pem\n\n Certificate passes validation checks\n\n $ openssl verify -CAfile trust_anchor.pem forged_ee.pem\n\n CN = Acme Corporate Root CA, O = Acme Corp\n\n error 18 at 0 depth lookup: self-signed certificate\n\n error forged_ee.pem: verification failed\n\nThe certificates share DN and SKI but have completely different keys:\n\n Trust anchor pubkey SHA-256: 1367c23ab13fe3a1fc498837...\n\n Forged EE pubkey SHA-256: 7fe67b5cad53213f79bd1a7d...\n\nThe fix is one line. X509_Certificate::operator== already compares\n\nsignature, signature algorithm, and tbs bytes (x509cert.cpp:696-699):\n\n bool certificate_known(const X509_Certificate& cert) const {\n\n auto found = find_cert(cert.subject_dn(), cert.subject_key_id());\n\n return found.has_value() && (*found == cert);\n\n }\n\nAlternatively, build_all_certificate_paths could place the store's\n\ncertificate object into the chain rather than the attacker-supplied one,\n\nso downstream checks use the trusted public key. Either closes the hole;\n\nthe certificate_known() change is minimal. Patch attached.\n\nThe method is non-virtual and inherited unchanged by every\n\nCertificate_Store backend (In_Memory, Flatfile, System, Windows, MacOS,\n\nIn_SQL), so the fix at the base class covers all of them.\n\nPlease let me know if you have any questions.\n\nNicholas\n\nYou can reproduce this on any machine with Botan built:\n\n # Build Botan (if needed)\n\n git clone --depth 1 https://github.com/randombit/botan.git\n\n cd botan && python3 configure.py --build-targets=static,cli && make -j\n\n # Generate the PoC certs (or use the attached .pem files)\n\n bash gen_certs.sh\n\n # Botan accepts the forged cert\n\n ./botan cert_verify forged_ee.pem trust_anchor.pem\n\n # -> Certificate passes validation checks\n\n # OpenSSL correctly rejects it\n\n openssl verify -CAfile trust_anchor.pem forged_ee.pem\n\n # -> error 18 at 0 depth lookup: self-signed certificate\n\n # Control: unrelated DN is correctly rejected by Botan too\n\n ./botan cert_verify control_ee.pem trust_anchor.pem\n\n # -> Certificate did not validate - Cannot establish trust",
"title": "[security] certificate_known(): Complete x509 bypass",
"vendor_severity": null
}