Commit 0152e4f9f7 for openssl.org

commit 0152e4f9f7503d445f13f92670dd6c651361c377
Author: tlhc <tongli2@huawei.com>
Date:   Fri Feb 6 16:40:39 2026 +0800

    dtls: buffer early CCS to handle UDP reorder

    Cache early-arriving CCS in dtls1_state_st and replay it when the
    handshake reaches the expected state. Clear the flag in
    dtls1_clear_received_buffer().

    Add dtls_ccs_reorder_test covering client/server reorder, resumption and mutual TLS.

    Reviewed-by: Tomas Mraz <tomas@openssl.org>
    Reviewed-by: Matt Caswell <matt@openssl.foundation>
    MergeDate: Mon Mar 16 11:01:55 2026
    (Merged from https://github.com/openssl/openssl/pull/30225)

diff --git a/CHANGES.md b/CHANGES.md
index 27636e50e7..95e7f7545b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -365,6 +365,11 @@ OpenSSL Releases

    *Bob Beck*

+ * Improved DTLS handshake robustness under UDP reordering by buffering and
+   replaying early ChangeCipherSpec (CCS) records at the expected state.
+
+   *Tong Li*
+
  * Many functions accepting `X509 *` arguments, or returning values
    from a const `X509 *` have been changed to take/return const
    arguments. The most visible changes are places where pointer values
diff --git a/ssl/d1_lib.c b/ssl/d1_lib.c
index 1f77ede0cb..b1cd512510 100644
--- a/ssl/d1_lib.c
+++ b/ssl/d1_lib.c
@@ -116,6 +116,7 @@ void dtls1_clear_received_buffer(SSL_CONNECTION *s)
         dtls1_hm_fragment_free(frag);
         pitem_free(item);
     }
+    s->d1->has_change_cipher_spec = 0;
 }

 void dtls1_clear_sent_buffer(SSL_CONNECTION *s)
diff --git a/ssl/ssl_local.h b/ssl/ssl_local.h
index c7feaf556f..e28cc2aa22 100644
--- a/ssl/ssl_local.h
+++ b/ssl/ssl_local.h
@@ -1982,6 +1982,7 @@ typedef struct dtls1_state_st {
     unsigned int timeout_duration_us;

     unsigned int retransmitting;
+    unsigned int has_change_cipher_spec;
 #ifndef OPENSSL_NO_SCTP
     int shutdown_received;
 #endif
diff --git a/ssl/statem/statem_clnt.c b/ssl/statem/statem_clnt.c
index 7d0818e7ab..c2b812bd40 100644
--- a/ssl/statem/statem_clnt.c
+++ b/ssl/statem/statem_clnt.c
@@ -403,20 +403,6 @@ int ossl_statem_client_read_transition(SSL_CONNECTION *s, int mt)

 err:
     /* No valid transition found */
-    if (SSL_CONNECTION_IS_DTLS(s) && mt == SSL3_MT_CHANGE_CIPHER_SPEC) {
-        BIO *rbio;
-
-        /*
-         * CCS messages don't have a message sequence number so this is probably
-         * because of an out-of-order CCS. We'll just drop it.
-         */
-        s->init_num = 0;
-        s->rwstate = SSL_READING;
-        rbio = SSL_get_rbio(SSL_CONNECTION_GET_SSL(s));
-        BIO_clear_retry_flags(rbio);
-        BIO_set_retry_read(rbio);
-        return 0;
-    }
     SSLfatal(s, SSL3_AD_UNEXPECTED_MESSAGE, SSL_R_UNEXPECTED_MESSAGE);
     return 0;
 }
