Commit 66fdbb1ca5 for openssl.org

commit 66fdbb1ca51bc10ac5fa1a8843365c48f1263df0
Author: Daniel Kubec <kubec@openssl.foundation>
Date:   Tue Oct 20 13:15:46 2026 +0200

    x509: key the duplicate-extension hash with SipHash to resist flooding

    scan_ext_flags() deduplicates certificate extensions. Standard extensions have
    an assigned NID and are handled by an O(1) bitset, but unknown or dynamically
    registered OIDs fall back to an hash table.

    Hash the OID with SipHash under a random key drawn once per scan, so the bucket
    an OID lands in is unpredictable to an attacker. This mirrors the existing
    defense for attacker-controlled keys in ssl/quic/quic_lcidm.c.

    If the table cannot be set up or an insert fails, fail closed with
    EXFLAG_INVALID, as ossl_x509v3_cache_extensions() does for any other extension
    it cannot process. Leaving the loop silently would skip not only the duplicate
    check but also the unsupported-critical check for this and every later
    extension.

    Discovered by Anthropic's OSS Scanner (finding ANT-2026-7UKRZ2MY).

    Assisted-by: Claude:claude-opus-4-8
    Reviewed-by: Neil Horman <nhorman@openssl.org>
    Reviewed-by: Matt Caswell <matt@openssl.foundation>
    Merge-date: Wed Oct 21 15:58:52 2026
    Merged-from: https://github.com/openssl/openssl/pull/33281

diff --git a/crypto/x509/v3_purp.c b/crypto/x509/v3_purp.c
index 9832a98a70..9f627bc375 100644
--- a/crypto/x509/v3_purp.c
+++ b/crypto/x509/v3_purp.c
@@ -16,7 +16,8 @@
 #include "internal/tsan_assist.h"
 #include "x509_local.h"
 #include "crypto/objects/obj_dat.h"
-#include "internal/hashfunc.h"
+#include "crypto/siphash.h"
+#include <openssl/rand.h>

 static int check_ssl_ca(const X509 *x);
 static int check_purpose_ssl_client(const X509_PURPOSE *xp, const X509 *x,
@@ -408,16 +409,44 @@ static int check_sig_alg_match(const EVP_PKEY *issuer_key, const X509 *subject)
     return X509_V_ERR_SIGNATURE_ALGORITHM_MISMATCH;
 }

+/*
+ * Per-scan SipHash keying for the duplicate-extension fall-back table.
+ *
+ * Standard extensions have an assigned NID and are deduplicated with the O(1)
+ * bitset in scan_ext_flags(). Only unknown or dynamically registered OIDs,
+ * whose bytes are fully attacker-controlled on an untrusted certificate, reach
+ * the hash table. An unkeyed hash would let an attacker precompute colliding
+ * OIDs and drive the table into O(n^2) chain walks (hash flooding). Hash the
+ * OID with SipHash under a random key drawn once per scan, mirroring the
+ * defense in ssl/quic/quic_lcidm.c.
+ */
+typedef struct {
+    const ASN1_OBJECT *obj;
+    const uint64_t *key; /* 128-bit SipHash key owned by scan_ext_flags */
+} oid_hte;
+
 static unsigned long oid_hash(const void *p)
 {
-    const ASN1_OBJECT *a = p;
+    const oid_hte *e = (const oid_hte *)p;
+    SIPHASH sip = { 0 };
+    unsigned char digest[SIPHASH_MIN_DIGEST_SIZE];
+    unsigned long v = 0;
+
+    if (!SipHash_set_hash_size(&sip, SIPHASH_MIN_DIGEST_SIZE))
+        return 0;
+    if (!SipHash_Init(&sip, (const unsigned char *)e->key, 0, 0))
+        return 0;
+    SipHash_Update(&sip, (const unsigned char *)e->obj->data, e->obj->length);

-    return (unsigned long)ossl_fnv1a_hash((uint8_t *)a->data, a->length);
+    if (!SipHash_Final(&sip, digest, sizeof(digest)))
+        return 0;
+    memcpy(&v, digest, sizeof(v) < sizeof(digest) ? sizeof(v) : sizeof(digest));
+    return v;
 }

 static int oid_cmp(const void *a, const void *b)
 {
-    return OBJ_cmp((const ASN1_OBJECT *)a, (const ASN1_OBJECT *)b);
+    return OBJ_cmp(((const oid_hte *)a)->obj, ((const oid_hte *)b)->obj);
 }

 /*
@@ -432,11 +461,15 @@ static int oid_cmp(const void *a, const void *b)
 static void scan_ext_flags(const X509 *x509, uint32_t *flags)
 {
     OPENSSL_LHASH *h = NULL;
+    oid_hte *arr = NULL;
+    size_t num_entries = 0;
+    uint64_t k[2]; /* random SipHash key for the fall-back table */
     uint8_t ex_bitset[(NUM_NID + 7) / 8];
