Commit 24137d12a2 for openssl.org

commit 24137d12a2e65db94118e01bf370ccce1d68c80f
Author: Haiyang Huang <huanghaiyang83@gmail.com>
Date:   Thu Jun 18 09:19:42 2026 +0800

    quic: reject ACK of an unsent packet number

    ossl_ackm_on_rx_ack_frame() stored ack_ranges[0].end into
    largest_acked_pkt[pkt_space] without checking it against the highest
    packet number actually sent in that space. Because largest_acked_pkt
    only ever increases and drives loss detection, an ACK acknowledging a
    packet number that was never sent (up to 2**62 - 1) pins the value and
    causes every in-flight and subsequently-sent packet to be declared lost,
    permanently corrupting loss detection for the connection.

    RFC 9000 s. 13.1 recommends treating an acknowledgment for a packet the
    endpoint did not send as a connection error of type PROTOCOL_VIOLATION,
    where it can be detected.

    Reject any ACK whose largest acknowledged packet number exceeds the
    highest packet number sent in that space; the bound, highest_sent, is
    already tracked. The depacketiser raises PROTOCOL_VIOLATION when the ACK
    manager rejects the frame.

    Update the QUIC tests for the new behaviour: cases 7 and 8 now assert
    rejection, case 14 covers the 2**62 - 1 boundary, two pre-existing
    fixtures that acknowledged one packet past the highest sent are
    corrected, and the "fictional PN" script now expects a PROTOCOL_VIOLATION
    close.

    Fixes: fa4e92a70a5f "QUIC ACK Manager, Statistics Manager and Congestion Control API"
    Assisted-by: Claude:claude-opus-4.6

    Reviewed-by: Saša NedvÄ›dický <sashan@openssl.org>
    Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
    MergeDate: Tue Jun 23 16:36:27 2026
    (Merged from https://github.com/openssl/openssl/pull/31582)

diff --git a/ssl/quic/quic_ackm.c b/ssl/quic/quic_ackm.c
index d1ac3b88e9..24acb8635c 100644
--- a/ssl/quic/quic_ackm.c
+++ b/ssl/quic/quic_ackm.c
@@ -1169,8 +1169,21 @@ int ossl_ackm_on_rx_ack_frame(OSSL_ACKM *ackm, const OSSL_QUIC_FRAME_ACK *ack,
     int pkt_space, OSSL_TIME rx_time)
 {
     OSSL_ACKM_TX_PKT *na_pkts, *lost_pkts;
+    struct tx_pkt_history_st *h = get_tx_history(ackm, pkt_space);
     int must_set_timer = 0;

+    /*
+     * RFC 9000 s. 13.1 recommends treating an acknowledgment for a packet we
+     * did not send as a PROTOCOL_VIOLATION, where detectable. The largest
+     * acknowledged PN is ack_ranges[0].end; if it exceeds the highest PN we have
+     * sent in this space, reject the ACK. Otherwise the peer-controlled value is
+     * stored into largest_acked_pkt below, which only ever increases and drives
+     * loss detection, so a single such ACK would permanently force every
+     * in-flight and subsequently-sent packet to be declared lost.
+     */
+    if (ack->ack_ranges[0].end > h->highest_sent)
+        return 0;
+
     if (ackm->largest_acked_pkt[pkt_space] == QUIC_PN_INVALID)
         ackm->largest_acked_pkt[pkt_space] = ack->ack_ranges[0].end;
     else
diff --git a/ssl/quic/quic_rx_depack.c b/ssl/quic/quic_rx_depack.c
index 730b0cf621..74cfceed05 100644
--- a/ssl/quic/quic_rx_depack.c
+++ b/ssl/quic/quic_rx_depack.c
@@ -125,8 +125,19 @@ static int depack_do_frame_ack(PACKET *pkt, QUIC_CHANNEL *ch,
     }

     if (!ossl_ackm_on_rx_ack_frame(ch->ackm, &ack,
-            packet_space, received))
-        goto malformed;
+            packet_space, received)) {
+        /*
+         * The ACK manager rejects the frame if it acknowledges a packet number
+         * we have not sent. RFC 9000 s. 13.1 recommends treating this as a
+         * PROTOCOL_VIOLATION connection error (distinct from a frame decoding
+         * error, which is handled at the malformed label below).
+         */
+        ossl_quic_channel_raise_protocol_error(ch,
+            OSSL_QUIC_ERR_PROTOCOL_VIOLATION,
+            frame_type,
+            "ACK for unsent packet number");
+        return 0;
+    }

     ++ch->diag_num_rx_ack;
     return 1;
