Commit eeb5f65fb3 for openssl.org

commit eeb5f65fb3b6ef3a57fa7847c0242fcbf8c0d2dc
Author: Matt Caswell <matt@openssl.foundation>
Date:   Mon Oct 19 09:08:14 2026 +0100

    DTLS 1.3: only accept plaintext alerts once the hellos are done

    DTLS 1.3 defers installing the handshake read keys until the peer's
    first epoch 2 record arrives. Until then the read record layer stays in
    epoch 0 and accepted plaintext handshake and ACK records. A peer that
    never sends an epoch 2 record could therefore send its Finished in
    plaintext. That Finished was checked with a finished key that was never
    derived (all zero), and the connection completed with its application
    read keys numbered epoch 1 instead of 3.

    From that state a client can ACK only the second NewSessionTicket and
    then send a record claiming epoch 2. The server performs the deferred
    key change on the established connection, and dtls1_clear_sent_buffer()
    on the partly acknowledged queue leaves a freed item linked in
    sent_messages, which is a use-after-free. A malicious server can do the
    same to a client.

    Once the hellos are done, tell the epoch 0 read layer that only
    plaintext alerts may still arrive, and discard any handshake fragments
    buffered before then. Make tls_process_finished() refuse a DTLS 1.3
    Finished read before the handshake keys are installed. Disarm the
    deferred key change once it has run, and reset it with the connection.

    DTLS 1.3 has not been in any final release, so no released version of
    OpenSSL is affected.

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

    Assisted-by: Claude Code:claude-opus-5-5
    Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
    Reviewed-by: Mounir Idrassi <mounir.idrassi@idrix.fr>
    Reviewed-by: Daniel Kubec <kubec@openssl.foundation>
    Merge-date: Tue Oct 20 12:38:20 2026
    Merged-from: https://github.com/openssl/openssl/pull/33256

diff --git a/ssl/d1_lib.c b/ssl/d1_lib.c
index cbe08c2709..c4b01779c9 100644
--- a/ssl/d1_lib.c
+++ b/ssl/d1_lib.c
@@ -134,6 +134,20 @@ void dtls1_clear_received_buffer(SSL_CONNECTION *s)
     s->d1->has_change_cipher_spec = 0;
 }

+/*
+ * Called once the DTLS 1.3 hellos are done. Our handshake read keys are only
+ * installed when the peer's first epoch 2 record arrives (dtls1_read_bytes()),
+ * so until then nothing but a plaintext alert may be read. Anything already
+ * reassembled or buffered arrived unprotected, so discard it.
+ */
+void dtls13_hellos_done(SSL_CONNECTION *s)
+{
+    s->dtls13_process_hello = 1;
+    dtls1_clear_received_buffer(s);
+    if (s->rlayer.rrlmethod->set_plain_alerts != NULL)
+        s->rlayer.rrlmethod->set_plain_alerts(s->rlayer.rrl, 1);
+}
+
 void ossl_list_record_number_elem_free(OSSL_LIST(record_number) *p_list)
 {
     DTLS1_RECORD_NUMBER *p_elem;
diff --git a/ssl/record/methods/dtls_meth.c b/ssl/record/methods/dtls_meth.c
index 68a5349894..4cb8c4d827 100644
--- a/ssl/record/methods/dtls_meth.c
+++ b/ssl/record/methods/dtls_meth.c
@@ -464,6 +464,20 @@ int dtls_prev_epoch_allows_type(const OSSL_RECORD_LAYER *crypto_rl, int type)
     return !(crypto_rl->epoch == 2 && type == SSL3_RT_APPLICATION_DATA);
 }

+/*
+ * In DTLS 1.3 only the hellos are sent unprotected. Once they are done the
+ * read layer still stays in epoch 0 until the peer's first epoch 2 record
+ * turns up. libssl marks that point by allowing plaintext alerts, and from
+ * then on an alert is the only thing that may still arrive unprotected.
+ */
+static int dtls13_plaintext_allows_type(const OSSL_RECORD_LAYER *rl, int type)
+{
+    return rl->version != DTLS1_3_VERSION
+        || rl->epoch != 0
+        || !rl->allow_plain_alerts
+        || type == SSL3_RT_ALERT;
+}
+
 /*-
  * Call this to get a new input record.
  * It will return <= 0 if more data is needed, normally due to an error
@@ -905,6 +919,12 @@ again:
         goto again;
     }

+    if (!dtls13_plaintext_allows_type(rl, rr->type)) {
+        rr->length = 0;
+        rl->packet_length = 0;
+        goto again;
+    }
+
     if (rr->length == 0) {
         /* No payload data in this record. Dump it */
         rl->packet_length = 0;
diff --git a/ssl/record/rec_layer_d1.c b/ssl/record/rec_layer_d1.c
index 8e0fc46181..19072af9cd 100644
--- a/ssl/record/rec_layer_d1.c
+++ b/ssl/record/rec_layer_d1.c
@@ -284,6 +284,8 @@ start:
                     SSLfatal(sc, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
                     return -1;
                 }