diff --git a/ssl/statem/statem_dtls.c b/ssl/statem/statem_dtls.c
index 4052ef6219..1644c6e47c 100644
--- a/ssl/statem/statem_dtls.c
+++ b/ssl/statem/statem_dtls.c
@@ -1,5 +1,5 @@
 /*
- * Copyright 2005-2025 The OpenSSL Project Authors. All Rights Reserved.
+ * Copyright 2005-2026 The OpenSSL Project Authors. All Rights Reserved.
  *
  * Licensed under the Apache License 2.0 (the "License").  You may not use
  * this file except in compliance with the License.  You can obtain a copy
@@ -67,6 +67,59 @@ static void dtls1_set_message_header_int(SSL_CONNECTION *s, unsigned char mt,
 static int dtls_get_reassembled_message(SSL_CONNECTION *s, int *errtype,
     size_t *len);

+/*
+ * Check if CCS is expected in current state.
+ *
+ * RFC 6347 Section 4.1 states DTLS must handle message reordering since UDP
+ * does not guarantee in-order delivery. This function determines when a
+ * buffered CCS should be delivered to the state machine.
+ *
+ * Server states where CCS is expected:
+ *   - TLS_ST_SR_KEY_EXCH: After key exchange (anonymous or no_cert_verify)
+ *   - TLS_ST_SR_CERT_VRFY: After certificate verify
+ *   - TLS_ST_SW_FINISHED: Session resumption (abbreviated handshake)
+ *
+ * Client states where CCS is expected:
+ *   - TLS_ST_CR_SRVR_HELLO: Abbreviated handshake without ticket
+ *   - TLS_ST_CW_FINISHED: After sending Finished, before server CCS
+ *   - TLS_ST_CR_SESSION_TICKET: After receiving session ticket
+ */
+static int dtls_ccs_expected(SSL_CONNECTION *s)
+{
+    OSSL_HANDSHAKE_STATE st = s->statem.hand_state;
+
+    if (s->server) {
+        switch (st) {
+        case TLS_ST_SR_KEY_EXCH:
+            /* Anonymous or no client cert: CCS follows KeyExchange */
+            if (s->session->peer == NULL && s->session->peer_rpk == NULL)
+                return 1;
+            /* Client cert but no verify message required */
+            return s->statem.no_cert_verify;
+        case TLS_ST_SR_CERT_VRFY:
+            return 1;
+        case TLS_ST_SW_FINISHED:
+            /* Abbreviated handshake: server sends first, then receives CCS */
+            return s->hit;
+        default:
+            return 0;
+        }
+    } else {
+        switch (st) {
+        case TLS_ST_CR_SRVR_HELLO:
+            /* Abbreviated handshake without session ticket */
+            return (s->hit && !s->ext.ticket_expected);
+        case TLS_ST_CW_FINISHED:
+            /* Full handshake: waiting for server CCS after sending Finished */
+            return !s->ext.ticket_expected;
+        case TLS_ST_CR_SESSION_TICKET:
+            return 1;
+        default:
+            return 0;
+        }
+    }
+}
+
 static hm_fragment *dtls1_hm_fragment_new(size_t frag_len, int reassembly)
 {
     hm_fragment *frag = NULL;
@@ -830,6 +883,28 @@ static int dtls_get_reassembled_message(SSL_CONNECTION *s, int *errtype,
     p = (unsigned char *)s->init_buf->data;

 redo:
+    /* Check for buffered CCS */
+    if ((s->version == DTLS1_VERSION || s->version == DTLS1_2_VERSION
+            || s->version == DTLS1_BAD_VER)
+        && s->d1->has_change_cipher_spec && dtls_ccs_expected(s)) {
+        size_t extra = (s->version == DTLS1_BAD_VER) ? 2 : 0;
+
+        s->d1->has_change_cipher_spec = 0;
+        p[0] = SSL3_MT_CCS;
+        /*
+         * The extra 2 bytes are never consumed, only checked for
+         * length -- zero-fill to avoid old init_buf content.
+         */
+        if (extra > 0)
+            memset(p + 1, 0, extra);
+        s->init_num = extra;
+        s->init_msg = p + 1;
+        s->s3.tmp.message_type = SSL3_MT_CHANGE_CIPHER_SPEC;
+        s->s3.tmp.message_size = extra;
+        *len = extra;
+        return 1;
+    }
+
     /* see if we have the required fragment already */
     ret = dtls1_retrieve_buffered_fragment(s, &frag_len);
     if (ret < 0) {
@@ -857,12 +932,22 @@ redo:
             goto f_err;
         }

-        s->init_num = readbytes - 1;
-        s->init_msg = s->init_buf->data + 1;
-        s->s3.tmp.message_type = SSL3_MT_CHANGE_CIPHER_SPEC;
-        s->s3.tmp.message_size = readbytes - 1;
-        *len = readbytes - 1;
-        return 1;
+        /* Buffer CCS for reorder tolerance */
+        if (s->version == DTLS1_VERSION || s->version == DTLS1_2_VERSION
+            || s->version == DTLS1_BAD_VER) {
+            size_t expected = (s->version == DTLS1_BAD_VER) ? 3 : 1;
+
+            if (readbytes != expected) {
+                SSLfatal(s, SSL_AD_DECODE_ERROR,
+                    SSL_R_BAD_CHANGE_CIPHER_SPEC);
+                goto f_err;
+            }
+            s->d1->has_change_cipher_spec = 1;
+            goto redo;
+        }
+        SSLfatal(s, SSL_AD_UNEXPECTED_MESSAGE,
+            SSL_R_BAD_CHANGE_CIPHER_SPEC);
+        goto f_err;
     }

     /* Handshake fails if message header is incomplete */
diff --git a/ssl/statem/statem_srvr.c b/ssl/statem/statem_srvr.c
index 16fa0b0e38..288fc31f8f 100644
--- a/ssl/statem/statem_srvr.c
+++ b/ssl/statem/statem_srvr.c
@@ -309,20 +309,6 @@ int ossl_statem_server_read_transition(SSL_CONNECTION *s, int mt)

 err:
     /* No valid transition found */
-    if (SSL_CONNECTION_IS_DTLS(s) && mt == SSL3_MT_CHANGE_CIPHER_SPEC) {
-        BIO *rbio;
-
-        /*
-         * CCS messages don't have a message sequence number so this is probably
-         * because of an out-of-order CCS. We'll just drop it.
-         */
-        s->init_num = 0;
-        s->rwstate = SSL_READING;
-        rbio = SSL_get_rbio(SSL_CONNECTION_GET_SSL(s));
-        BIO_clear_retry_flags(rbio);
-        BIO_set_retry_read(rbio);
-        return 0;
-    }
     SSLfatal(s, SSL3_AD_UNEXPECTED_MESSAGE, SSL_R_UNEXPECTED_MESSAGE);
     return 0;
 }