diff --git a/test/quic_ackm_test.c b/test/quic_ackm_test.c
index a4b488fafc..8598bf8398 100644
--- a/test/quic_ackm_test.c
+++ b/test/quic_ackm_test.c
@@ -11,6 +11,7 @@
 #include <openssl/ssl.h>
 #include "internal/quic_ackm.h"
 #include "internal/quic_cc.h"
+#include "internal/quic_vlint.h"

 static OSSL_TIME fake_time = { 0 };

@@ -147,13 +148,26 @@ struct tx_ack_test_case {
     const OSSL_QUIC_ACK_RANGE *ack_ranges;
     size_t num_ack_ranges;
     const char *expect_ack; /* 1=ack, 2=lost, 4=discarded */
+    int expect_reject; /* if nonzero the ACK must be rejected (returns 0) */
 };

 #define DEFINE_TX_ACK_CASE(n, pntable)                       \
     static const struct tx_ack_test_case tx_ack_case_##n = { \
         (pntable), OSSL_NELEM(pntable),                      \
         tx_ack_range_##n, OSSL_NELEM(tx_ack_range_##n),      \
-        tx_ack_expect_##n                                    \
+        tx_ack_expect_##n, 0                                 \
+    }
+
+/*
+ * As DEFINE_TX_ACK_CASE, but the ACK acknowledges a packet number that was
+ * never sent and so must be rejected by ossl_ackm_on_rx_ack_frame()
+ * (RFC 9000 s. 13.1).
+ */
+#define DEFINE_TX_ACK_CASE_REJECT(n, pntable)                \
+    static const struct tx_ack_test_case tx_ack_case_##n = { \
+        (pntable), OSSL_NELEM(pntable),                      \
+        tx_ack_range_##n, OSSL_NELEM(tx_ack_range_##n),      \
+        tx_ack_expect_##n, 1                                 \
     }

 /* One range, partial coverage of space */
@@ -207,32 +221,32 @@ static const char tx_ack_expect_5[] = {
 };
 DEFINE_TX_ACK_CASE(5, linear_20);

-/* One range, covering entire space */
+/* One range covering the whole space (0..19, highest sent PN is 19): all acked */
 static const OSSL_QUIC_ACK_RANGE tx_ack_range_6[] = {
-    { 0, 20 },
+    { 0, 19 },
 };
 static const char tx_ack_expect_6[] = {
     1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
 };
 DEFINE_TX_ACK_CASE(6, linear_20);

-/* One range, covering more space than exists */
+/* One range above the highest sent PN (30 > 19): ACK rejected */
 static const OSSL_QUIC_ACK_RANGE tx_ack_range_7[] = {
     { 0, 30 },
 };
 static const char tx_ack_expect_7[] = {
-    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
+    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
 };
-DEFINE_TX_ACK_CASE(7, linear_20);
+DEFINE_TX_ACK_CASE_REJECT(7, linear_20);

-/* One range, covering nothing (too high) */
+/* One range entirely above the sent PNs (21..30): ACK rejected */
 static const OSSL_QUIC_ACK_RANGE tx_ack_range_8[] = {
     { 21, 30 },
 };
 static const char tx_ack_expect_8[] = {
     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
 };