+    int count = X509_get_ext_count(x509);

     memset(ex_bitset, 0, sizeof(ex_bitset));
     /* A certificate MUST NOT include more than one instance of an extension. */
-    for (int i = 0; i < X509_get_ext_count(x509); i++) {
+    for (int i = 0; i < count; i++) {
         const X509_EXTENSION *ex = X509_get_ext(x509, i);
         const ASN1_OBJECT *a = X509_EXTENSION_get_object(ex);
         int nid = OBJ_obj2nid(a);
@@ -467,18 +500,38 @@ static void scan_ext_flags(const X509 *x509, uint32_t *flags)
                 break;
             }
             /*
-             * Hashing the OID should be manageable more cheaply as well, and
-             * without additional dynamic allocations. In the case of this
-             * corner case, it’s not a problem at all, but the other duplicate
-             * detections also require hashing, so for the sake of consistency
-             * it would make sense to use a cheaper construct here later as well.
+             * Create the table and draw a fresh random SipHash key the first
+             * time an attacker-controlled OID is seen, so unkeyed hashing
+             * cannot be used to force O(n^2) collisions (hash flooding).
+             *
+             * The key and the entry wrappers remain valid for the duration of
+             * the scan; the table never outlives this function.
+             *
+             * If the table cannot be set up or an insert fails, fail closed
+             * with EXFLAG_INVALID, as ossl_x509v3_cache_extensions() does for
+             * any other extension it cannot process. Leaving the loop
+             * silently would skip not only the duplicate check but also the
+             * unsupported-critical check for this and every later extension.
              */
-            if (h == NULL && (h = OPENSSL_LH_new(oid_hash, oid_cmp)) == NULL)
-                break;
-            if (OPENSSL_LH_insert(h, (void *)a) != NULL) {
+            if (h == NULL) {
+                if (RAND_bytes_ex(x509->libctx, (unsigned char *)k, sizeof(k), 0) <= 0
+                    || (arr = OPENSSL_malloc_array(count, sizeof(*arr))) == NULL
+                    || (h = OPENSSL_LH_new(oid_hash, oid_cmp)) == NULL) {
+                    *flags |= EXFLAG_INVALID;
+                    break;
+                }
+            }
+            arr[num_entries].obj = a;
+            arr[num_entries].key = k;
+            if (OPENSSL_LH_insert(h, &arr[num_entries]) != NULL) {
                 *flags |= EXFLAG_DUPLICATE;
                 break;
             }
+            if (OPENSSL_LH_error(h) != 0) {
+                *flags |= EXFLAG_INVALID;
+                break;
+            }
+            num_entries++;
         }
         if (nid == NID_freshest_crl)
             *flags |= EXFLAG_FRESHEST;
@@ -490,6 +543,7 @@ static void scan_ext_flags(const X509 *x509, uint32_t *flags)
         }
     }
     OPENSSL_LH_free(h);
+    OPENSSL_free(arr);
 }

 #define V1_ROOT (EXFLAG_V1 | EXFLAG_SS)