diff --git a/test/bad_dtls_test.c b/test/bad_dtls_test.c
index 904f28e2c2..60d095e839 100644
--- a/test/bad_dtls_test.c
+++ b/test/bad_dtls_test.c
@@ -46,6 +46,15 @@
 /* For DTLS1_BAD_VER packets the MAC doesn't include the handshake header */
 #define MAC_OFFSET (DTLS1_RT_HEADER_LENGTH + DTLS1_HM_HEADER_LENGTH)

+static unsigned int infinite_timer_cb(SSL *s, unsigned int timer_us)
+{
+    (void)s;
+
+    if (timer_us == 0)
+        return 999999999;
+    return timer_us;
+}
+
 static unsigned char client_random[SSL3_RANDOM_SIZE];
 static unsigned char server_random[SSL3_RANDOM_SIZE];

@@ -332,7 +341,7 @@ static int send_hello_verify(BIO *rbio)
     return 1;
 }

-static int send_server_hello(BIO *rbio)
+static int send_server_hello(BIO *rbio, int reorder_ccs)
 {
     static unsigned char server_hello[] = {
         0x16, /* Handshake */
@@ -459,8 +468,13 @@ static int send_server_hello(BIO *rbio)
             sizeof(server_hello) - MAC_OFFSET))
         return 0;

-    BIO_write(rbio, server_hello, sizeof(server_hello));
-    BIO_write(rbio, change_cipher_spec, sizeof(change_cipher_spec));
+    if (reorder_ccs) {
+        BIO_write(rbio, change_cipher_spec, sizeof(change_cipher_spec));
+        BIO_write(rbio, server_hello, sizeof(server_hello));
+    } else {
+        BIO_write(rbio, server_hello, sizeof(server_hello));
+        BIO_write(rbio, change_cipher_spec, sizeof(change_cipher_spec));
+    }

     return 1;
 }
@@ -665,8 +679,9 @@ static struct {
     /* The last test should be NODROP, because a DROP wouldn't get tested. */
 };

-static int test_bad_dtls(void)
+static int test_bad_dtls(int idx)
 {
+    int reorder_ccs = idx;
     SSL_SESSION *sess = NULL;
     SSL_CTX *ctx = NULL;
     SSL *con = NULL;
@@ -734,6 +749,8 @@ static int test_bad_dtls(void)
     }

     SSL_set_connect_state(con);
+    if (reorder_ccs)
+        DTLS_set_timer_cb(con, infinite_timer_cb);

     /* Send initial ClientHello */
     ret = SSL_do_handshake(con);
@@ -747,7 +764,7 @@ static int test_bad_dtls(void)
     if (!TEST_int_le(ret, 0)
         || !TEST_int_eq(SSL_get_error(con, ret), SSL_ERROR_WANT_READ)
         || !TEST_int_eq(validate_client_hello(wbio), 2)
-        || !TEST_true(send_server_hello(rbio)))
+        || !TEST_true(send_server_hello(rbio, reorder_ccs)))
         goto end;

     ret = SSL_do_handshake(con);
@@ -808,6 +825,6 @@ end:

 int setup_tests(void)
 {
-    ADD_TEST(test_bad_dtls);
+    ADD_ALL_TESTS(test_bad_dtls, 2);
     return 1;
 }
diff --git a/test/build.info b/test/build.info
index eec0553cc8..c5a7a565b7 100644
--- a/test/build.info
+++ b/test/build.info
@@ -54,7 +54,7 @@ IF[{- !$disabled{tests} -}]
           dtlsv1listentest ct_test threadstest d2i_test \
           ssl_test_ctx_test ssl_test x509aux cipherlist_test asynciotest \
           bio_callback_test bio_memleak_test bio_core_test bio_dgram_test param_build_test \
-          bioprinttest sslapitest ssl_handshake_rtt_test dtlstest sslcorrupttest \
+          bioprinttest sslapitest ssl_handshake_rtt_test dtlstest dtls_ccs_reorder_test sslcorrupttest \
           bio_base64_test bio_enc_test pkey_meth_kdf_test evp_kdf_test uitest \
           cipherbytes_test threadstest_fips threadpool_test \
           asn1_encode_test asn1_decode_test asn1_string_table_test asn1_stable_parse_test \
@@ -665,6 +665,10 @@ IF[{- !$disabled{tests} -}]
   INCLUDE[dtlstest]=../include ../apps/include
   DEPEND[dtlstest]=../libcrypto ../libssl libtestutil.a

+  SOURCE[dtls_ccs_reorder_test]=dtls_ccs_reorder_test.c helpers/ssltestlib.c
+  INCLUDE[dtls_ccs_reorder_test]=../include ../apps/include
+  DEPEND[dtls_ccs_reorder_test]=../libcrypto ../libssl libtestutil.a
+
   SOURCE[sslcorrupttest]=sslcorrupttest.c helpers/ssltestlib.c
   INCLUDE[sslcorrupttest]=../include ../apps/include
   DEPEND[sslcorrupttest]=../libcrypto ../libssl libtestutil.a
