Commit 25c2f88caa for openssl.org

commit 25c2f88caaaa53db5f116206207d1d760c9d2c53
Author: Daniel Kubec <kubec@openssl.org>
Date:   Mon Mar 2 17:56:52 2026 +0100

    x509: add EXFLAG_DUPLICATE and cheap O(1) extension duplicate check

    In ossl_x509v3_cache_extensions(), introduce EXFLAG_DUPLICATE flag to
    signal duplicate X.509 extensions. Add O(1) duplicate detection
    using a bitset with minimal stack memory footprint, in compliance with
    RFC 5280 Section 4.2.

    Fixes #26325

    Co-authored-by: Tomáš Mráz <tm@t8m.info>
    Co-authored-by: David von Oheimb <DDvO@users.noreply.github.com>

    Reviewed-by: Tomas Mraz <tomas@openssl.org>
    Reviewed-by: Simo Sorce <simo@redhat.com>
    Reviewed-by: Paul Dale <paul.dale@oracle.com>
    Reviewed-by: Neil Horman <nhorman@openssl.org>
    MergeDate: Tue Mar 17 13:43:13 2026
    (Merged from https://github.com/openssl/openssl/pull/30233)

diff --git a/CHANGES.md b/CHANGES.md
index 24b68d6d06..88b6a0ebde 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -55,6 +55,12 @@ OpenSSL Releases

    *Nikolas Gauder*

+ * Add new verification error `X509_V_ERR_DUPLICATE_EXTENSION` with descriptive
+   message for certificates containing duplicate X.509 extensions, which are
+   explicitly prohibited by [RFC 5280].
+
+   *Daniel Kubec*
+
 ### Changes between 3.6 and 4.0 [xx XXX xxxx]

  * Added `-expected-rpks` option to the `openssl s_client`
diff --git a/crypto/x509/v3_purp.c b/crypto/x509/v3_purp.c
index 7b548e32f5..df1f0ae2d0 100644
--- a/crypto/x509/v3_purp.c
+++ b/crypto/x509/v3_purp.c
@@ -15,6 +15,8 @@
 #include "crypto/x509.h"
 #include "internal/tsan_assist.h"
 #include "x509_local.h"
+#include "crypto/objects/obj_dat.h"
+#include "internal/hashfunc.h"

 static int check_ssl_ca(const X509 *x);
 static int check_purpose_ssl_client(const X509_PURPOSE *xp, const X509 *x,
@@ -420,6 +422,90 @@ static int check_sig_alg_match(const EVP_PKEY *issuer_key, const X509 *subject)
     return X509_V_ERR_SIGNATURE_ALGORITHM_MISMATCH;
 }

+static unsigned long oid_hash(const void *p)
+{
+    const ASN1_OBJECT *a = p;
+
+    return (unsigned long)ossl_fnv1a_hash((uint8_t *)a->data, a->length);
+}
+
+static int oid_cmp(const void *a, const void *b)
+{
+    return OBJ_cmp((const ASN1_OBJECT *)a, (const ASN1_OBJECT *)b);
+}
+
+/*
+ * Scan all extensions of a certificate to collect extension-related flags.
+ * Detects duplicate extensions (RFC 5280 section 4.2), the presence of a
+ * freshest CRL extension and unsupported critical extensions.
+ *
+ * In the future, if needed, this scanning function could return the index
+ * of the offending extension on error, allowing the caller to identify which
+ * extension caused the problem and report it via ERR_raise_data().
+ */
+static void scan_ext_flags(const X509 *x509, uint32_t *flags)
+{
+    OPENSSL_LHASH *h = NULL;
+    uint8_t ex_bitset[(NUM_NID + 7) / 8];
+
+    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++) {
+        const X509_EXTENSION *ex = X509_get_ext(x509, i);
+        const ASN1_OBJECT *a = X509_EXTENSION_get_object(ex);
+        int nid = OBJ_obj2nid(a);
+
+        /*
+         * Known NIDs within the build-time bitset limit are checked for
+         * duplicates in constant time. Unknown OIDs and dynamically registered
+         * NIDs that exceed the limit fall back to duplicate detection via a
+         * hash table.
+         */
+        if (nid > NID_undef && nid < NUM_NID) {
+            unsigned int ex_bit = nid;
+
+            if ((ex_bitset[ex_bit >> 3] & (1u << (ex_bit & 7))) != 0) {
+                *flags |= EXFLAG_DUPLICATE;
+                break;
+            }
+            ex_bitset[ex_bit >> 3] |= (1u << (ex_bit & 7));
+        } else {
+            /*
+             * Extensions with unknown NID (NID_undef) and dynamically
+             * registered NIDs are handled here by hashing the OID (data/length).
+             * A zero-length OID should not reach this point, but we check for
+             * it anyway and assign the EXFLAG_INVALID flag if it does.
+             */
+            if (a->length < 1) {
+                *flags |= EXFLAG_INVALID;
+                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.
+             */
+            if (h == NULL && (h = OPENSSL_LH_new(oid_hash, oid_cmp)) == NULL)
+                break;
+            if (OPENSSL_LH_insert(h, (void *)a) != NULL) {
+                *flags |= EXFLAG_DUPLICATE;
+                break;
+            }
+        }
+        if (nid == NID_freshest_crl)
+            *flags |= EXFLAG_FRESHEST;
+        if (!X509_EXTENSION_get_critical(ex))
+            continue;
+        if (!X509_supported_extension(ex)) {
+            *flags |= EXFLAG_CRITICAL;
+            break;
+        }
+    }
+    OPENSSL_LH_free(h);
+}
+
 #define V1_ROOT (EXFLAG_V1 | EXFLAG_SS)
 #define ku_reject(x, usage) \
     (((x)->ex_flags & EXFLAG_KUSAGE) != 0 && ((x)->ex_kusage & (usage)) == 0)
@@ -660,19 +746,7 @@ int ossl_x509v3_cache_extensions(const X509 *const_x)
         tmp_ex_flags |= EXFLAG_INVALID;
 #endif

-    for (i = 0; i < X509_get_ext_count(const_x); i++) {
-        const X509_EXTENSION *ex = X509_get_ext(const_x, i);
-        int nid = OBJ_obj2nid(X509_EXTENSION_get_object(ex));
-
-        if (nid == NID_freshest_crl)
-            tmp_ex_flags |= EXFLAG_FRESHEST;
-        if (!X509_EXTENSION_get_critical(ex))
-            continue;
-        if (!X509_supported_extension(ex)) {
-            tmp_ex_flags |= EXFLAG_CRITICAL;
-            break;
-        }
-    }
+    scan_ext_flags(const_x, &tmp_ex_flags);

     /* Set x->siginf, ignoring errors due to unsupported algos */
     (void)ossl_x509_init_sig_info(const_x, &tmp_siginf);
diff --git a/crypto/x509/x509_txt.c b/crypto/x509/x509_txt.c
index 1b14957bf0..3a77e52f32 100644
--- a/crypto/x509/x509_txt.c
+++ b/crypto/x509/x509_txt.c
@@ -227,6 +227,8 @@ const char *X509_verify_cert_error_string(long n)
         return "Empty Authority Key Identifier";
     case X509_V_ERR_AKID_ISSUER_SERIAL_NOT_PAIRED:
         return "Authority Key Identifier issuer and serial number must be paired";
+    case X509_V_ERR_DUPLICATE_EXTENSION:
+        return "Certificate includes more than one instance of a particular extension";

         /*
          * Entries must be kept consistent with include/openssl/x509_vfy.h.in
diff --git a/crypto/x509/x509_vfy.c b/crypto/x509/x509_vfy.c
index 5c3d4619c9..c2c39a1e49 100644
--- a/crypto/x509/x509_vfy.c
+++ b/crypto/x509/x509_vfy.c
@@ -625,6 +625,9 @@ static int check_extensions(X509_STORE_CTX *ctx)

     for (i = 0; i < num; i++) {
         x = sk_X509_value(ctx->chain, i);
+        /* RFC 5280, 4.2: a given extension MUST NOT appear more than once */
+        CB_FAIL_IF((x->ex_flags & EXFLAG_DUPLICATE) != 0,
+            ctx, x, i, X509_V_ERR_DUPLICATE_EXTENSION);
         CB_FAIL_IF((ctx->param->flags & X509_V_FLAG_IGNORE_CRITICAL) == 0
                 && (x->ex_flags & EXFLAG_CRITICAL) != 0,
             ctx, x, i, X509_V_ERR_UNHANDLED_CRITICAL_EXTENSION);
diff --git a/doc/man1/openssl-verification-options.pod b/doc/man1/openssl-verification-options.pod
index 9acff1b4d4..2fd0881fa2 100644
--- a/doc/man1/openssl-verification-options.pod
+++ b/doc/man1/openssl-verification-options.pod
@@ -670,6 +670,13 @@ it checks that any present EKU extension (that does not contain
 B<anyExtendedKeyUsage>) contains the respective EKU as detailed below.
 Moreover, it does these checks even for trust anchor certificates.

+=head3 Duplicate Extensions
+
+According to RFC 5280 section 4.2, a certificate MUST NOT include more
+than one instance of a particular extension. If a duplicate extension is
+detected, the certificate is rejected.
+This check applies to all certificates: end-entity certificates, intermediate CA certificates, and root CA (trust anchor) certificates.
+
 =head3 Checks Implied by Specific Predefined Policies

 A specific description of each check is given below. The comments about
diff --git a/doc/man3/X509_STORE_CTX_get_error.pod b/doc/man3/X509_STORE_CTX_get_error.pod
index 63bbd823b6..dd45b9ef89 100644
--- a/doc/man3/X509_STORE_CTX_get_error.pod
+++ b/doc/man3/X509_STORE_CTX_get_error.pod
@@ -518,6 +518,10 @@ Authority Key Identifier has no attributes.

 Authority Key Identifier issuer and serial number must be paired.

+=item B<X509_V_ERR_DUPLICATE_EXTENSION: extension MUST NOT appear more than once>
+
+Certificate includes more than one instance of a particular extension.
+
 =back

 =head1 NOTES
diff --git a/include/openssl/x509_vfy.h.in b/include/openssl/x509_vfy.h.in
index 8672d28c23..e6d19ff14c 100644
--- a/include/openssl/x509_vfy.h.in
+++ b/include/openssl/x509_vfy.h.in
@@ -337,6 +337,8 @@ void X509_STORE_CTX_set_depth(X509_STORE_CTX *ctx, int depth);
 #define X509_V_ERR_EMPTY_AUTHORITY_KEY_IDENTIFIER 102
 #define X509_V_ERR_AKID_ISSUER_SERIAL_NOT_PAIRED 103

+#define X509_V_ERR_DUPLICATE_EXTENSION 104
+
 /* Certificate verify flags */
 #ifndef OPENSSL_NO_DEPRECATED_1_1_0
 #define X509_V_FLAG_CB_ISSUER_CHECK 0x0 /* Deprecated */
diff --git a/include/openssl/x509v3.h.in b/include/openssl/x509v3.h.in
index 6eb86273ce..4a0365f380 100644
--- a/include/openssl/x509v3.h.in
+++ b/include/openssl/x509v3.h.in
@@ -455,6 +455,9 @@ struct ISSUING_DIST_POINT_st {
 #define EXFLAG_SKID_CRITICAL 0x40000
 #define EXFLAG_SAN_CRITICAL 0x80000

+/* A certificate MUST NOT include more than one instance of an extension. */
+#define EXFLAG_DUPLICATE 0x200000
+
 /* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3 */
 #define KU_DIGITAL_SIGNATURE X509v3_KU_DIGITAL_SIGNATURE
 #define KU_NON_REPUDIATION X509v3_KU_NON_REPUDIATION
diff --git a/test/x509_internal_test.c b/test/x509_internal_test.c
index 8fbd3304bb..19a7c2469d 100644
--- a/test/x509_internal_test.c
+++ b/test/x509_internal_test.c
@@ -688,6 +688,257 @@ err:
     return test;
 }

+/* https://github.com/openssl/openssl/issues/26325 */
+static const char *kRootExtensionDuplicity[] = {
+    "-----BEGIN CERTIFICATE-----\n",
+    "MIIDhDCCAmygAwIBAgIDCxQYMA0GCSqGSIb3DQEBCwUAMHoxCzAJBgNVBAYTAlVO\n",
+    "MQ8wDQYDVQQIDAZNeSBTVDExFTATBgNVBAcMDE1ZIExvY2FsaXR5MTEUMBIGA1UE\n",
+    "CgwLTVkgQ29tcGFueTExETAPBgNVBAsMCE15IFVuaXQxMRowGAYDVQQDDBF3d3cu\n",
+    "bXljb21wYW55LmNvbTAeFw0xOTA2MTkwODU1NTlaFw0yOTA2MTkwODU1NTlaMHox\n",
+    "CzAJBgNVBAYTAlVOMQ8wDQYDVQQIDAZNeSBTVDExFTATBgNVBAcMDE1ZIExvY2Fs\n",
+    "aXR5MTEUMBIGA1UECgwLTXkgQ29tcGFueTExETAPBgNVBAsMCE15IFVuaXQxMRow\n",
+    "GAYDVQQDDBF3d3cubXljb21wYW55LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEP\n",
+    "ADCCAQoCggEBALVNKQrEfNWp3s0FOW+9RAjXOMvhAprV/FsWo6M72Mq/EwaV4Ny+\n",
+    "Q2CZ2Bs09KmRw43RG4dHHkB5/ewE7HhohQcHVH+tcWrM0IdgQIzKva2vICFZkp6O\n",
+    "am71qSe8+qtLSkzlTYJv4oeTLmMA2SSwTTP74hB29MS6O8scaLcM+OqfaGzr6k/Z\n",
+    "GnMMjI/zf4rbrLGPJcGGZ4jIMkrYm1PnwAwg6ijXrU0kb8DBgVvpmrluYfQdBvy6\n",
+    "bSib3P9ckyCGqqszn50qQZqqa2n6Ol/CBwRsCuYuhazRsBcXiULQ1lv2JQG86ILb\n",
+    "h/SXXfB4A6p0ti3tmcTMIPN5AI3y/EvUUwkCAwEAAaMTMBEwDwYDVR0TAQH/BAUw\n",
+    "AwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAS6joG5vUo2kMLX0bcpjKzE3h40ZypVgJ\n",
+    "bSCLu/alVcIDzdLTK/SOp2NMvtGmn+BMRvfzW+Lk58sMZ2QC3x+RZKHV+pDsT+Lj\n",
+    "Zi1bhpvtzrN62PmYZXGTu0xPME3SlBLilUFIRgH5lrxzlBdRURMCbHJOblAfzVdw\n",
+    "EBCtDVdGcox/mzu1Jo/sJQb59a49ZQpvwp7m7kZE0q6dBgElYX4JaRYhbwsv/tP2\n",
+    "jEA+jQYNORgFvCOkITbaO4Avc7BXSCGkDHoH6GsANf0bdtaMQCbUMeaC2CYUzRoC\n",
+    "fTEJ9LvFu7syeEDpUbgPXgRqpUQLyxoVYxWjXZ3CG5jeRmJzAEAoyg==\n",
+    "-----END CERTIFICATE-----\n",
+    NULL
+};
+
+static const char *kCertExtensionDuplicity[] = {
+    "-----BEGIN CERTIFICATE-----\n",
+    "MIIENDCCAxygAwIBAgIVASygDp5oUEVMj9bx6z7bj3ce5ypQMA0GCSqGSIb3DQEB\n",
+    "CwUAMHoxCzAJBgNVBAYTAlVOMQ8wDQYDVQQIDAZNeSBTVDExFTATBgNVBAcMDE1Z\n",
+    "IExvY2FsaXR5MTEUMBIGA1UECgwLTXkgQ29tcGFueTExETAPBgNVBAsMCE15IFVu\n",
+    "aXQxMRowGAYDVQQDDBF3d3cubXljb21wYW55LmNvbTAiGA8yMDE5MDYxOTA4NTU1\n",
+    "OVoYDzIwMjkwNjE5MDg1NTU5WjB7MQswCQYDVQQGEwJVTjEPMA0GA1UECAwGTXkg\n",
+    "U1QxMRUwEwYDVQQHDAxNWSBMb2NhbGl0eTExFDASBgNVBAoMC015IENvbXBhbnkx\n",
+    "MREwDwYDVQQLDAhNeSBVbml0MTEbMBkGA1UEAwwSd3d3Lm15Y29tcGFueTEuY29t\n",
+    "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtU0pCsR81anezQU5b71E\n",
+    "CNc4y+ECmtX8WxajozvYyr8TBpXg3L5DYJnYGzT0qZHDjdEbh0ceQHn97ATseGiF\n",
+    "BwdUf61xaszQh2BAjMq9ra8gIVmSno5qbvWpJ7z6q0tKTOVNgm/ih5MuYwDZJLBN\n",
+    "M/viEHb0xLo7yxxotwz46p9obOvqT9kacwyMj/N/itussY8lwYZniMgyStibU+fA\n",
+    "DCDqKNetTSRvwMGBW+mauW5h9B0G/LptKJvc/1yTIIaqqzOfnSpBmqprafo6X8IH\n",
+    "BGwK5i6FrNGwFxeJQtDWW/YlAbzogtuH9Jdd8HgDqnS2Le2ZxMwg83kAjfL8S9RT\n",
+    "CQIDAQABo4GrMIGoMFIGCCsGAQUFBwELBEYwRDBCBggrBgEFBQcwBYY2ZnRwOi8v\n",
+    "NjYuMjMzLjIuMjM1L2Z8M2YvTUI5JT94dV89WEdlYXxIMFgmcTRpRm1YIXs9dS89\n",
+    "MFIGCCsGAQUFBwELBEYwRDBCBggrBgEFBQcwBYY2ZnRwOi8vNjouMjMzLjIuMjM1\n",
+    "L2Z8M2YvTUI5JT94dV89WEdlYXxIMFgmcTRpRm1YIXs9dS89MA0GCSqGSIb3DQEB\n",
+    "CwUAA4IBAQCoLSlKFFlg2xSGf9PFrXayO9ODk4pUkzb/+u0fsf6Vekwo/0dFNxSM\n",
+    "1sPtfoyprGMd7DK8R0rELq7k4+TaypV1JFBj9G9///dCTdX8Fg1SMRamIY0cs8Cu\n",
+    "VJCPWpLD6RQzZm9WkUqcc1yhjW8eO7OABazKwFQBLRS97ocztbyNvPbsZ0xInSMV\n",
+    "7E3xOj4XeibJ2y+EHUbMRDPtwZuy+E1m/kYScLAqIweVaxrWQnCC1HcARxL6eHx9\n",
+    "8kzGS23XAT9jLvdxwNs23GXiAjzxifJmR7oujP+uALF+FfHdJb7vr6l8lVNzRnDH\n",
+    "utv/6BamvgrYfDmA6GO3UItEgYozDtaN\n",
+    "-----END CERTIFICATE-----\n",
+    NULL
+};
+
+/*
+ * The following test checks for a duplicate extension with a known build-time
+ * NID, which is detected in constant time.
+ * */
+static int tests_x509_check_ext_duplicity(void)
+{
+    X509 *root = NULL, *leaf = NULL;
+    X509_STORE_CTX *ctx = NULL;
+    X509_STORE *store = NULL;
+    X509_VERIFY_PARAM *param = NULL;
+    STACK_OF(X509) *x509s = NULL;
+    const time_t verify_time = 1753284700; /* July 23th, 2025 */
+    int test;
+
+    test = TEST_ptr(ctx = X509_STORE_CTX_new())
+        && TEST_ptr(store = X509_STORE_new())
+        && TEST_ptr(param = X509_VERIFY_PARAM_new())
+        && TEST_ptr(x509s = sk_X509_new_null())
+        && TEST_ptr((root = X509_from_strings(kRootExtensionDuplicity)))
+        && TEST_ptr((leaf = X509_from_strings(kCertExtensionDuplicity)))
+        && TEST_true(X509_STORE_CTX_init(ctx, store, leaf, NULL));
+
+    if (test != 1)
+        goto err;
+    if (!TEST_true(sk_X509_push(x509s, root)))
+        goto err;
+    root = NULL;
+
+    X509_STORE_CTX_set0_trusted_stack(ctx, x509s);
+    X509_VERIFY_PARAM_set_depth(param, 16);
+    X509_VERIFY_PARAM_set_time(param, verify_time);
+    X509_STORE_CTX_set0_param(ctx, param);
+    param = NULL;
+    ERR_clear_error();
+
+    test = TEST_int_eq(X509_verify_cert(ctx), 0)
+        && TEST_int_eq(X509_STORE_CTX_get_error(ctx),
+            X509_V_ERR_DUPLICATE_EXTENSION);
+
+err:
+    OSSL_STACK_OF_X509_free(x509s);
+    X509_VERIFY_PARAM_free(param);
+    X509_STORE_CTX_free(ctx);
+    X509_STORE_free(store);
+    X509_free(leaf);
+    X509_free(root);
+
+    return test;
+}
+
+/*
+ * This test checks for a duplicate extension with an undefined NID, where the
+ * duplicate is detected via OID.
+ */
+static int tests_x509_check_ext_duplicity_nid_undef(void)
+{
+    X509 *root = NULL, *leaf = NULL;
+    X509_STORE_CTX *ctx = NULL;
+    X509_STORE *store = NULL;
+    X509_VERIFY_PARAM *param = NULL;
+    STACK_OF(X509) *x509s = NULL;
+    ASN1_OBJECT *obj1 = NULL, *obj2 = NULL;
+    ASN1_OCTET_STRING *oct1 = NULL, *oct2 = NULL;
+    X509_EXTENSION *ext1 = NULL, *ext2 = NULL;
+    const unsigned char data[] = { 0x04, 0x03, 0x41, 0x42, 0x43 };
+    const time_t verify_time = 1753284700; /* July 23th, 2025 */
+    const char *unknown_oid = "1.2.3.4.5.6.7.8.9";
+    int test;
+
+    test = TEST_ptr(ctx = X509_STORE_CTX_new())
+        && TEST_ptr(store = X509_STORE_new())
+        && TEST_ptr(param = X509_VERIFY_PARAM_new())
+        && TEST_ptr(x509s = sk_X509_new_null())
+        && TEST_ptr((root = X509_from_strings(kRootMendelsonAKIDKeyNULL)))
+        && TEST_ptr((leaf = X509_from_strings(kLeafMendelsonAKIDKeyNULL)))
+        && TEST_true(X509_STORE_CTX_init(ctx, store, leaf, NULL))
+        && TEST_ptr(obj1 = OBJ_txt2obj(unknown_oid, 1))
+        && TEST_ptr(oct1 = ASN1_OCTET_STRING_new())
+        && TEST_int_eq(ASN1_OCTET_STRING_set(oct1, data, sizeof(data)), 1)
+        && TEST_ptr(ext1 = X509_EXTENSION_create_by_OBJ(NULL, obj1, 0, oct1))
+        && TEST_int_eq(X509_add_ext(leaf, ext1, -1), 1)
+        && TEST_ptr(obj2 = OBJ_txt2obj(unknown_oid, 1))
+        && TEST_ptr(oct2 = ASN1_OCTET_STRING_new())
+        && TEST_int_eq(ASN1_OCTET_STRING_set(oct2, data, sizeof(data)), 1)
+        && TEST_ptr(ext2 = X509_EXTENSION_create_by_OBJ(NULL, obj2, 0, oct2))
+        && TEST_int_eq(X509_add_ext(leaf, ext2, -1), 1);
+
+    if (test != 1)
+        goto err;
+    if (!TEST_true(sk_X509_push(x509s, root)))
+        goto err;
+    root = NULL;
+
+    X509_STORE_CTX_set0_trusted_stack(ctx, x509s);
+    X509_VERIFY_PARAM_set_depth(param, 16);
+    X509_VERIFY_PARAM_set_time(param, verify_time);
+    X509_STORE_CTX_set0_param(ctx, param);
+    param = NULL;
+    ERR_clear_error();
+
+    test = TEST_int_eq(X509_verify_cert(ctx), 0)
+        && TEST_int_eq(X509_STORE_CTX_get_error(ctx),
+            X509_V_ERR_DUPLICATE_EXTENSION);
+
+err:
+    ASN1_OBJECT_free(obj1);
+    ASN1_OCTET_STRING_free(oct1);
+    X509_EXTENSION_free(ext1);
+    ASN1_OBJECT_free(obj2);
+    ASN1_OCTET_STRING_free(oct2);
+    X509_EXTENSION_free(ext2);
+    OSSL_STACK_OF_X509_free(x509s);
+    X509_VERIFY_PARAM_free(param);
+    X509_STORE_CTX_free(ctx);
+    X509_STORE_free(store);
+    X509_free(leaf);
+    X509_free(root);
+
+    return test;
+}
+
+/*
+ * This test checks for a duplicate extension with a dynamically registered NID,
+ * where the duplicate is detected via OID.
+ */
+static int tests_x509_check_ext_duplicity_nid_dynamic(void)
+{
+    X509 *root = NULL, *leaf = NULL;
+    X509_STORE_CTX *ctx = NULL;
+    X509_STORE *store = NULL;
+    X509_VERIFY_PARAM *param = NULL;
+    STACK_OF(X509) *x509s = NULL;
+    ASN1_OBJECT *obj1 = NULL, *obj2 = NULL;
+    ASN1_OCTET_STRING *oct1 = NULL, *oct2 = NULL;
+    X509_EXTENSION *ext1 = NULL, *ext2 = NULL;
+    const unsigned char data[] = { 0x04, 0x03, 0x41, 0x42, 0x43 };
+    const time_t verify_time = 1753284700; /* July 23th, 2025 */
+    const char *oid = "1.2.3.4.5.6.7.8.9";
+    const char *sn = "testOID";
+    const char *ln = "testOID Long Name";
+    int nid;
+    int test;
+
+    test = TEST_ptr(ctx = X509_STORE_CTX_new())
+        && TEST_ptr(store = X509_STORE_new())
+        && TEST_ptr(param = X509_VERIFY_PARAM_new())
+        && TEST_ptr(x509s = sk_X509_new_null())
+        && TEST_ptr((root = X509_from_strings(kRootMendelsonAKIDKeyNULL)))
+        && TEST_ptr((leaf = X509_from_strings(kLeafMendelsonAKIDKeyNULL)))
+        && TEST_true(X509_STORE_CTX_init(ctx, store, leaf, NULL))
+        && TEST_true((nid = OBJ_create(oid, sn, ln)) != NID_undef)
+        && TEST_ptr(obj1 = OBJ_nid2obj(nid))
+        && TEST_ptr(oct1 = ASN1_OCTET_STRING_new())
+        && TEST_int_eq(ASN1_OCTET_STRING_set(oct1, data, sizeof(data)), 1)
+        && TEST_ptr(ext1 = X509_EXTENSION_create_by_OBJ(NULL, obj1, 0, oct1))
+        && TEST_int_eq(X509_add_ext(leaf, ext1, -1), 1)
+        && TEST_ptr(obj2 = OBJ_nid2obj(nid))
+        && TEST_ptr(oct2 = ASN1_OCTET_STRING_new())
+        && TEST_int_eq(ASN1_OCTET_STRING_set(oct2, data, sizeof(data)), 1)
+        && TEST_ptr(ext2 = X509_EXTENSION_create_by_OBJ(NULL, obj2, 0, oct2))
+        && TEST_int_eq(X509_add_ext(leaf, ext2, -1), 1);
+
+    if (test != 1)
+        goto err;
+    if (!TEST_true(sk_X509_push(x509s, root)))
+        goto err;
+    root = NULL;
+
+    X509_STORE_CTX_set0_trusted_stack(ctx, x509s);
+    X509_VERIFY_PARAM_set_depth(param, 16);
+    X509_VERIFY_PARAM_set_time(param, verify_time);
+    X509_STORE_CTX_set0_param(ctx, param);
+    param = NULL;
+    ERR_clear_error();
+
+    test = TEST_int_eq(X509_verify_cert(ctx), 0)
+        && TEST_int_eq(X509_STORE_CTX_get_error(ctx),
+            X509_V_ERR_DUPLICATE_EXTENSION);
+
+err:
+    ASN1_OBJECT_free(obj1);
+    ASN1_OCTET_STRING_free(oct1);
+    X509_EXTENSION_free(ext1);
+    ASN1_OBJECT_free(obj2);
+    ASN1_OCTET_STRING_free(oct2);
+    X509_EXTENSION_free(ext2);
+    OSSL_STACK_OF_X509_free(x509s);
+    X509_VERIFY_PARAM_free(param);
+    X509_STORE_CTX_free(ctx);
+    X509_STORE_free(store);
+    X509_free(leaf);
+    X509_free(root);
+
+    return test;
+}
+
 int setup_tests(void)
 {
     ADD_TEST(test_standard_exts);
@@ -697,5 +948,9 @@ int setup_tests(void)
     ADD_TEST(tests_X509_check_crypto);
     ADD_TEST(tests_x509_check_dpn);
     ADD_TEST(tests_x509_check_akid);
+    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);
+
     return 1;
 }