+                /* This must only ever happen once per handshake */
+                sc->dtls13_process_hello = 0;

                 /*
                  * Read the Buffered epoch
diff --git a/ssl/ssl_lib.c b/ssl/ssl_lib.c
index 240ae9bb6e..c71d7f59c6 100644
--- a/ssl/ssl_lib.c
+++ b/ssl/ssl_lib.c
@@ -589,6 +589,7 @@ int ossl_ssl_connection_reset(SSL *s)
     sc->psksession_id = NULL;
     sc->psksession_id_len = 0;
     sc->hello_retry_request = SSL_HRR_NONE;
+    sc->dtls13_process_hello = 0;
     sc->sent_tickets = 0;

     sc->error = 0;
diff --git a/ssl/ssl_local.h b/ssl/ssl_local.h
index 671f49ffc5..24784fbc6d 100644
--- a/ssl/ssl_local.h
+++ b/ssl/ssl_local.h
@@ -3120,6 +3120,7 @@ void dtls1_get_queue_priority(unsigned char *prio64be, unsigned short seq,
     int record_type);
 int dtls1_retransmit_sent_messages(SSL_CONNECTION *s);
 void dtls1_clear_received_buffer(SSL_CONNECTION *s);
+void dtls13_hellos_done(SSL_CONNECTION *s);
 void dtls1_clear_sent_buffer(SSL_CONNECTION *s, int keep_unacked_msgs);
 void dtls1_acknowledge_sent_buffer(SSL_CONNECTION *s, uint64_t before_epoch);
 __owur OSSL_TIME dtls1_default_timeout(void);
diff --git a/ssl/statem/statem_clnt.c b/ssl/statem/statem_clnt.c
index 8dd8b63653..cc291b1ff8 100644
--- a/ssl/statem/statem_clnt.c
+++ b/ssl/statem/statem_clnt.c
@@ -2328,13 +2328,12 @@ MSG_PROCESS_RETURN tls_process_server_hello(SSL_CONNECTION *s, PACKET *pkt)
             }
         }

-        if (!SSL_CONNECTION_IS_DTLS13(s)
-            && !ssl->method->ssl3_enc->change_cipher_state(s,
-                SSL3_CC_HANDSHAKE | SSL3_CHANGE_CIPHER_CLIENT_READ)) {
+        if (SSL_CONNECTION_IS_DTLS13(s)) {
+            dtls13_hellos_done(s);
+        } else if (!ssl->method->ssl3_enc->change_cipher_state(s,
+                       SSL3_CC_HANDSHAKE | SSL3_CHANGE_CIPHER_CLIENT_READ)) {
             /* SSLfatal() already called */
             goto err;
-        } else {
-            s->dtls13_process_hello = 1;
         }
     }

diff --git a/ssl/statem/statem_lib.c b/ssl/statem/statem_lib.c
index fcc10320aa..55ce0a4825 100644
--- a/ssl/statem/statem_lib.c
+++ b/ssl/statem/statem_lib.c
@@ -832,6 +832,15 @@ MSG_PROCESS_RETURN tls_process_finished(SSL_CONNECTION *s, PACKET *pkt)
     int was_first = SSL_IS_FIRST_HANDSHAKE(s);
     int ok;