diff --git a/test/dtls_ccs_reorder_test.c b/test/dtls_ccs_reorder_test.c
new file mode 100644
index 0000000000..5e145c961d
--- /dev/null
+++ b/test/dtls_ccs_reorder_test.c
@@ -0,0 +1,490 @@
+/*
+ * Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License 2.0 (the "License").  You may not use
+ * this file except in compliance with the License.  You can obtain a copy
+ * in the file LICENSE in the source distribution or at
+ * https://www.openssl.org/source/license.html
+ */
+
+/* DTLS CCS early-arrival tests */
+
+#include <openssl/aes.h>
+#include <openssl/ssl.h>
+#include <openssl/ssl3.h>
+#include <openssl/evp.h>
+#include <openssl/core_names.h>
+#include <openssl/x509.h>
+
+#include "helpers/ssltestlib.h"
+#include "testutil.h"
+
+static char *cert = NULL;
+static char *privkey = NULL;
+
+#ifndef OPENSSL_NO_DTLS
+
+static unsigned int infinite_timer_cb(SSL *s, unsigned int timer_us)
+{
+    (void)s;
+
+    if (timer_us == 0)
+        return 999999999;
+    return timer_us;
+}
+
+static int verify_accept_cb(int ok, X509_STORE_CTX *ctx)
+{
+    (void)ok;
+    (void)ctx;
+
+    return 1;
+}
+
+static int tick_key_renew_cb(SSL *s, unsigned char key_name[16],
+    unsigned char iv[EVP_MAX_IV_LENGTH],
+    EVP_CIPHER_CTX *ctx, EVP_MAC_CTX *hctx,
+    int enc)
+{
+    const unsigned char tick_aes_key[16] = {
+        '0', '1', '2', '3', '4', '5', '6', '7',
+        '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
+    };
+
+    unsigned char tick_hmac_key[16] = {
+        '0', '1', '2', '3', '4', '5', '6', '7',
+        '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
+    };
+    OSSL_PARAM params[2];
+    EVP_CIPHER *aes128cbc = EVP_CIPHER_fetch(NULL, "AES-128-CBC", NULL);
+    int ret;
+
+    (void)s;
+
+    if (aes128cbc == NULL)
+        return -1;
+
+    memset(key_name, 0, 16);
+    memset(iv, 0, AES_BLOCK_SIZE);
+    params[0] = OSSL_PARAM_construct_utf8_string(OSSL_MAC_PARAM_DIGEST,
+        "SHA256", 0);
+    params[1] = OSSL_PARAM_construct_end();
+
+    if (!EVP_CipherInit_ex(ctx, aes128cbc, NULL, tick_aes_key, iv, enc)
+        || !EVP_MAC_init(hctx, tick_hmac_key, sizeof(tick_hmac_key), params))
+        ret = -1;
+    else
+        ret = enc ? 1 : 2;
+
+    EVP_CIPHER_free(aes128cbc);
+    return ret;
+}
+
+static int verify_data_transfer(SSL *writer, SSL *reader)
+{
+    const char msg[] = "CCS reorder test";
+    char buf[sizeof(msg)];
+
+    if (!TEST_int_eq(SSL_write(writer, msg, sizeof(msg)), (int)sizeof(msg))
+        || !TEST_int_eq(SSL_read(reader, buf, sizeof(buf)), (int)sizeof(msg))
+        || !TEST_mem_eq(buf, sizeof(msg), msg, sizeof(msg)))
+        return 0;
+    return 1;
+}
+
+/* Move CCS just before the handshake message given by before_hs_msg. */
+static int reorder_ccs(BIO *bio, int before_hs_msg)
+{
+    int target_pkt = -1, target_rec = -1;
+    int ccs_pkt = -1, ccs_rec = -1;
+    int p;
+
+    if (!TEST_true(mempacket_find_record(bio, SSL3_RT_HANDSHAKE,
+            before_hs_msg,
+            &target_pkt, &target_rec)))
+        return 0;
+
+    if (target_rec > 0
+        && !TEST_true(mempacket_split_packet_at(bio, target_pkt, target_rec)))
+        return 0;
+
+    if (!TEST_true(mempacket_find_record(bio, SSL3_RT_CHANGE_CIPHER_SPEC, -1,
+            &ccs_pkt, &ccs_rec)))
+        return 0;
+
+    if (ccs_rec > 0
+        && !TEST_true(mempacket_split_packet_at(bio, ccs_pkt, ccs_rec)))
+        return 0;
+
+    if (!TEST_true(mempacket_find_record(bio, SSL3_RT_CHANGE_CIPHER_SPEC, -1,
+            &ccs_pkt, &ccs_rec))
+        || !TEST_int_eq(ccs_rec, 0))
+        return 0;
+
+    if (!TEST_true(mempacket_split_packet_at(bio, ccs_pkt, 1)))
+        return 0;
+
+    if (!TEST_true(mempacket_find_record(bio, SSL3_RT_HANDSHAKE,
+            before_hs_msg,
+            &target_pkt, &target_rec))
+        || !TEST_int_eq(target_rec, 0))
+        return 0;
+
+    if (ccs_pkt == target_pkt)
+        return 0;
+
+    if (ccs_pkt > target_pkt) {
+        if (!TEST_true(mempacket_move_packet(bio, target_pkt, ccs_pkt)))
+            return 0;
+    } else {
+        for (p = ccs_pkt; p + 1 < target_pkt; p++) {
+            if (!TEST_true(mempacket_move_packet(bio, p, p + 1)))
+                return 0;
+        }
+    }
+
+    /* CCS packet should be at position target_pkt - 1 */
+    if (!TEST_true(mempacket_find_record(bio, SSL3_RT_CHANGE_CIPHER_SPEC,
+            -1, &ccs_pkt, &ccs_rec))
+        || !TEST_true(mempacket_find_record(bio, SSL3_RT_HANDSHAKE,
+            before_hs_msg,
+            &target_pkt, &target_rec))
+        || !TEST_int_eq(ccs_pkt + 1, target_pkt)
+        || !TEST_int_eq(ccs_rec, 0)
+        || !TEST_int_eq(target_rec, 0))
+        return 0;
+
+    return 1;
+}
+
+static const struct {
+    int mtls;
+    int reorder_before;
+    int max_version;
+} full_hs_tests[] = {
+#ifndef OPENSSL_NO_DTLS1_2
+    /* DTLS 1.2: [CKE][CCS][Fin] -> [CCS][CKE][Fin] */
+    { 0, SSL3_MT_CLIENT_KEY_EXCHANGE, DTLS1_2_VERSION },
+    /* DTLS 1.2 mTLS: [Cert][CKE][CV][CCS][Fin] -> [CCS][Cert]... */
+    { 1, SSL3_MT_CERTIFICATE, DTLS1_2_VERSION },
+    /* DTLS 1.2 mTLS: [Cert][CKE][CV][CCS][Fin] -> [Cert][CKE][CCS][CV]... */
+    { 1, SSL3_MT_CERTIFICATE_VERIFY, DTLS1_2_VERSION },
+#endif
+#ifndef OPENSSL_NO_DTLS1
+    /* DTLS 1.0: [CKE][CCS][Fin] -> [CCS][CKE][Fin] */
+    { 0, SSL3_MT_CLIENT_KEY_EXCHANGE, DTLS1_VERSION },
+    /* DTLS 1.0 mTLS: [Cert][CKE][CV][CCS][Fin] -> [CCS][Cert]... */
+    { 1, SSL3_MT_CERTIFICATE, DTLS1_VERSION },
+    /* DTLS 1.0 mTLS: [Cert][CKE][CV][CCS][Fin] -> [Cert][CKE][CCS][CV]... */
+    { 1, SSL3_MT_CERTIFICATE_VERIFY, DTLS1_VERSION },
+#endif
+};
+
+/* Full handshake, Flight 3 (C->S): early CCS in the client flight. */
+static int test_dtls_ccs_full_hs(int idx)
+{
+    SSL_CTX *sctx = NULL, *cctx = NULL;
+    SSL *sssl = NULL, *cssl = NULL;
+    BIO *bio;
+    X509 *peer = NULL;
+    int testresult = 0, ret;
+    int mtls = full_hs_tests[idx].mtls;
+    int reorder_before = full_hs_tests[idx].reorder_before;
+    int max_ver = full_hs_tests[idx].max_version;
+
+    if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+            DTLS_client_method(),
+            max_ver, max_ver,
+            &sctx, &cctx, cert, privkey)))
+        return 0;
+
+    if (max_ver == DTLS1_VERSION) {
+        SSL_CTX_set_security_level(sctx, 0);
+        SSL_CTX_set_security_level(cctx, 0);
+    }
+
+    if (mtls) {
+        SSL_CTX_set_verify(sctx,
+            SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT,
+            verify_accept_cb);
+
+        if (!TEST_true(SSL_CTX_use_certificate_file(cctx, cert,
+                SSL_FILETYPE_PEM))
+            || !TEST_true(SSL_CTX_use_PrivateKey_file(cctx, privkey,
+                SSL_FILETYPE_PEM)))
+            goto end;
+    }
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+        goto end;
+
+    DTLS_set_timer_cb(sssl, infinite_timer_cb);
+    DTLS_set_timer_cb(cssl, infinite_timer_cb);
+
+    if (!TEST_int_le(SSL_connect(cssl), 0))
+        goto end;
+
+    if (!TEST_int_le(SSL_accept(sssl), 0))
+        goto end;
+
+    if (!TEST_int_le(SSL_connect(cssl), 0))
+        goto end;
+
+    bio = SSL_get_wbio(cssl);
+    if (!TEST_ptr(bio)
+        || !TEST_true(reorder_ccs(bio, reorder_before)))
+        goto end;
+
+    ret = SSL_accept(sssl);
+    if (!TEST_int_gt(ret, 0)) {
+        TEST_info("SSL_accept: ret=%d err=%d state=%s",
+            ret, SSL_get_error(sssl, ret),
+            SSL_state_string_long(sssl));
+        goto end;
+    }
+
+    ret = SSL_connect(cssl);
+    if (!TEST_int_gt(ret, 0)) {
+        TEST_info("SSL_connect: ret=%d err=%d state=%s",
+            ret, SSL_get_error(cssl, ret),
+            SSL_state_string_long(cssl));
+        goto end;
+    }
+
+    if (mtls) {
+        peer = SSL_get1_peer_certificate(sssl);
+        if (!TEST_ptr(peer))
+            goto end;
+    }
+
+    if (!TEST_true(verify_data_transfer(sssl, cssl)))
+        goto end;
+
+    testresult = 1;
+end:
+    X509_free(peer);
+    SSL_free(sssl);
+    SSL_free(cssl);
+    SSL_CTX_free(sctx);
+    SSL_CTX_free(cctx);
+    return testresult;
+}
+
+static const int nst_versions[] = {
+#ifndef OPENSSL_NO_DTLS1_2
+    DTLS1_2_VERSION,
+#endif
+#ifndef OPENSSL_NO_DTLS1
+    DTLS1_VERSION,
+#endif
+};
+
+/* Flight 4 (S->C): [NST][CCS][Finished] -> [CCS][NST][Finished] */
+static int test_dtls_ccs_before_nst(int idx)
+{
+    SSL_CTX *sctx = NULL, *cctx = NULL;
+    SSL *sssl = NULL, *cssl = NULL;
+    BIO *bio;
+    int testresult = 0, ret;
+    int max_ver = nst_versions[idx];
+
+    if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+            DTLS_client_method(),
+            max_ver, max_ver,
+            &sctx, &cctx, cert, privkey)))
+        return 0;
+
+    if (max_ver == DTLS1_VERSION) {
+        SSL_CTX_set_security_level(sctx, 0);
+        SSL_CTX_set_security_level(cctx, 0);
+    }
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+        goto end;
+
+    DTLS_set_timer_cb(sssl, infinite_timer_cb);
+    DTLS_set_timer_cb(cssl, infinite_timer_cb);
+
+    if (!TEST_int_le(SSL_connect(cssl), 0))
+        goto end;
+
+    if (!TEST_int_le(SSL_accept(sssl), 0))
+        goto end;
+
+    if (!TEST_int_le(SSL_connect(cssl), 0))
+        goto end;
+
+    ret = SSL_accept(sssl);
+    if (!TEST_int_gt(ret, 0)) {
+        TEST_info("SSL_accept: ret=%d err=%d state=%s",
+            ret, SSL_get_error(sssl, ret),
+            SSL_state_string_long(sssl));
+        goto end;
+    }
+
+    bio = SSL_get_wbio(sssl);
+    if (!TEST_ptr(bio)
+        || !TEST_true(reorder_ccs(bio, SSL3_MT_NEWSESSION_TICKET)))
+        goto end;
+
+    ret = SSL_connect(cssl);
+    if (!TEST_int_gt(ret, 0)) {
+        TEST_info("SSL_connect: ret=%d err=%d state=%s",
+            ret, SSL_get_error(cssl, ret),
+            SSL_state_string_long(cssl));
+        goto end;
+    }
+
+    if (!TEST_true(verify_data_transfer(cssl, sssl)))
+        goto end;
+
+    testresult = 1;
+end:
+    SSL_free(sssl);
+    SSL_free(cssl);
+    SSL_CTX_free(sctx);
+    SSL_CTX_free(cctx);
+    return testresult;
+}
+
+static const struct {
+    int use_ticket;
+    int reorder_before;
+    int max_version;
+} resume_tests[] = {
+#ifndef OPENSSL_NO_DTLS1_2
+    /* DTLS 1.2 Session ID: [SH][CCS][Fin] -> [CCS][SH][Fin] */
+    { 0, SSL3_MT_SERVER_HELLO, DTLS1_2_VERSION },
+    /* DTLS 1.2 Ticket renewal: [NST][CCS][Fin] -> [CCS][NST][Fin] */
+    { 1, SSL3_MT_NEWSESSION_TICKET, DTLS1_2_VERSION },
+#endif
+#ifndef OPENSSL_NO_DTLS1
+    /* DTLS 1.0 Session ID: [SH][CCS][Fin] -> [CCS][SH][Fin] */
+    { 0, SSL3_MT_SERVER_HELLO, DTLS1_VERSION },
+    /* DTLS 1.0 Ticket renewal: [NST][CCS][Fin] -> [CCS][NST][Fin] */
+    { 1, SSL3_MT_NEWSESSION_TICKET, DTLS1_VERSION },
+#endif
+};
+
+/* Resumption, Flight 2 (S->C): early CCS in the server flight. */
+static int test_dtls_ccs_resume(int idx)
+{
+    SSL_CTX *sctx = NULL, *cctx = NULL;
+    SSL *sssl = NULL, *cssl = NULL;
+    SSL_SESSION *sess = NULL;
+    BIO *bio;
+    int testresult = 0, ret;
+    int use_ticket = resume_tests[idx].use_ticket;
+    int reorder_before = resume_tests[idx].reorder_before;
+    int max_ver = resume_tests[idx].max_version;
+
+    if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+            DTLS_client_method(),
+            max_ver, max_ver,
+            &sctx, &cctx, cert, privkey)))
+        return 0;
+
+    if (max_ver == DTLS1_VERSION) {
+        SSL_CTX_set_security_level(sctx, 0);
+        SSL_CTX_set_security_level(cctx, 0);
+    }
+
+    if (use_ticket) {
+        if (!TEST_true(SSL_CTX_set_tlsext_ticket_key_evp_cb(sctx,
+                tick_key_renew_cb)))
+            goto end;
+    } else {
+        SSL_CTX_set_options(sctx, SSL_OP_NO_TICKET);
+    }
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+        goto end;
+
+    if (!TEST_true(create_ssl_connection(sssl, cssl, SSL_ERROR_NONE)))
+        goto end;
+
+    sess = SSL_get1_session(cssl);
+    if (!TEST_ptr(sess))
+        goto end;
+
+    shutdown_ssl_connection(sssl, cssl);
+    sssl = cssl = NULL;
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+        goto end;
+
+    DTLS_set_timer_cb(sssl, infinite_timer_cb);
+    DTLS_set_timer_cb(cssl, infinite_timer_cb);
+
+    if (!TEST_true(SSL_set_session(cssl, sess)))
+        goto end;
+
+    if (!TEST_int_le(SSL_connect(cssl), 0))
+        goto end;
+
+    if (!TEST_int_le(SSL_accept(sssl), 0))
+        goto end;
+
+    bio = SSL_get_wbio(sssl);
+    if (!TEST_ptr(bio)
+        || !TEST_true(reorder_ccs(bio, reorder_before)))
+        goto end;
+
+    ret = SSL_connect(cssl);
+    if (!TEST_int_gt(ret, 0)) {
+        TEST_info("SSL_connect: ret=%d err=%d state=%s",
+            ret, SSL_get_error(cssl, ret),
+            SSL_state_string_long(cssl));
+        goto end;
+    }
+
+    ret = SSL_accept(sssl);
+    if (!TEST_int_gt(ret, 0)) {
+        TEST_info("SSL_accept: ret=%d err=%d state=%s",
+            ret, SSL_get_error(sssl, ret),
+            SSL_state_string_long(sssl));
+        goto end;
+    }
+
+    if (!TEST_true(SSL_session_reused(cssl)))
+        goto end;
+
+    if (!TEST_true(verify_data_transfer(cssl, sssl)))
+        goto end;
+
+    testresult = 1;
+end:
+    SSL_free(sssl);
+    SSL_free(cssl);
+    SSL_CTX_free(sctx);
+    SSL_CTX_free(cctx);
+    SSL_SESSION_free(sess);
+    return testresult;
+}
+
+#endif /* OPENSSL_NO_DTLS */
+
+int setup_tests(void)
+{
+    if (!test_skip_common_options()) {
+        TEST_error("Error parsing test options\n");
+        return 0;
+    }
+
+    if (!TEST_ptr(cert = test_get_argument(0))
+        || !TEST_ptr(privkey = test_get_argument(1)))
+        return 0;
+
+#ifndef OPENSSL_NO_DTLS
+    ADD_ALL_TESTS(test_dtls_ccs_full_hs, OSSL_NELEM(full_hs_tests));
+    ADD_ALL_TESTS(test_dtls_ccs_before_nst, OSSL_NELEM(nst_versions));
+    ADD_ALL_TESTS(test_dtls_ccs_resume, OSSL_NELEM(resume_tests));
+#endif
+
+    return 1;
+}
+
+void cleanup_tests(void)
+{
+    bio_s_mempacket_test_free();
+}
diff --git a/test/helpers/ssltestlib.c b/test/helpers/ssltestlib.c
index 471498cfb8..8e512eaabb 100644
--- a/test/helpers/ssltestlib.c
+++ b/test/helpers/ssltestlib.c
@@ -525,6 +525,117 @@ int mempacket_move_packet(BIO *bio, int d, int s)
     return 1;
 }

