Commit 3d3a147a81 for openssl.org
commit 3d3a147a814ec8fd62c3080247faf17320714e6a
Author: Mounir IDRASSI <mounir.idrassi@idrix.fr>
Date: Mon Aug 31 22:34:51 2026 +0900
test: DTLS 1.3 forged DTLSPlaintext alert regression tests
Add regression coverage for post-handshake and buffered mid-handshake
forged DTLSPlaintext alerts, including shutdown, replay-window,
framing, malformed, and overlong cases. Also cover plaintext handshake
and ACK records. Verify keyed epochs silently discard every record
while application traffic continues.
Keep an epoch-0 control proving legitimate plaintext alerts are still
delivered. Change TLSProxy's DTLS 1.3 teardown close_notify to
DTLSCiphertext using p_ossltest's passthrough AEAD; its former
DTLSPlaintext record is now correctly discarded and caused proxy
recipes to hang.
All forged-record cases fail without the record-layer fix.
Assisted-by: pi:kimi-k3
Reviewed-by: Ryan Hooper <ryanh@openssl.foundation>
Reviewed-by: Frederik Wedel-Heinen <fwh.openssl@gmail.com>
Merge-date: Mon Sep 7 14:41:17 2026
Merged-from: https://github.com/openssl/openssl/pull/32622
diff --git a/test/dtlstest.c b/test/dtlstest.c
index 96ad66bef8..acc9a72048 100644
--- a/test/dtlstest.c
+++ b/test/dtlstest.c
@@ -1087,6 +1087,393 @@ end:
return testresult;
}
+
+/*
+ * Keyed DTLS 1.3 epochs must silently discard DTLSPlaintext records
+ * (RFC 9147 sections 4 and 4.5.2).
+ */
+
+/*
+ * RFC 9147 section 6.1 assigns epoch 2 to handshake traffic and epoch 3 to
+ * the first application traffic keys.
+ */
+#define DTLS13_HANDSHAKE_EPOCH 2
+#define DTLS13_APPLICATION_EPOCH 3
+
+/*
+ * Genuine sequence numbers are close to zero in these tests. A sequence
+ * number of 100 is beyond the 64 record replay window, so accepting it would
+ * make the next genuine record stale.
+ */
+#define DTLS13_FAR_AHEAD_SEQUENCE 100
+
+/*
+ * DTLSPlaintext has a 48 bit sequence number. Its maximum moves the replay
+ * window as far forward as the record format permits. Sequence zero is the
+ * first protected record in a newly installed epoch.
+ */
+#define DTLS13_MAX_PLAINTEXT_SEQUENCE ((((uint64_t)1) << 48) - 1)
+#define DTLS13_FIRST_PROTECTED_SEQUENCE 0
+
+static size_t make_forged_plaintext_record(unsigned char *out,
+ unsigned int epoch, uint64_t seq,
+ unsigned int type,
+ const unsigned char *body,
+ size_t bodylen)
+{
+ out[0] = (unsigned char)type;
+ out[1] = 0xfe;
+ out[2] = 0xfd;
+ out[3] = (unsigned char)(epoch >> 8);
+ out[4] = (unsigned char)epoch;
+ out[5] = (unsigned char)(seq >> 40);
+ out[6] = (unsigned char)(seq >> 32);
+ out[7] = (unsigned char)(seq >> 24);
+ out[8] = (unsigned char)(seq >> 16);
+ out[9] = (unsigned char)(seq >> 8);
+ out[10] = (unsigned char)seq;
+ out[11] = (unsigned char)(bodylen >> 8);
+ out[12] = (unsigned char)bodylen;
+ memcpy(out + 13, body, bodylen);
+ return 13 + bodylen;
+}
+
+static size_t make_forged_alert(unsigned char *out, unsigned int epoch,
+ uint64_t seq, unsigned int level,
+ unsigned int descr)
+{
+ unsigned char body[2];
+
+ body[0] = (unsigned char)level;
+ body[1] = (unsigned char)descr;
+ return make_forged_plaintext_record(out, epoch, seq, SSL3_RT_ALERT,
+ body, sizeof(body));
+}
+
+static int do_dtls13_handshake(SSL *sssl, SSL *cssl)
+{
+ int i;
+
+ /*
+ * An in memory DTLS 1.3 handshake completes in far fewer than 64 calls,
+ * even when its flights are fragmented. This isn't a protocol limit: it
+ * leaves ample room while making a stalled handshake fail promptly.
+ */
+ for (i = 0; i < 64; i++) {
+ int rc = SSL_connect(cssl);
+ int rs = SSL_accept(sssl);
+
+ if (SSL_is_init_finished(cssl) && SSL_is_init_finished(sssl))
+ return 1;
+ if (rc <= 0) {
+ int e = SSL_get_error(cssl, rc);
+
+ if (e != SSL_ERROR_WANT_READ && e != SSL_ERROR_WANT_WRITE)
+ return 0;
+ }
+ if (rs <= 0) {
+ int e = SSL_get_error(sssl, rs);
+
+ if (e != SSL_ERROR_WANT_READ && e != SSL_ERROR_WANT_WRITE)
+ return 0;
+ }
+ }
+ return 0;
+}
+
+/* Drain pending ACK and NewSessionTicket records. */
+static int drain_ssl(SSL *ssl)
+{
+ unsigned char buf[256];
+ int ret;
+
+ do {
+ ret = SSL_read(ssl, buf, sizeof(buf));
+ } while (ret > 0);
+
+ if (!TEST_int_eq(SSL_get_error(ssl, ret), SSL_ERROR_WANT_READ))
+ return 0;
+ ERR_clear_error();
+ return 1;
+}
+
+static int inject_client_datagram(SSL *cssl, const unsigned char *pkt,
+ size_t pktlen)
+{
+ BIO *bio = SSL_get_wbio(cssl);
+
+ if (!TEST_ptr(bio))
+ return 0;
+ return TEST_int_eq(mempacket_test_inject(bio, (const char *)pkt,
+ (int)pktlen, -1,
+ INJECT_PACKET_IGNORE_REC_SEQ),
+ (int)pktlen);
+}
+
+/* Rejected plaintext must not change state or disrupt application data. */
+static int test_dtls13_forged_plaintext_alert(int idx)
+{
+ SSL_CTX *sctx = NULL, *cctx = NULL;
+ SSL *sssl = NULL, *cssl = NULL;
+ unsigned char pkt[5 * 15];
+ size_t pktlen = 0;
+ char msg[] = { 0x00, 0x01, 0x02, 0x03 };
+ char buf[16];
+ int ret, i, 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;
+
+ if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+ goto end;
+
+ if (!TEST_true(do_dtls13_handshake(sssl, cssl)))
+ goto end;
+
+ /* Consume any post-handshake ACKs and NewSessionTickets */
+ if (!TEST_true(drain_ssl(cssl)) || !TEST_true(drain_ssl(sssl)))
+ goto end;
+
+ switch (idx) {
+ case 0:
+ /* Spoofed close_notify */
+ pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+ DTLS13_FAR_AHEAD_SEQUENCE, SSL3_AL_WARNING,
+ SSL3_AD_CLOSE_NOTIFY);
+ break;
+ case 1:
+ /* Spoofed fatal alert (handshake_failure) */
+ pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+ DTLS13_FAR_AHEAD_SEQUENCE, SSL3_AL_FATAL,
+ SSL3_AD_HANDSHAKE_FAILURE);
+ break;
+ case 2:
+ /* user_cancelled with seq 2^48-1 (replay-window poison) */
+ pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+ DTLS13_MAX_PLAINTEXT_SEQUENCE, SSL3_AL_WARNING,
+ SSL_AD_USER_CANCELLED);
+ break;
+ case 3:
+ /* Trip the warning limit while preserving record framing. */
+ for (i = 0; i < 5; i++)
+ pktlen += make_forged_alert(pkt + pktlen,
+ DTLS13_APPLICATION_EPOCH, 10 + i, SSL3_AL_WARNING,
+ SSL_AD_USER_CANCELLED);
+ break;
+ case 4:
+ /* Malformed alert body (fragment length 3) */
+ pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+ DTLS13_FAR_AHEAD_SEQUENCE, SSL3_AL_FATAL,
+ SSL3_AD_HANDSHAKE_FAILURE);
+ pkt[12] = 3;
+ pkt[15] = 0xff;
+ pktlen = 16;
+ break;
+ case 5:
+ /* Alert with an overlong body (longer than content + tag) */
+ pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+ DTLS13_FAR_AHEAD_SEQUENCE, SSL3_AL_WARNING,
+ SSL3_AD_CLOSE_NOTIFY);
+ pkt[11] = 0;
+ pkt[12] = 40;
+ memset(pkt + 15, 0xaa, 40 - 2);
+ pktlen = 13 + 40;
+ break;
+ case 6:
+ /* Type 22 used to reach AAD setup with an uninitialised WPACKET. */
+ {
+ unsigned char body[40];
+
+ memset(body, 0xbb, sizeof(body));
+ pktlen = make_forged_plaintext_record(pkt,
+ DTLS13_APPLICATION_EPOCH, DTLS13_FAR_AHEAD_SEQUENCE,
+ SSL3_RT_HANDSHAKE, body, sizeof(body));
+ }
+ break;
+ case 7:
+ /* Same as case 6 with outer type ack(26) */
+ {
+ unsigned char body[40];
+
+ memset(body, 0xcc, sizeof(body));
+ pktlen = make_forged_plaintext_record(pkt,
+ DTLS13_APPLICATION_EPOCH, DTLS13_FAR_AHEAD_SEQUENCE,
+ SSL3_RT_ACK, body, sizeof(body));
+ }
+ break;
+ default:
+ goto end;
+ }
+
+ if (!inject_client_datagram(cssl, pkt, pktlen))
+ goto end;
+
+ /* It must be silently discarded without changing shutdown state. */
+ ret = SSL_read(sssl, buf, sizeof(buf));
+ if (!TEST_int_le(ret, 0)
+ || !TEST_int_eq(SSL_get_error(sssl, ret), SSL_ERROR_WANT_READ)
+ || !TEST_int_eq(SSL_get_shutdown(sssl), 0))
+ goto end;
+ ERR_clear_error();
+
+ /* The association must still work: application data keeps flowing */
+ if (!TEST_int_eq(SSL_write(cssl, msg, sizeof(msg)), (int)sizeof(msg))
+ || !TEST_int_eq(ret = SSL_read(sssl, buf, sizeof(buf)),
+ (int)sizeof(msg))
+ || !TEST_mem_eq(buf, sizeof(msg), msg, sizeof(msg)))
+ goto end;
+
+ testresult = 1;
+end:
+ SSL_free(cssl);
+ SSL_free(sssl);
+ SSL_CTX_free(cctx);
+ SSL_CTX_free(sctx);
+
+ return testresult;
+}
+
+/*
+ * Plant epoch 2 plaintext after ClientHello and verify that it is discarded
+ * when reparsed under epoch 2.
+ *
+ * idx 0: far ahead sequence makes Finished stale
+ * idx 1: fatal alert aborts the handshake
+ * idx 2: sequence 0 makes the first protected record look replayed
+ */
+static int test_dtls13_forged_plaintext_alert_plant(int idx)
+{
+ SSL_CTX *sctx = NULL, *cctx = NULL;
+ SSL *sssl = NULL, *cssl = NULL;
+ unsigned char pkt[15];
+ size_t pktlen = 0;
+ char msg[] = { 0x00, 0x01, 0x02, 0x03 };
+ char buf[16];
+ 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;
+
+ if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+ goto end;
+
+ /* Send flight 1: ClientHello */
+ if (!TEST_int_le(SSL_connect(cssl), 0))
+ goto end;
+
+ switch (idx) {
+ case 0:
+ pktlen = make_forged_alert(pkt, DTLS13_HANDSHAKE_EPOCH,
+ DTLS13_MAX_PLAINTEXT_SEQUENCE, SSL3_AL_WARNING,
+ SSL_AD_USER_CANCELLED);
+ break;
+ case 1:
+ pktlen = make_forged_alert(pkt, DTLS13_HANDSHAKE_EPOCH,
+ DTLS13_FIRST_PROTECTED_SEQUENCE, SSL3_AL_FATAL,
+ SSL3_AD_HANDSHAKE_FAILURE);
+ break;
+ case 2:
+ pktlen = make_forged_alert(pkt, DTLS13_HANDSHAKE_EPOCH,
+ DTLS13_FIRST_PROTECTED_SEQUENCE, SSL3_AL_WARNING,
+ SSL_AD_USER_CANCELLED);
+ break;
+ default:
+ goto end;
+ }
+
+ if (!inject_client_datagram(cssl, pkt, pktlen))
+ goto end;
+
+ /* The handshake must complete despite the plant */
+ if (!TEST_true(do_dtls13_handshake(sssl, cssl)))
+ goto end;
+
+ /* Application data must flow in both directions */
+ if (!TEST_int_eq(SSL_write(cssl, msg, sizeof(msg)), (int)sizeof(msg))
+ || !TEST_int_eq(SSL_read(sssl, buf, sizeof(buf)), (int)sizeof(msg))
+ || !TEST_mem_eq(buf, sizeof(msg), msg, sizeof(msg))
+ || !TEST_int_eq(SSL_write(sssl, msg, sizeof(msg)), (int)sizeof(msg))
+ || !TEST_int_eq(SSL_read(cssl, buf, sizeof(buf)), (int)sizeof(msg))
+ || !TEST_mem_eq(buf, sizeof(msg), msg, sizeof(msg)))
+ goto end;
+
+ testresult = 1;
+end:
+ SSL_free(cssl);
+ SSL_free(sssl);
+ SSL_CTX_free(cctx);
+ SSL_CTX_free(sctx);
+
+ return testresult;
+}
+
+/* Epoch 0 plaintext alerts must still reach the handshake. */
+static int test_dtls13_epoch0_plaintext_alert(void)
+{
+#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;
+ unsigned char pkt[15];
+ size_t pktlen;
+ int ret, i, 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)))
+ goto end;
+
+ if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+ goto end;
+
+ /* Send flight 1: ClientHello */
+ if (!TEST_int_le(SSL_connect(cssl), 0))
+ goto end;
+
+ /* ClientHello used sequence 0, so inject the alert with sequence 1. */
+ pktlen = make_forged_alert(pkt, 0, 1, SSL3_AL_FATAL,
+ SSL3_AD_HANDSHAKE_FAILURE);
+ if (!inject_client_datagram(cssl, pkt, pktlen))
+ goto end;
+
+ /* The epoch 0 alert must fail the handshake. */
+ ret = SSL_accept(sssl);
+ for (i = 0; i < 3 && ret <= 0
+ && SSL_get_error(sssl, ret) == SSL_ERROR_WANT_READ;
+ i++)
+ ret = SSL_accept(sssl);
+ if (!TEST_int_le(ret, 0)
+ || !TEST_int_eq(SSL_get_error(sssl, ret), SSL_ERROR_SSL)
+ || !TEST_int_eq(ERR_GET_REASON(ERR_peek_last_error()),
+ SSL_AD_REASON_OFFSET + SSL3_AD_HANDSHAKE_FAILURE)
+ || !TEST_true((SSL_get_shutdown(sssl) & SSL_RECEIVED_SHUTDOWN) != 0))
+ goto end;
+ ERR_clear_error();
+
+ testresult = 1;
+end:
+ SSL_free(cssl);
+ SSL_free(sssl);
+ SSL_CTX_free(cctx);
+ SSL_CTX_free(sctx);
+
+ return testresult;
+}
#endif /* OPENSSL_NO_DTLS1_3 */
/* Confirm that we can create a connections using DTLSv1_listen() */
@@ -1171,6 +1558,9 @@ int setup_tests(void)
#ifndef OPENSSL_NO_DTLS1_3
ADD_TEST(test_duplicate_app_data_dtls13);
ADD_ALL_TESTS(test_seq_num_wrap, OSSL_NELEM(seqnum_tests));
+ 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);
#endif
return 1;
diff --git a/util/perl/TLSProxy/Proxy.pm b/util/perl/TLSProxy/Proxy.pm
index 783794e25d..f1d9c2bf97 100644
--- a/util/perl/TLSProxy/Proxy.pm
+++ b/util/perl/TLSProxy/Proxy.pm
@@ -690,32 +690,38 @@ sub construct_alert_message
die "construct_alert_message only valid for DTLSv1.3 tests\n"
if !$self->{isdtls} || !$self->is_tls13();
- my $seqhi = ($sequence_number >> 32) & 0xffff;
- my $seqmi = ($sequence_number >> 16) & 0xffff;
- my $seqlo = ($sequence_number >> 0) & 0xffff;
-
- # DTLS Record Layer Header
- my $content_type = pack("C", 21); # Alert (21)
- my $legacy_version = pack("n", 0xFEFD); # DTLS 1.2 (0xFEFD)
- my $epoch_bytes = pack("n", $epoch); # 2 bytes
- my $sequence_bytes = pack('nnn', $seqhi, $seqmi, $seqlo);
-
- my $length = pack("n", 2); # 2 bytes for alert payload
-
- # Alert Message
my $alert_level = pack("C", 1); # Warning (1)
my $alert_description = pack("C", 0); # close_notify (0)
+ my $alert_payload = $alert_level.$alert_description;
+
+ if ($epoch == 0) {
+ # Epoch 0 uses DTLSPlaintext.
+ my $seqhi = ($sequence_number >> 32) & 0xffff;
+ my $seqmi = ($sequence_number >> 16) & 0xffff;
+ my $seqlo = ($sequence_number >> 0) & 0xffff;
+
+ return pack("C", 21). # Alert (21)
+ pack("n", 0xFEFD). # legacy version DTLS 1.2
+ pack("n", $epoch).
+ pack('nnn', $seqhi, $seqmi, $seqlo).
+ pack("n", length($alert_payload)).
+ $alert_payload;
+ }
- # Combine all parts
- my $packet = $content_type.
- $legacy_version.
- $epoch_bytes.
- $sequence_bytes.
- $length.
- $alert_level.
- $alert_description;
-
- return $packet;
+ # Keyed epochs use DTLSCiphertext. The p_ossltest cipher leaves the
+ # payload unchanged and does not verify the tag. The inner plaintext
+ # holds close_notify and its content type; a zero tag follows.
+ # The header uses a 16 bit sequence number and a length field. Mask the
+ # sequence number with the first two payload bytes, as in Record.pm.
+ my $payload = $alert_payload.pack("C", 21).("\x00" x 16);
+ my $maskhi = unpack("n", substr($payload, 0, 2));
+ my $seqlo = ($sequence_number & 0xffff) ^ $maskhi;
+ my $first = 0x20 | 0x08 | 0x04 | ($epoch & 0x03);
+
+ return pack("C", $first).
+ pack("n", $seqlo).
+ pack("n", length($payload)).
+ $payload;
}
sub process_packet