+    /*
+     * A DTLS 1.3 Finished is protected with the peer's handshake keys. If we
+     * have not installed them yet we have no key to verify it with either.
+     */
+    if (SSL_CONNECTION_IS_DTLS13(s) && dtls1_get_epoch(s, SSL3_CC_READ) < 2) {
+        SSLfatal(s, SSL_AD_UNEXPECTED_MESSAGE, SSL_R_UNEXPECTED_MESSAGE);
+        return MSG_PROCESS_ERROR;
+    }
+
     /* This is a real handshake so make sure we clean it up at the end */
     if (s->server) {
         /*
diff --git a/ssl/statem/statem_srvr.c b/ssl/statem/statem_srvr.c
index 5fe3f2ae59..30bb20a5f2 100644
--- a/ssl/statem/statem_srvr.c
+++ b/ssl/statem/statem_srvr.c
@@ -1244,14 +1244,13 @@ WORK_STATE ossl_statem_server_post_work(SSL_CONNECTION *s, WORK_STATE wst)
                 return WORK_ERROR;
             }

-            if (!SSL_CONNECTION_IS_DTLS13(s)
-                && s->ext.early_data != SSL_EARLY_DATA_ACCEPTED
+            if (SSL_CONNECTION_IS_DTLS13(s)) {
+                dtls13_hellos_done(s);
+            } else if (s->ext.early_data != SSL_EARLY_DATA_ACCEPTED
                 && !ssl->method->ssl3_enc->change_cipher_state(s,
                     SSL3_CC_HANDSHAKE | SSL3_CHANGE_CIPHER_SERVER_READ)) {
                 /* SSLfatal() already called */
                 return WORK_ERROR;
-            } else {
-                s->dtls13_process_hello = 1;
             }
             /*
              * We don't yet know whether the next record we are going to receive
diff --git a/test/dtlstest.c b/test/dtlstest.c
index 8b1612fb24..994f50eec1 100644
--- a/test/dtlstest.c
+++ b/test/dtlstest.c
@@ -1600,6 +1600,100 @@ end:
     return testresult;
 }

+/*
+ * Only the hellos may be sent unprotected. A plaintext handshake message
+ * that arrives after them, or that was buffered before them, must be
+ * dropped and not processed: the handshake read keys have not been
+ * installed yet, so e.g. a Finished would be checked with a zero key.
+ *
+ * 0: Finished to the server after it has sent its flight
+ * 1: Certificate to the server before the ClientHello
+ * 2: Certificate to the client before the ServerHello
+ *
+ * A Finished that arrives before the hellos is already discarded as a stale
+ * retransmission, hence the Certificate. Either is a fatal unexpected message
+ * if it is processed.
+ */
+static int test_dtls13_plaintext_after_hellos(int idx)
+{
+#ifdef OPENSSL_NO_EC
+    const char *group = "ffdhe3072";
+#else
+    const char *group = "P-256";
+#endif
+    SSL_CTX *sctx = NULL, *cctx = NULL;
+    SSL *sssl = NULL, *cssl = NULL;
+    /*
+     * A handshake message header. The remaining SHA384_DIGEST_LENGTH bytes
+     * are not initialised explicitly, so they are zero: that is the body.
+     */
+    unsigned char msg[DTLS1_HM_HEADER_LENGTH + SHA384_DIGEST_LENGTH] = {
+        SSL3_MT_FINISHED, /* msg_type */
+        0, 0, SHA384_DIGEST_LENGTH, /* length: 3 bytes */
+        0, 1, /* message_seq: 2 bytes */
+        0, 0, 0, /* fragment_offset: 3 bytes */
+        0, 0, SHA384_DIGEST_LENGTH /* fragment_length: 3 bytes */
+    };
+    unsigned char pkt[DTLS1_RT_HEADER_LENGTH + sizeof(msg)];
+    size_t pktlen;
+    int testresult = 0;
+
+    if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+            DTLS_client_method(),
+            DTLS1_3_VERSION, DTLS1_3_VERSION,
+            &sctx, &cctx, cert, privkey)))
+        return 0;
+
+    /* Keep ClientHello in one record so sequence number 1 remains unused. */
+    if (!TEST_true(SSL_CTX_set1_groups_list(sctx, group))
+        || !TEST_true(SSL_CTX_set1_groups_list(cctx, group))
+        || !TEST_true(SSL_CTX_set_ciphersuites(sctx, "TLS_AES_256_GCM_SHA384"))
+        || !TEST_true(SSL_CTX_set_ciphersuites(cctx, "TLS_AES_256_GCM_SHA384")))
+        goto end;
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+        goto end;
+
+    /* An all zero message with message sequence 1, in record sequence 1 */
+    if (idx > 0)
+        msg[0] = SSL3_MT_CERTIFICATE;
+    pktlen = make_forged_plaintext_record(pkt, 0, 1, SSL3_RT_HANDSHAKE,
+        msg, sizeof(msg));
+
+    switch (idx) {
+    case 0:
+        if (!TEST_int_le(SSL_connect(cssl), 0)
+            || !TEST_int_le(SSL_accept(sssl), 0)
+            || !inject_client_datagram(cssl, pkt, pktlen))
+            goto end;
+        break;
+    case 1:
+        if (!inject_client_datagram(cssl, pkt, pktlen))
+            goto end;
+        break;
+    case 2:
+        /* The server's wbio is the client's rbio */
+        if (!inject_client_datagram(sssl, pkt, pktlen))
+            goto end;
+        break;
+    default:
+        goto end;
+    }
+
+    /* The genuine handshake must not be disturbed by it */
+    if (!TEST_true(do_dtls13_handshake(sssl, cssl)))
+        goto end;
+
+    testresult = 1;
+end:
+    SSL_free(cssl);
+    SSL_free(sssl);
+    SSL_CTX_free(cctx);
+    SSL_CTX_free(sctx);
+
+    return testresult;
+}
+
 /*
  * RFC 9147 (DTLS 1.3): TLS_AES_128_CCM_8_SHA256 MUST NOT be used in DTLS
  * without additional safeguards against forgery, due to its short
@@ -2516,6 +2610,7 @@ int setup_tests(void)
     ADD_ALL_TESTS(test_dtls13_forged_plaintext_alert, 8);
     ADD_ALL_TESTS(test_dtls13_forged_plaintext_alert_plant, 3);
     ADD_TEST(test_dtls13_epoch0_plaintext_alert);
+    ADD_ALL_TESTS(test_dtls13_plaintext_after_hellos, 3);
     ADD_TEST(test_dtls13_ccm8_not_offered);
     ADD_TEST(test_dtls13_ack_read_timeout);
 #endif