Commit 859aea422b for openssl.org
commit 859aea422b5be17ee9fc0f7678e9de302eb67b72
Author: Matt Caswell <matt@openssl.foundation>
Date: Thu Sep 10 15:11:14 2026 +0100
Bound the DTLS 1.3 ACK body read by the record, not init_num
dtls_get_reassembled_message() reads an ACK in two steps: the first
DTLS1_HM_HEADER_LENGTH bytes, then the rest. The second read was
bounded by "s->init_num - DTLS1_HM_HEADER_LENGTH", but s->init_num
does not describe this message -- the read state machine zeroes it
after the previous one, so the subtraction usually underflows to near
SIZE_MAX. Ask for what is left of the record instead: an ACK never
spans records.
The read also has to stop when the first one emptied the record. An
ACK body is 2 + 16n bytes, so a record holding exactly
DTLS1_HM_HEADER_LENGTH bytes of body is malformed, and is spent by the
time we look at it. The old bound sent the second read after another
record, which appended whatever arrived next -- an ACK, or a handshake
message -- to the ACK body and lost it. Because that bound was not a
bound at all, a full sized record appended there would also have run
past the end of s->init_buf, which is SSL3_RT_MAX_PLAIN_LENGTH long.
Assisted-by: Claude Code:claude-opus-5
Reviewed-by: Mounir Idrassi <mounir.idrassi@idrix.fr>
Reviewed-by: Ryan Hooper <ryanh@openssl.foundation>
Merge-date: Fri Sep 18 14:14:28 2026
Merged-from: https://github.com/openssl/openssl/pull/32783
diff --git a/ssl/statem/statem_dtls.c b/ssl/statem/statem_dtls.c
index 3fe9403bbc..1381c416f9 100644
--- a/ssl/statem/statem_dtls.c
+++ b/ssl/statem/statem_dtls.c
@@ -1031,14 +1031,20 @@ redo:
goto f_err;
}
if (recvd_type == SSL3_RT_ACK) {
- if (readbytes == DTLS1_HM_HEADER_LENGTH) {
+ /*
+ * An ACK has no message header: the bytes already read are body, and
+ * an ACK never spans records, so read the rest of this one. If that
+ * first read already exhausted the record there is no more body to
+ * read, and asking would fetch an unrelated record.
+ */
+ if (readbytes == DTLS1_HM_HEADER_LENGTH
+ && s->rlayer.curr_rec < s->rlayer.num_recs) {
const size_t first_readbytes = readbytes;
p += DTLS1_HM_HEADER_LENGTH;
i = ssl->method->ssl_read_bytes(ssl, SSL3_RT_HANDSHAKE, NULL, p,
- s->init_num - DTLS1_HM_HEADER_LENGTH,
- 0, &readbytes);
+ s->rlayer.tlsrecs[s->rlayer.curr_rec].length, 0, &readbytes);
readbytes += first_readbytes;
/*
* This shouldn't ever fail due to NBIO because we already checked
diff --git a/test/recipes/70-test_dtls13ack.t b/test/recipes/70-test_dtls13ack.t
index 4a37aca575..8649fa866a 100644
--- a/test/recipes/70-test_dtls13ack.t
+++ b/test/recipes/70-test_dtls13ack.t
@@ -44,11 +44,12 @@ my $proxy = TLSProxy::Proxy->new_dtls(
have_IPv6()
);
-my $testcount = 3;
+my $testcount = 4;
plan tests => $testcount;
(undef, my $session) = tempfile();
my $found_first_client_finish_msg = 0;
+my $truncated_ack = 0;
#Test 1: Check that records are acked during an uninterrupted handshake
$proxy->serverflags("-min_protocol DTLSv1.3 -max_protocol DTLSv1.3");
@@ -106,6 +107,23 @@ SKIP: {
ok($missing_count == 0 && $expected_count == 5,
"Check that all record numbers are acked");
+
+ # Test 4: An ACK body of exactly DTLS1_HM_HEADER_LENGTH bytes fills the
+ # read that looks for a message header and exhausts its record doing so.
+ # The record that follows it in the same datagram must not then be read
+ # as the rest of the ACK.
+ $proxy->clear();
+ $proxy->filter(\&short_ack_filter);
+ $proxy->serverflags("-min_protocol DTLSv1.3 -max_protocol DTLSv1.3");
+ $proxy->clientflags("-min_protocol DTLSv1.3 -max_protocol DTLSv1.3 -groups ?X25519:?P-256");
+ TLSProxy::Message->successondata(1);
+ $proxy->start();
+
+ ok(TLSProxy::Message->fail()
+ && defined(TLSProxy::Message->alert())
+ && TLSProxy::Message->alert()->description()
+ == TLSProxy::Message::AL_DESC_ILLEGAL_PARAMETER,
+ "Check a short ACK does not consume the record after it");
}
unlink $session;
@@ -207,3 +225,55 @@ sub drop_first_client_finish_filter
}
}
}
+
+# Truncate the server's ACK to a 12 byte body and follow it with a copy of the
+# original in the same datagram. The truncated body claims a record number list
+# of 16 bytes but carries 10, so it fills the initial DTLS1_HM_HEADER_LENGTH
+# read, leaves its record empty, and cannot be parsed from that record alone. A
+# peer that reads the rest of the body from the record behind it gets a list it
+# can parse, and loses that record.
+sub short_ack_filter
+{
+ my $inproxy = shift;
+ my $records = $inproxy->record_list;
+ my $idx = 0;
+
+ return if $truncated_ack;
+
+ foreach my $record (@{$records}) {
+ if (!$record->{sent} && $record->serverissender && $record->encrypted
+ && $record->content_type == TLSProxy::Record::RT_ACK) {
+ # The copy needs a sequence number of its own, or it is a replay
+ my $copy = TLSProxy::Record->new_dtls(
+ 1,
+ $record->flight,
+ $record->outer_content_type,
+ $record->version,
+ $record->epoch,
+ $record->seq + 5,
+ $record->len,
+ $record->len_real,
+ $record->decrypt_len,
+ $record->data,
+ $record->decrypt_data);
+
+ $copy->encrypted(1);
+ $copy->content_type(TLSProxy::Record::RT_ACK);
+
+ # The p_ossltest cipher is a no-op, so the record body is the
+ # inner plaintext, its content type, and a zero tag
+ my $body = pack("n", 16).("\0" x 10);
+ my $data = $body.pack("C", TLSProxy::Record::RT_ACK).("\0" x 16);
+
+ $record->data($data);
+ $record->len(length $data);
+ $record->decrypt_data($body);
+ $record->decrypt_len(length $body);
+
+ splice @{$records}, $idx + 1, 0, $copy;
+ $truncated_ack = 1;
+ return;
+ }
+ $idx++;
+ }
+}