+/*
+ * Find the first DTLS record with content type rectype.
+ * If hs_msg_type >= 0, only match epoch-0 handshake records whose
+ * first handshake byte equals hs_msg_type.
+ */
+int mempacket_find_record(BIO *bio, int rectype, int hs_msg_type,
+    int *pktidx, int *recidx)
+{
+    MEMPACKET_TEST_CTX *ctx = BIO_get_data(bio);
+    int numpkts = sk_MEMPACKET_num(ctx->pkts);
+    int i, j, rem, len, payload_len;
+    unsigned char *rec;
+
+    for (i = 0; i < numpkts; i++) {
+        MEMPACKET *thispkt = sk_MEMPACKET_value(ctx->pkts, i);
+
+        if (thispkt == NULL)
+            continue;
+        for (j = 0, rem = thispkt->len, rec = thispkt->data;
+            rem >= DTLS1_RT_HEADER_LENGTH;
+            j++, rem -= len, rec += len) {
+            payload_len = (rec[RECORD_LEN_HI] << 8) | rec[RECORD_LEN_LO];
+            len = payload_len + DTLS1_RT_HEADER_LENGTH;
+            if (rem < len)
+                return 0;
+            if (rec[RECORD_CONTENT_TYPE] != (unsigned char)rectype)
+                continue;
+            if (hs_msg_type >= 0
+                && ((rec[RECORD_EPOCH_HI] | rec[RECORD_EPOCH_LO]) != 0
+                    || payload_len < 1
+                    || rec[DTLS1_RT_HEADER_LENGTH]
+                        != (unsigned char)hs_msg_type))
+                continue;
+            *pktidx = i;
+            *recidx = j;
+            return 1;
+        }
+    }
+    return 0;
+}
+
+/*
+ * Split packet pktidx before record recidx and insert the tail at pktidx + 1.
+ * Splitting at either boundary (before record 0 or after the last record)
+ * is treated as a successful no-op.
+ */
+int mempacket_split_packet_at(BIO *bio, int pktidx, int recidx)
+{
+    MEMPACKET_TEST_CTX *ctx = BIO_get_data(bio);
+    MEMPACKET *srcpkt, *newpkt;
+    int numpkts = sk_MEMPACKET_num(ctx->pkts);
+    int rem, len, i, split_off = 0;
+    unsigned char *rec;
+
+    if (pktidx < 0 || pktidx >= numpkts || recidx < 0)
+        return 0;
+
+    srcpkt = sk_MEMPACKET_value(ctx->pkts, pktidx);
+    if (srcpkt == NULL)
+        return 0;
+
+    for (i = 0, rem = srcpkt->len, rec = srcpkt->data;
+        rem >= DTLS1_RT_HEADER_LENGTH && i < recidx;
+        i++, rem -= len, rec += len) {
+        len = ((rec[RECORD_LEN_HI] << 8) | rec[RECORD_LEN_LO])
+            + DTLS1_RT_HEADER_LENGTH;
+        if (rem < len)
+            return 0;
+        split_off += len;
+    }
+
+    if (i != recidx)
+        return 0;
+
+    if (split_off == 0 || split_off == srcpkt->len)
+        return 1;
+
+    newpkt = OPENSSL_malloc(sizeof(*newpkt));
+    if (newpkt == NULL)
+        return 0;
+
+    newpkt->len = srcpkt->len - split_off;
+    newpkt->data = OPENSSL_malloc(newpkt->len);
+    if (newpkt->data == NULL) {
+        OPENSSL_free(newpkt);
+        return 0;
+    }
+
+    memcpy(newpkt->data, srcpkt->data + split_off, newpkt->len);
+    newpkt->type = srcpkt->type;
+    if (pktidx + 1 < numpkts
+        && sk_MEMPACKET_value(ctx->pkts, pktidx + 1) != NULL)
+        newpkt->num = sk_MEMPACKET_value(ctx->pkts, pktidx + 1)->num;
+    else
+        newpkt->num = srcpkt->num + 1;
+
+    if (sk_MEMPACKET_insert(ctx->pkts, newpkt, pktidx + 1) <= 0) {
+        OPENSSL_free(newpkt->data);
+        OPENSSL_free(newpkt);
+        return 0;
+    }
+
+    srcpkt->len = split_off;
+
+    numpkts = sk_MEMPACKET_num(ctx->pkts);
+    for (i = pktidx + 2; i < numpkts; i++)
+        sk_MEMPACKET_value(ctx->pkts, i)->num++;
+
+    return 1;
+}
+
 int mempacket_dup_last_packet(BIO *bio)
 {
     MEMPACKET_TEST_CTX *ctx = BIO_get_data(bio);
diff --git a/test/helpers/ssltestlib.h b/test/helpers/ssltestlib.h
index 9477e06b13..54aeb6b50c 100644
--- a/test/helpers/ssltestlib.h
+++ b/test/helpers/ssltestlib.h
@@ -74,6 +74,9 @@ void bio_s_maybe_retry_free(void);

 int mempacket_swap_epoch(BIO *bio);
 int mempacket_move_packet(BIO *bio, int d, int s);
+int mempacket_find_record(BIO *bio, int rectype, int hs_msg_type,
+    int *pktidx, int *recidx);
+int mempacket_split_packet_at(BIO *bio, int pktidx, int recidx);
 int mempacket_dup_last_packet(BIO *bio);
 int mempacket_test_inject(BIO *bio, const char *in, int inl, int pktnum,
     int type);
diff --git a/test/recipes/80-test_dtls_ccs_reorder.t b/test/recipes/80-test_dtls_ccs_reorder.t
new file mode 100755
index 0000000000..3c703afc34
--- /dev/null
+++ b/test/recipes/80-test_dtls_ccs_reorder.t
@@ -0,0 +1,26 @@
+#! /usr/bin/env perl
+# Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+#
+# Licensed under the Apache License 2.0 (the "License").  You may not use
+# this file except in compliance with the License.  You can obtain a copy
+# in the file LICENSE in the source distribution or at
+# https://www.openssl.org/source/license.html
+
+use strict;
+use warnings;
+
+use OpenSSL::Test::Simple;
+use OpenSSL::Test::Utils;
+use OpenSSL::Test qw/:DEFAULT srctop_file/;
+
+setup("test_dtls_ccs_reorder");
+
+plan skip_all => "No DTLS protocols are supported"
+    if alldisabled(available_protocols("dtls"));
+
+plan tests => 1;
+
+ok(run(test(["dtls_ccs_reorder_test",
+             srctop_file("test/certs/servercert.pem"),
+             srctop_file("test/certs/serverkey.pem")])),
+   "DTLS CCS reorder tolerance tests");