-DEFINE_TX_ACK_CASE(8, linear_20);
+DEFINE_TX_ACK_CASE_REJECT(8, linear_20);

 /* One range, covering nothing (too low) */
 static const OSSL_QUIC_ACK_RANGE tx_ack_range_9[] = {
@@ -289,6 +303,20 @@ static const char tx_ack_expect_13[] = {
 };
 DEFINE_TX_ACK_CASE(13, high_linear_20);

+/*
+ * Largest range claims the maximum PN (2**62 - 1, never sent) plus a second
+ * range over real packets so loss detection would otherwise run. ACK rejected;
+ * otherwise largest_acked_pkt pins at the maximum and every in-flight packet is
+ * declared lost.
+ */
+static const OSSL_QUIC_ACK_RANGE tx_ack_range_14[] = {
+    { OSSL_QUIC_VLINT_MAX, OSSL_QUIC_VLINT_MAX }, { 15, 19 }
+};
+static const char tx_ack_expect_14[] = {
+    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
+};
+DEFINE_TX_ACK_CASE_REJECT(14, linear_20);
+
 static const struct tx_ack_test_case *const tx_ack_cases[] = {
     &tx_ack_case_1,
     &tx_ack_case_2,
@@ -303,6 +331,7 @@ static const struct tx_ack_test_case *const tx_ack_cases[] = {
     &tx_ack_case_11,
     &tx_ack_case_12,
     &tx_ack_case_13,
+    &tx_ack_case_14,
 };

 enum {
@@ -402,6 +431,25 @@ static int test_tx_ack_case_actual(int tidx, int space, int mode)
         /* Try acknowledging. */
         ack.ack_ranges = (OSSL_QUIC_ACK_RANGE *)c->ack_ranges;
         ack.num_ack_ranges = c->num_ack_ranges;
+
+        if (c->expect_reject) {
+            /* ACK of an unsent PN: rejected without touching loss detection. */
+            if (!TEST_int_eq(ossl_ackm_on_rx_ack_frame(h.ackm, &ack, space,
+                                 fake_time),
+                    0))
+                goto err;
+
+            for (i = 0; i < c->pn_table_len; ++i) {
+                if (!TEST_int_eq(h.pkts[i].acked, 0)
+                    || !TEST_int_eq(h.pkts[i].lost, 0)
+                    || !TEST_int_eq(h.pkts[i].discarded, 0))
+                    goto err;
+            }
+
+            testresult = 1;
+            goto err;
+        }
+
         if (!TEST_int_eq(ossl_ackm_on_rx_ack_frame(h.ackm, &ack, space, fake_time), 1))
             goto err;

@@ -577,7 +625,7 @@ static int test_tx_ack_time_script(int tidx)
             ack.num_ack_ranges = 1;

             ack_range.start = s->pn;
-            ack_range.end = s->pn + s->num_pn;
+            ack_range.end = s->pn + s->num_pn - 1;

             fake_time = ossl_time_add(fake_time,
                 ossl_ticks2time(s->time_advance));
diff --git a/test/quic_multistream_test.c b/test/quic_multistream_test.c
index de7ea27a49..4edd53e15f 100644
--- a/test/quic_multistream_test.c
+++ b/test/quic_multistream_test.c
@@ -3886,7 +3886,12 @@ static const struct script_op script_49[] = {
     OP_SET_INJECT_WORD(4, 0),

     OP_S_WRITE(a, "Strawberry", 10),
-    OP_C_READ_EXPECT(DEFAULT, "Strawberry", 10),
+    /*
+     * The injected ACK acknowledges a packet number we have not sent, which the
+     * peer is expected to treat as a PROTOCOL_VIOLATION, so the connection is
+     * closed rather than the stream data being delivered.
+     */
+    OP_C_EXPECT_CONN_CLOSE_INFO(OSSL_QUIC_ERR_PROTOCOL_VIOLATION, 0, 0),

     OP_END
 };