diff --git a/test/x509_internal_test.c b/test/x509_internal_test.c
index b8c613a493..316baa8cbc 100644
--- a/test/x509_internal_test.c
+++ b/test/x509_internal_test.c
@@ -15,6 +15,8 @@
 #include <openssl/x509.h>
 #include <openssl/x509v3.h>
 #include <openssl/x509_vfy.h>
+#include <openssl/provider.h>
+#include <openssl/rand.h>
 #include "testutil.h"
 #include "internal/cryptlib.h"
 #include "internal/nelem.h"
@@ -1244,6 +1246,58 @@ err:
     return test;
 }

+/*
+ * scan_ext_flags() must not silently stop checking extensions when the
+ * duplicate-extension table cannot be set up. In a library context with no
+ * usable RAND the per-scan SipHash key cannot be drawn; an unknown critical
+ * extension must then still be reported, either as unsupported-critical or by
+ * failing closed with EXFLAG_INVALID. Loading the null provider explicitly
+ * stops the default provider from being activated, so RAND is unavailable.
+ */
+static int tests_x509_check_ext_unknown_critical_no_rand(void)
+{
+    OSSL_LIB_CTX *libctx = NULL;
+    OSSL_PROVIDER *nullprov = NULL;
+    X509 *x = NULL;
+    ASN1_OBJECT *obj = NULL;
+    ASN1_OCTET_STRING *oct = NULL;
+    X509_EXTENSION *ext = NULL;
+    const unsigned char data[] = { 0x00 };
+    unsigned char buf[16];
+    uint32_t flags;
+    int test = 0;
+
+    if (!TEST_ptr(libctx = OSSL_LIB_CTX_new())
+        || !TEST_ptr(nullprov = OSSL_PROVIDER_load(libctx, "null")))
+        goto err;
+    if (RAND_bytes_ex(libctx, buf, sizeof(buf), 0) > 0) {
+        TEST_info("RAND unexpectedly available in a null-provider context");
+        test = 1;
+        goto err;
+    }
+    ERR_clear_error();
+    if (!TEST_ptr(x = X509_new_ex(libctx, NULL))
+        || !TEST_true(X509_set_version(x, X509_VERSION_3))
+        || !TEST_ptr(obj = OBJ_txt2obj("1.3.6.1.4.1.99999.2.1", 1))
+        || !TEST_ptr(oct = ASN1_OCTET_STRING_new())
+        || !TEST_true(ASN1_OCTET_STRING_set(oct, data, sizeof(data)))
+        || !TEST_ptr(ext = X509_EXTENSION_create_by_OBJ(NULL, obj, 1, oct))
+        || !TEST_true(X509_add_ext(x, ext, -1)))
+        goto err;
+    flags = X509_get_extension_flags(x);
+    if (!TEST_true((flags & (EXFLAG_CRITICAL | EXFLAG_INVALID)) != 0))
+        goto err;
+    test = 1;
+err:
+    X509_EXTENSION_free(ext);
+    ASN1_OCTET_STRING_free(oct);
+    ASN1_OBJECT_free(obj);
+    X509_free(x);
+    OSSL_PROVIDER_unload(nullprov);
+    OSSL_LIB_CTX_free(libctx);
+    return test;
+}
+
 /*
  * X509_ATTRIBUTE_create_by_NID() must accept a BIT STRING value supplied as
  * raw bytes plus an explicit length, as PKCS8_add_keyusage() does for
@@ -1439,6 +1493,7 @@ int setup_tests(void)
     ADD_TEST(tests_x509_check_ext_duplicity);
     ADD_TEST(tests_x509_check_ext_duplicity_nid_undef);
     ADD_TEST(tests_x509_check_ext_duplicity_nid_dynamic);
+    ADD_TEST(tests_x509_check_ext_unknown_critical_no_rand);
     ADD_ALL_TESTS(test_x509_attribute_bit_string, 2);

     ADD_TEST(test_X509_ALGOR_set_md_sha1);