From eee0c8d7c2fa314a80dc28087942353d4590591c Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Fri, 14 Aug 2026 18:18:08 +0000 Subject: [PATCH 1/5] DTLS 1.3: only ack handshake records that were processed or buffered F-8926 RFC 9147 Sec 7 forbids acking a record whose handshake message was neither processed nor buffered: the peer stops retransmitting it and the handshake deadlocks. Dtls13RecordRecvd() added the ack at the record layer, before _Dtls13HandshakeRecv() had a chance to drop the message (oversized plaintext message, non-ClientHello before the ClientHello is verified, fragmented plaintext message we don't reassemble, a buffering failure, or a handler returning WANT_WRITE). DtlsMsgStore() now reports whether the fragment was stored so the DTLS 1.3 caller can tell "buffered" from "dropped", and the three DtlsMsgSet() paths that dropped a fragment on an allocation failure while returning success now return MEMORY_ERROR. The DTLS 1.2 callers keep ignoring the value, matching their current behaviour. A retransmission of an already processed message is still acked. That message was processed, and the ack is what stops a peer that retransmitted because our previous ack was lost. The post-handshake CertificateRequest stays implicitly acked by the flight it triggers. Acks are keyed by record, so a record packing a dropped message behind a kept one is still acked. Closing that needs record-scoped state in DoProcessReplyEx and is not worth it for a peer that only hurts itself. --- src/dtls13.c | 48 +++++++++++++++++++++++++++++++++------------- src/internal.c | 37 ++++++++++++++++++++++------------- wolfssl/internal.h | 2 +- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/dtls13.c b/src/dtls13.c index 32b4e00df0..5d01f5b099 100644 --- a/src/dtls13.c +++ b/src/dtls13.c @@ -803,6 +803,18 @@ int Dtls13RtxAddAck(WOLFSSL* ssl, w64wrapper epoch, w64wrapper seq) return 0; } +/* RFC 9147 Sec 7: only ack a record whose message we processed or buffered. + * Acking a dropped message stops the peer retransmitting it and deadlocks. */ +static void Dtls13RtxAddAckForCurRecord(WOLFSSL* ssl) +{ + /* Stateless processing must not touch the ssl object. */ + if (!ssl->options.dtlsStateful) + return; + + if (Dtls13RtxAddAck(ssl, ssl->keys.curEpoch64, ssl->keys.curSeq) != 0) + WOLFSSL_MSG("can't save ack fragment"); +} + static void Dtls13RtxFlushAcks(WOLFSSL* ssl) { Dtls13RecordNumber *list, *rn; @@ -934,8 +946,9 @@ static void Dtls13SaveOrFlushClientHello(WOLFSSL* ssl) } } +/* implicitAck is set when the flight we send already acks this record. */ static int Dtls13RtxMsgRecvd(WOLFSSL* ssl, enum HandShakeType hs, - word32 fragOffset) + word32 fragOffset, byte* implicitAck) { WOLFSSL_ENTER("Dtls13RtxMsgRecvd"); @@ -965,6 +978,10 @@ static int Dtls13RtxMsgRecvd(WOLFSSL* ssl, enum HandShakeType hs, /* retransmission detected. */ ssl->dtls13Rtx.retransmit = 1; + /* Already processed, so acking is allowed. It stops a peer that + * retransmitted because our earlier ack was lost. */ + Dtls13RtxAddAckForCurRecord(ssl); + /* the other peer may have retransmitted because an ACK for a flight that needs explicit ACK was lost.*/ if (ssl->dtls13Rtx.seenRecords != NULL) @@ -985,6 +1002,7 @@ static int Dtls13RtxMsgRecvd(WOLFSSL* ssl, enum HandShakeType hs, should be rare and simplifies the code. Otherwise, it would be necessary to track which record number contained a CertificateRequest with a particular context id */ + *implicitAck = 1; Dtls13RtxRemoveCurAck(ssl); } @@ -1616,19 +1634,14 @@ int Dtls13ParseUnifiedRecordLayer(WOLFSSL* ssl, const byte* input, int Dtls13RecordRecvd(WOLFSSL* ssl) { - int ret; - if (ssl->curRL.type != handshake) return 0; if (!ssl->options.dtls13SendMoreAcks) ssl->dtls13FastTimeout = 1; - ret = Dtls13RtxAddAck(ssl, ssl->keys.curEpoch64, ssl->keys.curSeq); - if (ret != 0) - WOLFSSL_MSG("can't save ack fragment"); - - return ret; + /* Acking happens in Dtls13RtxAddAckForCurRecord(). */ + return 0; } static void Dtls13RtxMoveToEndOfList(WOLFSSL* ssl, Dtls13RtxRecord** prevNext, @@ -1874,10 +1887,12 @@ static int _Dtls13HandshakeRecv(WOLFSSL* ssl, byte* input, word32 size, byte usingAsyncCrypto; word32 messageLength; byte handshakeType; + byte implicitAck; word32 idx; int ret; idx = 0; + implicitAck = 0; ret = GetDtlsHandShakeHeader(ssl, input, &idx, &handshakeType, &messageLength, &fragOff, &fragLength, size); if (ret != 0) @@ -1937,7 +1952,8 @@ static int _Dtls13HandshakeRecv(WOLFSSL* ssl, byte* input, word32 size, if (fragOff + fragLength > messageLength) return BUFFER_ERROR; - ret = Dtls13RtxMsgRecvd(ssl, (enum HandShakeType)handshakeType, fragOff); + ret = Dtls13RtxMsgRecvd(ssl, (enum HandShakeType)handshakeType, fragOff, + &implicitAck); if (ret != 0) return ret; @@ -2001,10 +2017,13 @@ static int _Dtls13HandshakeRecv(WOLFSSL* ssl, byte* input, word32 size, ssl->keys.dtls_expected_peer_handshake_number || usingAsyncCrypto) { if (ssl->dtls_rx_msg_list_sz < DTLS_POOL_SZ) { - DtlsMsgStore(ssl, (word16)w64GetLow32(ssl->keys.curEpoch64), - ssl->keys.dtls_peer_handshake_number, - input + DTLS_HANDSHAKE_HEADER_SZ, messageLength, handshakeType, - fragOff, fragLength, ssl->heap); + /* Only ack a fragment we really buffered. */ + if (DtlsMsgStore(ssl, (word16)w64GetLow32(ssl->keys.curEpoch64), + ssl->keys.dtls_peer_handshake_number, + input + DTLS_HANDSHAKE_HEADER_SZ, messageLength, + handshakeType, fragOff, fragLength, ssl->heap) == 0) { + Dtls13RtxAddAckForCurRecord(ssl); + } } else { /* DTLS_POOL_SZ outstanding messages is way more than enough for any @@ -2025,6 +2044,9 @@ static int _Dtls13HandshakeRecv(WOLFSSL* ssl, byte* input, word32 size, if (ret != 0) return ret; + if (!implicitAck) + Dtls13RtxAddAckForCurRecord(ssl); + Dtls13MsgWasProcessed(ssl, (enum HandShakeType)handshakeType); /* check if we have buffered some message */ diff --git a/src/internal.c b/src/internal.c index 004943c4e7..e19b171f1a 100644 --- a/src/internal.c +++ b/src/internal.c @@ -10717,10 +10717,10 @@ int DtlsMsgSet(DtlsMsg* msg, word32 seq, word16 epoch, const byte* data, byte ty } prev->m.m.next = DtlsMsgCreateFragBucket(fragOffset, data, fragSz, heap); - if (prev->m.m.next != NULL) { - msg->bytesReceived += fragSz; - msg->fragBucketListCount++; - } + if (prev->m.m.next == NULL) + return MEMORY_ERROR; + msg->bytesReceived += fragSz; + msg->fragBucketListCount++; } else if (fragOffsetEnd < cur->m.m.offset) { /* Fragment is entirely before cur with a gap */ @@ -10741,6 +10741,7 @@ int DtlsMsgSet(DtlsMsg* msg, word32 seq, word16 epoch, const byte* data, byte ty else { /* reset on error */ *prev_next = cur; + return MEMORY_ERROR; } } else { @@ -10754,8 +10755,11 @@ int DtlsMsgSet(DtlsMsg* msg, word32 seq, word16 epoch, const byte* data, byte ty /* We can combine the buckets */ *prev_next = DtlsMsgCombineFragBuckets(msg, cur, next, fragOffset, data, fragSz, heap); - if (*prev_next == NULL) /* reset on error */ + if (*prev_next == NULL) { + /* reset on error */ *prev_next = cur; + return MEMORY_ERROR; + } } } } @@ -10777,7 +10781,8 @@ DtlsMsg* DtlsMsgFind(DtlsMsg* head, word16 epoch, word32 seq) } -void DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, const byte* data, +/* Returns 0 when the fragment was stored, negative when it was dropped. */ +int DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, const byte* data, word32 dataSz, byte type, word32 fragOffset, word32 fragSz, void* heap) { /* See if seq exists in the list. If it isn't in the list, make @@ -10799,6 +10804,7 @@ void DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, const byte* data, DtlsMsg* head = ssl->dtls_rx_msg_list; byte encrypted = ssl->keys.decryptedCur == 1; + int ret = 0; WOLFSSL_ENTER("DtlsMsgStore"); if (head != NULL) { @@ -10807,11 +10813,13 @@ void DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, const byte* data, cur = DtlsMsgNew(dataSz, 0, heap); if (cur == NULL) { WOLFSSL_MSG("DtlsMsgNew allocation failed"); - ssl->error = MEMORY_E; + ret = MEMORY_E; + ssl->error = ret; } else { - if (DtlsMsgSet(cur, seq, epoch, data, type, - fragOffset, fragSz, heap, dataSz, encrypted) < 0) { + ret = DtlsMsgSet(cur, seq, epoch, data, type, + fragOffset, fragSz, heap, dataSz, encrypted); + if (ret < 0) { DtlsMsgDelete(cur, heap); } else { @@ -10822,7 +10830,7 @@ void DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, const byte* data, } else { /* If this fails, the data is just dropped. */ - DtlsMsgSet(cur, seq, epoch, data, type, fragOffset, + ret = DtlsMsgSet(cur, seq, epoch, data, type, fragOffset, fragSz, heap, dataSz, encrypted); } } @@ -10830,10 +10838,11 @@ void DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, const byte* data, head = DtlsMsgNew(dataSz, 0, heap); if (head == NULL) { WOLFSSL_MSG("DtlsMsgNew allocation failed"); - ssl->error = MEMORY_E; + ret = MEMORY_E; + ssl->error = ret; } - else if (DtlsMsgSet(head, seq, epoch, data, type, fragOffset, - fragSz, heap, dataSz, encrypted) < 0) { + else if ((ret = DtlsMsgSet(head, seq, epoch, data, type, fragOffset, + fragSz, heap, dataSz, encrypted)) < 0) { DtlsMsgDelete(head, heap); head = NULL; } @@ -10843,6 +10852,8 @@ void DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, const byte* data, } ssl->dtls_rx_msg_list = head; + + return ret; } diff --git a/wolfssl/internal.h b/wolfssl/internal.h index dd20de7d6c..410a56be69 100644 --- a/wolfssl/internal.h +++ b/wolfssl/internal.h @@ -7431,7 +7431,7 @@ WOLFSSL_LOCAL word32 MacSize(const WOLFSSL* ssl); word32 totalLen, byte encrypted); WOLFSSL_TEST_VIS DtlsMsg* DtlsMsgFind(DtlsMsg* head, word16 epoch, word32 seq); - WOLFSSL_TEST_VIS void DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, + WOLFSSL_TEST_VIS int DtlsMsgStore(WOLFSSL* ssl, word16 epoch, word32 seq, const byte* data, word32 dataSz, byte type, word32 fragOffset, word32 fragSz, void* heap); From 5fd543a852e2a56e3c6b680baf53461ec36bb070 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Fri, 14 Aug 2026 18:18:23 +0000 Subject: [PATCH 2/5] DTLS 1.2: refuse to send a record that would wrap the sequence number F-8928 RFC 6347 Sec 4.1 requires abandoning the association or rehandshaking before the sequence number wraps. DtlsSEQIncrement() only carried from the low word into the word16 high word, so at 0xFFFF:0xFFFFFFFF the 48 bit epoch-local counter silently restarted at 0, reusing sequence numbers - and with them AEAD nonces - under the same key. The check lives in BuildMessage() so it only covers protected records. The epoch 0 counter is copied from the peer's ClientHello by SendHelloVerifyRequest()/DtlsSetSeqNumForReply(), so checking the plaintext send paths would let one datagram kill every handshake. --- src/internal.c | 37 ++++++++++++++++++++++++++++++++++- tests/api/test_dtls.c | 45 +++++++++++++++++++++++++++++++++++++++++++ tests/api/test_dtls.h | 2 ++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/internal.c b/src/internal.c index e19b171f1a..f22c78d097 100644 --- a/src/internal.c +++ b/src/internal.c @@ -10306,6 +10306,30 @@ static WC_INLINE void DtlsSEQIncrement(WOLFSSL* ssl, int order) } } } + +#ifndef WOLFSSL_NO_TLS12 +/* Is the send sequence number at its last legal value? DtlsSEQIncrement() + * would wrap the word16 high half to 0 and reuse sequence numbers. */ +static WC_INLINE int DtlsSEQAtMax(WOLFSSL* ssl, int order) +{ +#ifdef HAVE_SECURE_RENEGOTIATION + order = DtlsCheckOrder(ssl, order); +#endif + + if (order == PREV_ORDER) { + return ssl->keys.dtls_prev_sequence_number_hi == 0xFFFF && + ssl->keys.dtls_prev_sequence_number_lo == 0xFFFFFFFFU; + } + else if (order == PEER_ORDER) { + /* the peer's sequence number is taken from the record */ + return 0; + } + else { + return ssl->keys.dtls_sequence_number_hi == 0xFFFF && + ssl->keys.dtls_sequence_number_lo == 0xFFFFFFFFU; + } +} +#endif /* !WOLFSSL_NO_TLS12 */ #endif /* WOLFSSL_DTLS */ #if defined(WOLFSSL_DTLS) || !defined(WOLFSSL_NO_TLS12) @@ -26338,7 +26362,7 @@ int BuildMessage(WOLFSSL* ssl, byte* output, int outSz, const byte* input, * increments, so refuse at hi == lo == 0xFFFFFFFF (2^64-1): that last legal * value is deliberately sacrificed to avoid wrapping to 0 and reusing * sequence number 0. The caller must renegotiate or close. DTLS sequence - * numbers are epoch-scoped and handled elsewhere. */ + * numbers are epoch-scoped and checked just below. */ if (!sizeOnly && !ssl->options.dtls && ssl->keys.sequence_number_hi == 0xFFFFFFFFU && ssl->keys.sequence_number_lo == 0xFFFFFFFFU) { @@ -26347,6 +26371,17 @@ int BuildMessage(WOLFSSL* ssl, byte* output, int outSz, const byte* input, return SEQUENCE_NUMBER_E; } +#ifdef WOLFSSL_DTLS + /* RFC 6347 Sec 4.1: don't wrap the sequence number. Only protected records + * reach here, so the epoch 0 counter SendHelloVerifyRequest() copies from + * the peer is unaffected. */ + if (!sizeOnly && ssl->options.dtls && DtlsSEQAtMax(ssl, epochOrder)) { + WOLFSSL_MSG("DTLS write sequence number would wrap"); + WOLFSSL_ERROR_VERBOSE(SEQUENCE_NUMBER_E); + return SEQUENCE_NUMBER_E; + } +#endif + #ifdef WOLFSSL_ASYNC_CRYPT ret = WC_NO_PENDING_E; if (asyncOkay) { diff --git a/tests/api/test_dtls.c b/tests/api/test_dtls.c index fa70f7126d..ecc625c206 100644 --- a/tests/api/test_dtls.c +++ b/tests/api/test_dtls.c @@ -6110,6 +6110,51 @@ int test_dtls_old_seq_number(void) return EXPECT_RESULT(); } +int test_dtls12_seq_num_wrap(void) +{ + EXPECT_DECLS; +#if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_DTLS) && \ + !defined(WOLFSSL_NO_TLS12) + WOLFSSL_CTX *ctx_c = NULL, *ctx_s = NULL; + WOLFSSL *ssl_c = NULL, *ssl_s = NULL; + struct test_memio_ctx test_ctx; + const char msg[] = "wrap"; + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s, + wolfDTLSv1_2_client_method, wolfDTLSv1_2_server_method), 0); + ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0); + + /* one below the last legal 48 bit sequence number */ + if (EXPECT_SUCCESS() && ssl_c != NULL) { + ssl_c->keys.dtls_sequence_number_hi = 0xFFFF; + ssl_c->keys.dtls_sequence_number_lo = 0xFFFFFFFEU; + } + + ExpectIntEQ(wolfSSL_write(ssl_c, msg, (int)sizeof(msg)), (int)sizeof(msg)); + if (EXPECT_SUCCESS() && ssl_c != NULL) { + ExpectIntEQ((int)ssl_c->keys.dtls_sequence_number_hi, 0xFFFF); + ExpectIntEQ(ssl_c->keys.dtls_sequence_number_lo, 0xFFFFFFFFU); + } + + /* the next record would wrap the counter back to 0 */ + test_memio_clear_buffer(&test_ctx, 0); + ExpectIntLT(wolfSSL_write(ssl_c, msg, (int)sizeof(msg)), 0); + ExpectIntEQ(test_ctx.s_len, 0); + if (EXPECT_SUCCESS() && ssl_c != NULL) { + ExpectIntEQ((int)ssl_c->keys.dtls_sequence_number_hi, 0xFFFF); + ExpectIntEQ(ssl_c->keys.dtls_sequence_number_lo, 0xFFFFFFFFU); + } + + wolfSSL_free(ssl_c); + wolfSSL_CTX_free(ctx_c); + wolfSSL_free(ssl_s); + wolfSSL_CTX_free(ctx_s); +#endif + return EXPECT_RESULT(); +} + /*-- dtls12_missing_finished (api.c lines 32007,32068) ---*/ int test_dtls12_missing_finished(void) { diff --git a/tests/api/test_dtls.h b/tests/api/test_dtls.h index 37d8a8a2a4..ea98ef2ae3 100644 --- a/tests/api/test_dtls.h +++ b/tests/api/test_dtls.h @@ -87,6 +87,7 @@ int test_dtls_client_hello_timeout(void); int test_dtls_dropped_ccs(void); int test_dtls_seq_num_downgrade(void); int test_dtls_old_seq_number(void); +int test_dtls12_seq_num_wrap(void); int test_dtls12_missing_finished(void); int test_wolfSSL_dtls_export(void); int test_wolfSSL_dtls_export_peers(void); @@ -172,6 +173,7 @@ int test_WOLFSSL_dtls_version_alert(void); TEST_DECL_GROUP("dtls", test_dtls_dropped_ccs), \ TEST_DECL_GROUP("dtls", test_dtls_seq_num_downgrade), \ TEST_DECL_GROUP("dtls", test_dtls_old_seq_number), \ + TEST_DECL_GROUP("dtls", test_dtls12_seq_num_wrap), \ TEST_DECL_GROUP("dtls", test_dtls12_missing_finished), \ TEST_DECL_GROUP("dtls", test_dtls12_export_import_etm), \ TEST_DECL_GROUP("dtls", test_dtls13_min_rtx_interval), \ From f39c256d1ed893f4326b4d5880639520e5d1b9b8 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Fri, 14 Aug 2026 18:18:38 +0000 Subject: [PATCH 3/5] DTLS 1.2: refuse a peer initiated renegotiation that would wrap the epoch F-8929 RFC 6347 Sec 4.1 requires a new association rather than a wrapped epoch. _Rehandshake() refuses a locally initiated renegotiation at epoch 0xFFFF, but the server side accepted a post-handshake ClientHello unconditionally, after which SendFinished() and DoChangeCipherSpecTls12() wrapped the word16 send and receive epochs to 0. Epoch 0 means "unprotected" throughout the DTLS 1.2 record layer, so the association loses its plaintext-record rejection and SCR key selection breaks. Refused with a warning no_renegotiation alert, like the WOLFSSL_OP_NO_RENEGOTIATION case above it, so the established association stays usable. _Rehandshake() now also looks at the receive epoch. --- src/internal.c | 11 +++++++ src/ssl_api_ext.c | 3 +- tests/api/test_dtls.c | 68 +++++++++++++++++++++++++++++++++++++++++++ tests/api/test_dtls.h | 2 ++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/internal.c b/src/internal.c index f22c78d097..7d621f7db1 100644 --- a/src/internal.c +++ b/src/internal.c @@ -20110,6 +20110,17 @@ int DoHandShakeMsgType(WOLFSSL* ssl, byte* input, word32* inOutIdx, *inOutIdx = expectedIdx; return SendAlert(ssl, alert_warning, no_renegotiation); } +#ifdef WOLFSSL_DTLS + /* RFC 6347 Sec 4.1: the epoch must not wrap. Both directions are + * checked because a second ClientHello can arrive between the peer's + * CCS and our own. */ + if (ssl->options.dtls && (ssl->keys.dtls_epoch == 0xFFFF || + ssl->keys.peerSeq[0].nextEpoch == 0xFFFF)) { + WOLFSSL_MSG("Refusing renegotiation. Epoch would wrap"); + *inOutIdx = expectedIdx; + return SendAlert(ssl, alert_warning, no_renegotiation); + } +#endif ret = ResetHandshakeStateForReneg(ssl); if (ret != 0) return ret; diff --git a/src/ssl_api_ext.c b/src/ssl_api_ext.c index 2be0da0375..ffadca0721 100644 --- a/src/ssl_api_ext.c +++ b/src/ssl_api_ext.c @@ -1008,7 +1008,8 @@ static int _Rehandshake(WOLFSSL* ssl) ret = SECURE_RENEGOTIATION_E; } #ifdef WOLFSSL_DTLS - else if ((ssl->options.dtls) && (ssl->keys.dtls_epoch == 0xFFFF)) { + else if ((ssl->options.dtls) && ((ssl->keys.dtls_epoch == 0xFFFF) || + (ssl->keys.peerSeq[0].nextEpoch == 0xFFFF))) { WOLFSSL_MSG("Secure Renegotiation not allowed. Epoch would wrap"); ret = SECURE_RENEGOTIATION_E; } diff --git a/tests/api/test_dtls.c b/tests/api/test_dtls.c index ecc625c206..1e7fb2d537 100644 --- a/tests/api/test_dtls.c +++ b/tests/api/test_dtls.c @@ -6110,6 +6110,74 @@ int test_dtls_old_seq_number(void) return EXPECT_RESULT(); } +/* Renegotiate at the given server epoch and report whether it was allowed. */ +#if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_DTLS) && \ + !defined(WOLFSSL_NO_TLS12) && defined(HAVE_SECURE_RENEGOTIATION) && \ + defined(HAVE_SERVER_RENEGOTIATION_INFO) && !defined(NO_WOLFSSL_SERVER) && \ + !defined(NO_WOLFSSL_CLIENT) +static int test_dtls12_scr_epoch_wrap_at(word16 epoch, int expectAccept) +{ + EXPECT_DECLS; + WOLFSSL_CTX *ctx_c = NULL, *ctx_s = NULL; + WOLFSSL *ssl_c = NULL, *ssl_s = NULL; + struct test_memio_ctx test_ctx; + WOLFSSL_ALERT_HISTORY h; + char readBuf[16]; + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s, + wolfDTLSv1_2_client_method, wolfDTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_UseSecureRenegotiation(ssl_c), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSecureRenegotiation(ssl_s), WOLFSSL_SUCCESS); + ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0); + + /* Only the server's send epoch. Moving the receive epochs would drop the + * ClientHello, and moving the client's would trip _Rehandshake(). */ + if (EXPECT_SUCCESS() && ssl_s != NULL) + ssl_s->keys.dtls_epoch = epoch; + + ExpectIntEQ(wolfSSL_Rehandshake(ssl_c), -1); + ExpectIntEQ(wolfSSL_get_error(ssl_c, -1), WOLFSSL_ERROR_WANT_READ); + + XMEMSET(readBuf, 0, sizeof(readBuf)); + ExpectIntEQ(wolfSSL_read(ssl_s, readBuf, sizeof(readBuf)), -1); + + ExpectIntEQ(wolfSSL_SSL_renegotiate_pending(ssl_s), + expectAccept ? 1 : 0); + if (!expectAccept) { + /* refused with a warning alert, epoch untouched */ + XMEMSET(&h, 0, sizeof(h)); + ExpectIntEQ(wolfSSL_get_alert_history(ssl_s, &h), WOLFSSL_SUCCESS); + ExpectIntEQ(h.last_tx.level, alert_warning); + ExpectIntEQ(h.last_tx.code, no_renegotiation); + if (EXPECT_SUCCESS() && ssl_s != NULL) + ExpectIntEQ((int)ssl_s->keys.dtls_epoch, (int)epoch); + } + + wolfSSL_free(ssl_c); + wolfSSL_CTX_free(ctx_c); + wolfSSL_free(ssl_s); + wolfSSL_CTX_free(ctx_s); + + return EXPECT_RESULT(); +} +#endif + +int test_dtls12_scr_epoch_wrap(void) +{ + EXPECT_DECLS; +#if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_DTLS) && \ + !defined(WOLFSSL_NO_TLS12) && defined(HAVE_SECURE_RENEGOTIATION) && \ + defined(HAVE_SERVER_RENEGOTIATION_INFO) && !defined(NO_WOLFSSL_SERVER) && \ + !defined(NO_WOLFSSL_CLIENT) + ExpectIntEQ(test_dtls12_scr_epoch_wrap_at(0xFFFF, 0), TEST_SUCCESS); + /* one below the boundary still renegotiates */ + ExpectIntEQ(test_dtls12_scr_epoch_wrap_at(0xFFFE, 1), TEST_SUCCESS); +#endif + return EXPECT_RESULT(); +} + int test_dtls12_seq_num_wrap(void) { EXPECT_DECLS; diff --git a/tests/api/test_dtls.h b/tests/api/test_dtls.h index ea98ef2ae3..e21be9e229 100644 --- a/tests/api/test_dtls.h +++ b/tests/api/test_dtls.h @@ -88,6 +88,7 @@ int test_dtls_dropped_ccs(void); int test_dtls_seq_num_downgrade(void); int test_dtls_old_seq_number(void); int test_dtls12_seq_num_wrap(void); +int test_dtls12_scr_epoch_wrap(void); int test_dtls12_missing_finished(void); int test_wolfSSL_dtls_export(void); int test_wolfSSL_dtls_export_peers(void); @@ -174,6 +175,7 @@ int test_WOLFSSL_dtls_version_alert(void); TEST_DECL_GROUP("dtls", test_dtls_seq_num_downgrade), \ TEST_DECL_GROUP("dtls", test_dtls_old_seq_number), \ TEST_DECL_GROUP("dtls", test_dtls12_seq_num_wrap), \ + TEST_DECL_GROUP("dtls", test_dtls12_scr_epoch_wrap), \ TEST_DECL_GROUP("dtls", test_dtls12_missing_finished), \ TEST_DECL_GROUP("dtls", test_dtls12_export_import_etm), \ TEST_DECL_GROUP("dtls", test_dtls13_min_rtx_interval), \ From 4e3b29218bdd92eb7bf56a69e2baecba822dd669 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 12 Aug 2026 15:20:06 +0000 Subject: [PATCH 4/5] DTLS 1.2: require the CID record type when a CID is negotiated F-8931 GetDtlsRecordHeader() rejected a dtls12_cid record when no receive CID was configured but had no check the other way round. The CID MAC/AAD covers the real content type, not the wire type, so an on-path attacker could take a genuine dtls12_cid record, rewrite the type byte to application_data and strip the CID from the header: the record still authenticated, and because the wire type was no longer dtls12_cid the inner content type and padding were not stripped. The result was content type confusion - a handshake message, alert or CCS delivered as application data, or app data fed to the alert handler - plus the trailing real content type byte leaking into what the application reads. Only protected records are checked, so the plaintext epoch 0 flight that follows the CID extension exchange is unaffected. --- src/internal.c | 9 +++++- tests/api/test_dtls.c | 65 +++++++++++++++++++++++++++++++++++++++++++ tests/api/test_dtls.h | 2 ++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/internal.c b/src/internal.c index 7d621f7db1..77972152e7 100644 --- a/src/internal.c +++ b/src/internal.c @@ -13120,7 +13120,14 @@ static int GetDtlsRecordHeader(WOLFSSL* ssl, word32* inOutIdx, } #ifdef WOLFSSL_DTLS_CID - if (rh->type == dtls12_cid && (cidSz = DtlsGetCidRxSize(ssl)) == 0) + if (rh->type == dtls12_cid) { + if ((cidSz = DtlsGetCidRxSize(ssl)) == 0) + return DTLS_CID_ERROR; + } + /* RFC 9146 Sec 4: with a receive CID every protected record must use the + * dtls12_cid type. The MAC covers the inner content type, not the wire + * type, so a record re-framed as application_data still authenticates. */ + else if (ssl->keys.curEpoch != 0 && DtlsGetCidRxSize(ssl) != 0) return DTLS_CID_ERROR; #endif diff --git a/tests/api/test_dtls.c b/tests/api/test_dtls.c index 1e7fb2d537..46e124e63a 100644 --- a/tests/api/test_dtls.c +++ b/tests/api/test_dtls.c @@ -6110,6 +6110,71 @@ int test_dtls_old_seq_number(void) return EXPECT_RESULT(); } +/* Re-frame a protected dtls12_cid record as the given wire type and strip the + * CID. The MAC doesn't cover the wire type, so the record still authenticates + * and is dispatched on the forged type. */ +#if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_DTLS_CID) \ + && !defined(WOLFSSL_NO_TLS12) +static int test_dtls12_cid_type_swap_to(byte type) +{ + EXPECT_DECLS; + WOLFSSL_CTX *ctx_c = NULL, *ctx_s = NULL; + WOLFSSL *ssl_c = NULL, *ssl_s = NULL; + struct test_memio_ctx test_ctx; + unsigned char client_cid[] = { 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 }; + const char msg[] = "hello"; + char readBuf[32]; + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s, + wolfDTLSv1_2_client_method, wolfDTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_dtls_cid_use(ssl_c), 1); + ExpectIntEQ(wolfSSL_dtls_cid_use(ssl_s), 1); + ExpectIntEQ(wolfSSL_dtls_cid_set(ssl_s, client_cid, sizeof(client_cid)), 1); + ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0); + + test_memio_clear_buffer(&test_ctx, 0); + ExpectIntEQ(wolfSSL_write(ssl_c, msg, (int)sizeof(msg)), (int)sizeof(msg)); + ExpectIntEQ(test_ctx.s_buff[0], dtls12_cid); + ExpectIntGT(test_ctx.s_len, (int)(DTLS12_CID_OFFSET + sizeof(client_cid))); + + /* drop the CID and forge the wire type */ + if (EXPECT_SUCCESS()) { + int cidSz = (int)sizeof(client_cid); + test_ctx.s_buff[0] = type; + XMEMMOVE(test_ctx.s_buff + DTLS12_CID_OFFSET, + test_ctx.s_buff + DTLS12_CID_OFFSET + cidSz, + (size_t)(test_ctx.s_len - DTLS12_CID_OFFSET - cidSz)); + test_ctx.s_len -= cidSz; + test_ctx.s_msg_sizes[0] -= cidSz; + } + + /* the record must be dropped, not delivered or acted on */ + XMEMSET(readBuf, 0, sizeof(readBuf)); + ExpectIntEQ(wolfSSL_read(ssl_s, readBuf, sizeof(readBuf)), -1); + ExpectIntEQ(wolfSSL_get_error(ssl_s, -1), WOLFSSL_ERROR_WANT_READ); + + wolfSSL_free(ssl_c); + wolfSSL_CTX_free(ctx_c); + wolfSSL_free(ssl_s); + wolfSSL_CTX_free(ctx_s); + + return EXPECT_RESULT(); +} +#endif + +int test_dtls12_cid_record_type_swap(void) +{ + EXPECT_DECLS; +#if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_DTLS_CID) \ + && !defined(WOLFSSL_NO_TLS12) + ExpectIntEQ(test_dtls12_cid_type_swap_to(application_data), TEST_SUCCESS); + ExpectIntEQ(test_dtls12_cid_type_swap_to(alert), TEST_SUCCESS); +#endif + return EXPECT_RESULT(); +} + /* Renegotiate at the given server epoch and report whether it was allowed. */ #if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_DTLS) && \ !defined(WOLFSSL_NO_TLS12) && defined(HAVE_SECURE_RENEGOTIATION) && \ diff --git a/tests/api/test_dtls.h b/tests/api/test_dtls.h index e21be9e229..2ee68d2585 100644 --- a/tests/api/test_dtls.h +++ b/tests/api/test_dtls.h @@ -89,6 +89,7 @@ int test_dtls_seq_num_downgrade(void); int test_dtls_old_seq_number(void); int test_dtls12_seq_num_wrap(void); int test_dtls12_scr_epoch_wrap(void); +int test_dtls12_cid_record_type_swap(void); int test_dtls12_missing_finished(void); int test_wolfSSL_dtls_export(void); int test_wolfSSL_dtls_export_peers(void); @@ -176,6 +177,7 @@ int test_WOLFSSL_dtls_version_alert(void); TEST_DECL_GROUP("dtls", test_dtls_old_seq_number), \ TEST_DECL_GROUP("dtls", test_dtls12_seq_num_wrap), \ TEST_DECL_GROUP("dtls", test_dtls12_scr_epoch_wrap), \ + TEST_DECL_GROUP("dtls", test_dtls12_cid_record_type_swap), \ TEST_DECL_GROUP("dtls", test_dtls12_missing_finished), \ TEST_DECL_GROUP("dtls", test_dtls12_export_import_etm), \ TEST_DECL_GROUP("dtls", test_dtls13_min_rtx_interval), \ From de4b15ac495d1cbd850f72856b2619944aef22f8 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Fri, 14 Aug 2026 18:19:42 +0000 Subject: [PATCH 5/5] DTLS 1.3: fix output buffer under-reservation with a Connection ID F-9061 TLSX_ConnectionID_Parse() took the peer's CID length straight off the wire, bounded only by the extension length, and installed it as our TX CID. wolfSSL_dtls_cid_set(), DtlsCidReplaceTx() and DoDtls13NewConnectionId() all bound it by DTLS_CID_MAX_SIZE; negotiation did not. SendTls13Finished() and SendTls13KeyUpdate() reserve MAX_MSG_EXTRA, which only budgets RECORD_HEADER_SZ for the record header, then write at Dtls13GetRlHeaderLength(), which is 5 + the TX CID length. With a 255 byte peer CID that is a heap out-of-bounds write of the handshake header and the Finished MAC: the output buffer is grown to exactly the requested size, so there is no slack. Confirmed with AddressSanitizer. Both are fixed: the peer's CID is now bounded at negotiation, and the two send paths reserve the record header they actually use, so raising DTLS_CID_MAX_SIZE stays safe. DTLS 1.2 was never affected - it adds the CID size to its reservation and its MAC/AAD builders already reject an oversized CID. test_dtls13_build_post_hs_msg() sized its message buffer for the default DTLS_CID_MAX_SIZE, which overflowed the stack in a build that raised it. --- src/dtls.c | 10 ++++++++ src/tls13.c | 10 +++++++- tests/api/test_dtls.c | 55 +++++++++++++++++++++++++++++++++++++++++-- tests/api/test_dtls.h | 2 ++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/dtls.c b/src/dtls.c index 27fe518102..a9ae17daba 100644 --- a/src/dtls.c +++ b/src/dtls.c @@ -1323,6 +1323,16 @@ int TLSX_ConnectionID_Parse(WOLFSSL* ssl, const byte* input, word16 length, if (cidSz + OPAQUE8_LEN > length) return BUFFER_ERROR; +#if DTLS_CID_MAX_SIZE < 255 + /* The peer's CID becomes our TX CID. RFC 9146 allows up to 255 bytes, our + * send buffers are sized for DTLS_CID_MAX_SIZE. */ + if (cidSz > DTLS_CID_MAX_SIZE) { + WOLFSSL_MSG("Peer CID larger than DTLS_CID_MAX_SIZE"); + WOLFSSL_ERROR_VERBOSE(DTLS_CID_ERROR); + return DTLS_CID_ERROR; + } +#endif + info = DtlsCidGetInfo(ssl); if (info == NULL) return BAD_STATE_E; diff --git a/src/tls13.c b/src/tls13.c index 5f3a749b04..b4138d677f 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -12900,6 +12900,12 @@ static int SendTls13Finished(WOLFSSL* ssl) #endif /* WOLFSSL_DTLS13 */ outputSz = WC_MAX_DIGEST_SIZE + DTLS_HANDSHAKE_HEADER_SZ + MAX_MSG_EXTRA; +#ifdef WOLFSSL_DTLS13 + /* MAX_MSG_EXTRA only budgets RECORD_HEADER_SZ. The DTLS 1.3 unified header + * is longer and grows with the TX CID. */ + if (isDtls) + outputSz += Dtls13GetRlHeaderLength(ssl, 1); +#endif /* WOLFSSL_DTLS13 */ /* Check buffers are big enough and grow if needed. */ if ((ret = CheckAvailableSize(ssl, outputSz)) != 0) return ret; @@ -13167,7 +13173,9 @@ int SendTls13KeyUpdate(WOLFSSL* ssl) } } - outputSz = OPAQUE8_LEN + MAX_MSG_EXTRA; + /* i already carries the real record and handshake header lengths. + * MAX_MSG_EXTRA only budgets RECORD_HEADER_SZ. */ + outputSz = (int)i + OPAQUE8_LEN + MAX_MSG_EXTRA; /* Check buffers are big enough and grow if needed. */ if ((ret = CheckAvailableSize(ssl, outputSz)) != 0) return ret; diff --git a/tests/api/test_dtls.c b/tests/api/test_dtls.c index 46e124e63a..93253eab82 100644 --- a/tests/api/test_dtls.c +++ b/tests/api/test_dtls.c @@ -737,10 +737,14 @@ static int test_dtls13_build_post_hs_msg(WOLFSSL* ssl_c, WOLFSSL* ssl_s, byte hsType, const byte* body, word16 bodyLen, byte* rec, int* recSz) { EXPECT_DECLS; - byte msg[64]; + /* largest body a caller builds: a cid_immediate with an oversized CID */ + byte msg[DTLS_HANDSHAKE_HEADER_SZ + 2 + 1 + (DTLS_CID_MAX_SIZE + 1) + 1]; size_t idx = 0; - ExpectIntLE(DTLS_HANDSHAKE_HEADER_SZ + bodyLen, sizeof(msg)); + if (DTLS_HANDSHAKE_HEADER_SZ + bodyLen > (int)sizeof(msg)) { + ExpectFail(); + return EXPECT_RESULT(); + } msg[idx++] = hsType; c32to24(bodyLen, msg + idx); @@ -996,6 +1000,53 @@ int test_dtls13_request_connection_id(void) return EXPECT_RESULT(); } +/* Parse a connection_id extension of the given CID length as a ServerHello. */ +#if defined(WOLFSSL_DTLS_CID) && !defined(NO_WOLFSSL_CLIENT) && \ + DTLS_CID_MAX_SIZE < 255 +static int test_dtls_cid_negotiate_sz(byte cidSz, int expected) +{ + EXPECT_DECLS; + WOLFSSL_CTX* ctx = NULL; + WOLFSSL* ssl = NULL; + byte ext[4 + 1 + 255]; + word16 extSz = 0; + word16 i; + + ExpectNotNull(ctx = wolfSSL_CTX_new(wolfDTLSv1_2_client_method())); + ExpectNotNull(ssl = wolfSSL_new(ctx)); + ExpectIntEQ(wolfSSL_dtls_cid_use(ssl), 1); + + c16toa((word16)TLSX_CONNECTION_ID, ext + extSz); + extSz += OPAQUE16_LEN; + c16toa((word16)(cidSz + 1), ext + extSz); + extSz += OPAQUE16_LEN; + ext[extSz++] = cidSz; + for (i = 0; i < cidSz; i++) + ext[extSz++] = 0x5A; + + ExpectIntEQ(TLSX_Parse(ssl, ext, extSz, server_hello, NULL), expected); + + wolfSSL_free(ssl); + wolfSSL_CTX_free(ctx); + + return EXPECT_RESULT(); +} +#endif + +int test_dtls_cid_negotiate_oversize(void) +{ + EXPECT_DECLS; +#if defined(WOLFSSL_DTLS_CID) && !defined(NO_WOLFSSL_CLIENT) && \ + DTLS_CID_MAX_SIZE < 255 + /* send paths size their buffers for at most DTLS_CID_MAX_SIZE */ + ExpectIntEQ(test_dtls_cid_negotiate_sz(DTLS_CID_MAX_SIZE + 1, + WC_NO_ERR_TRACE(DTLS_CID_ERROR)), TEST_SUCCESS); + ExpectIntEQ(test_dtls_cid_negotiate_sz(DTLS_CID_MAX_SIZE, 0), + TEST_SUCCESS); +#endif + return EXPECT_RESULT(); +} + int test_dtls13_cid_msg_malformed(void) { EXPECT_DECLS; diff --git a/tests/api/test_dtls.h b/tests/api/test_dtls.h index 2ee68d2585..6a157245b9 100644 --- a/tests/api/test_dtls.h +++ b/tests/api/test_dtls.h @@ -30,6 +30,7 @@ int test_dtls13_new_connection_id(void); int test_dtls13_new_connection_id_not_negotiated(void); int test_dtls13_request_connection_id(void); int test_dtls13_cid_msg_malformed(void); +int test_dtls_cid_negotiate_oversize(void); int test_dtls_version_checking(void); int test_dtls_drop_invalid_record_during_handshake(void); int test_dtls_short_ciphertext(void); @@ -121,6 +122,7 @@ int test_WOLFSSL_dtls_version_alert(void); TEST_DECL_GROUP("dtls", test_dtls13_new_connection_id_not_negotiated), \ TEST_DECL_GROUP("dtls", test_dtls13_request_connection_id), \ TEST_DECL_GROUP("dtls", test_dtls13_cid_msg_malformed), \ + TEST_DECL_GROUP("dtls", test_dtls_cid_negotiate_oversize), \ TEST_DECL_GROUP("dtls", test_dtls_version_checking), \ TEST_DECL_GROUP("dtls", \ test_dtls_drop_invalid_record_during_handshake), \