Skip to content

Commit 3e7a445

Browse files
neildgopherbot
authored andcommitted
quic: skip packet numbers for optimistic ack defense
An "optimistic ACK attack" involves an attacker sending ACKs for packets it hasn't received, causing the victim's congestion controller to improperly send at a higher rate. The standard defense against this attack is to skip the occasional packet number, and to close the connection with an error if the peer ACKs an unsent packet. Implement this defense, increasing the gap between skipped packet numbers as a connection's lifetime grows and correspondingly the amount of work required on the part of the attacker. Change-Id: I01f44f13367821b86af6535ffb69d380e2b4d7b7 Reviewed-on: https://go-review.googlesource.com/c/net/+/664298 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Jonathan Amsterdam <[email protected]> Auto-Submit: Damien Neil <[email protected]>
1 parent 3f563d3 commit 3e7a445

9 files changed

+194
-17
lines changed

quic/conn.go

+13
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ package quic
66

77
import (
88
"context"
9+
cryptorand "crypto/rand"
910
"crypto/tls"
1011
"errors"
1112
"fmt"
1213
"log/slog"
14+
"math/rand/v2"
1315
"net/netip"
1416
"time"
1517
)
@@ -24,6 +26,7 @@ type Conn struct {
2426
testHooks connTestHooks
2527
peerAddr netip.AddrPort
2628
localAddr netip.AddrPort
29+
prng *rand.Rand
2730

2831
msgc chan any
2932
donec chan struct{} // closed when conn loop exits
@@ -36,6 +39,7 @@ type Conn struct {
3639
loss lossState
3740
streams streamsState
3841
path pathState
42+
skip skipState
3943

4044
// Packet protection keys, CRYPTO streams, and TLS state.
4145
keysInitial fixedKeyPair
@@ -136,13 +140,22 @@ func newConn(now time.Time, side connSide, cids newServerConnIDs, peerHostname s
136140
}
137141
}
138142

143+
// A per-conn ChaCha8 PRNG is probably more than we need,
144+
// but at least it's fairly small.
145+
var seed [32]byte
146+
if _, err := cryptorand.Read(seed[:]); err != nil {
147+
panic(err)
148+
}
149+
c.prng = rand.New(rand.NewChaCha8(seed))
150+
139151
// TODO: PMTU discovery.
140152
c.logConnectionStarted(cids.originalDstConnID, peerAddr)
141153
c.keysAppData.init()
142154
c.loss.init(c.side, smallestMaxDatagramSize, now)
143155
c.streamsInit()
144156
c.lifetimeInit()
145157
c.restartIdleTimer(now)
158+
c.skip.init(c)
146159

147160
if err := c.startTLS(now, initialConnID, peerHostname, transportParameters{
148161
initialSrcConnID: c.connIDState.srcConnID(),

quic/conn_recv.go

+2-7
Original file line numberDiff line numberDiff line change
@@ -421,15 +421,10 @@ func (c *Conn) handleFrames(now time.Time, dgram *datagram, ptype packetType, sp
421421
func (c *Conn) handleAckFrame(now time.Time, space numberSpace, payload []byte) int {
422422
c.loss.receiveAckStart()
423423
largest, ackDelay, n := consumeAckFrame(payload, func(rangeIndex int, start, end packetNumber) {
424-
if end > c.loss.nextNumber(space) {
425-
// Acknowledgement of a packet we never sent.
426-
c.abort(now, localTransportError{
427-
code: errProtocolViolation,
428-
reason: "acknowledgement for unsent packet",
429-
})
424+
if err := c.loss.receiveAckRange(now, space, rangeIndex, start, end, c.handleAckOrLoss); err != nil {
425+
c.abort(now, err)
430426
return
431427
}
432-
c.loss.receiveAckRange(now, space, rangeIndex, start, end, c.handleAckOrLoss)
433428
})
434429
// Prior to receiving the peer's transport parameters, we cannot
435430
// interpret the ACK Delay field because we don't know the ack_delay_exponent

quic/conn_send.go

+4
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ func (c *Conn) maybeSend(now time.Time) (next time.Time) {
142142
}
143143
if sent := c.w.finish1RTTPacket(pnum, pnumMaxAcked, dstConnID, &c.keysAppData); sent != nil {
144144
c.packetSent(now, appDataSpace, sent)
145+
if c.skip.shouldSkip(pnum + 1) {
146+
c.loss.skipNumber(now, appDataSpace)
147+
c.skip.updateNumberSkip(c)
148+
}
145149
}
146150
}
147151

quic/conn_send_test.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ func TestSendPacketNumberSize(t *testing.T) {
6666
// current packet and the max acked one is sufficiently large.
6767
for want := maxAcked + 1; want < maxAcked+0x100; want++ {
6868
p := recvPing()
69-
if p.num != want {
69+
if p.num == want+1 {
70+
// The conn skipped a packet number
71+
// (defense against optimistic ACK attacks).
72+
want++
73+
} else if p.num != want {
7074
t.Fatalf("received packet number %v, want %v", p.num, want)
7175
}
7276
gotPnumLen := int(p.header&0x03) + 1

quic/conn_streams_test.go

+1-3
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,7 @@ func TestStreamsWriteQueueFairness(t *testing.T) {
242242
if p == nil {
243243
break
244244
}
245-
tc.writeFrames(packetType1RTT, debugFrameAck{
246-
ranges: []i64range[packetNumber]{{0, p.num}},
247-
})
245+
tc.writeAckForLatest()
248246
for _, f := range p.frames {
249247
sf, ok := f.(debugFrameStream)
250248
if !ok {

quic/loss.go

+22-3
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ func (c *lossState) nextNumber(space numberSpace) packetNumber {
178178
return c.spaces[space].nextNum
179179
}
180180

181+
// skipPacketNumber skips a packet number as a defense against optimistic ACK attacks.
182+
func (c *lossState) skipNumber(now time.Time, space numberSpace) {
183+
sent := newSentPacket()
184+
sent.num = c.spaces[space].nextNum
185+
sent.time = now
186+
sent.state = sentPacketUnsent
187+
c.spaces[space].add(sent)
188+
}
189+
181190
// packetSent records a sent packet.
182191
func (c *lossState) packetSent(now time.Time, log *slog.Logger, space numberSpace, sent *sentPacket) {
183192
sent.time = now
@@ -230,17 +239,20 @@ func (c *lossState) receiveAckStart() {
230239

231240
// receiveAckRange processes a range within an ACK frame.
232241
// The ackf function is called for each newly-acknowledged packet.
233-
func (c *lossState) receiveAckRange(now time.Time, space numberSpace, rangeIndex int, start, end packetNumber, ackf func(numberSpace, *sentPacket, packetFate)) {
242+
func (c *lossState) receiveAckRange(now time.Time, space numberSpace, rangeIndex int, start, end packetNumber, ackf func(numberSpace, *sentPacket, packetFate)) error {
234243
// Limit our range to the intersection of the ACK range and
235244
// the in-flight packets we have state for.
236245
if s := c.spaces[space].start(); start < s {
237246
start = s
238247
}
239248
if e := c.spaces[space].end(); end > e {
240-
end = e
249+
return localTransportError{
250+
code: errProtocolViolation,
251+
reason: "acknowledgement for unsent packet",
252+
}
241253
}
242254
if start >= end {
243-
return
255+
return nil
244256
}
245257
if rangeIndex == 0 {
246258
// If the latest packet in the ACK frame is newly-acked,
@@ -252,6 +264,12 @@ func (c *lossState) receiveAckRange(now time.Time, space numberSpace, rangeIndex
252264
}
253265
for pnum := start; pnum < end; pnum++ {
254266
sent := c.spaces[space].num(pnum)
267+
if sent.state == sentPacketUnsent {
268+
return localTransportError{
269+
code: errProtocolViolation,
270+
reason: "acknowledgement for unsent packet",
271+
}
272+
}
255273
if sent.state != sentPacketSent {
256274
continue
257275
}
@@ -266,6 +284,7 @@ func (c *lossState) receiveAckRange(now time.Time, space numberSpace, rangeIndex
266284
c.ackFrameContainsAckEliciting = true
267285
}
268286
}
287+
return nil
269288
}
270289

271290
// receiveAckEnd finishes processing an ack frame.

quic/sent_packet.go

+4-3
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ type sentPacket struct {
3838
type sentPacketState uint8
3939

4040
const (
41-
sentPacketSent = sentPacketState(iota) // sent but neither acked nor lost
42-
sentPacketAcked // acked
43-
sentPacketLost // declared lost
41+
sentPacketSent = sentPacketState(iota) // sent but neither acked nor lost
42+
sentPacketAcked // acked
43+
sentPacketLost // declared lost
44+
sentPacketUnsent // never sent
4445
)
4546

4647
var sentPool = sync.Pool{

quic/skip.go

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package quic
6+
7+
// skipState is state for optimistic ACK defenses.
8+
//
9+
// An endpoint performs an optimistic ACK attack by sending acknowledgements for packets
10+
// which it has not received, potentially convincing the sender's congestion controller to
11+
// send at rates beyond what the network supports.
12+
//
13+
// We defend against this by periodically skipping packet numbers.
14+
// Receiving an ACK for an unsent packet number is a PROTOCOL_VIOLATION error.
15+
//
16+
// We only skip packet numbers in the Application Data number space.
17+
// The total data sent in the Initial/Handshake spaces should generally fit into
18+
// the initial congestion window.
19+
//
20+
// https://www.rfc-editor.org/rfc/rfc9000.html#section-21.4
21+
type skipState struct {
22+
// skip is the next packet number (in the Application Data space) we should skip.
23+
skip packetNumber
24+
25+
// maxSkip is the maximum number of packets to send before skipping another number.
26+
// Increases over time.
27+
maxSkip int64
28+
}
29+
30+
func (ss *skipState) init(c *Conn) {
31+
ss.maxSkip = 256 // skip our first packet number within this range
32+
ss.updateNumberSkip(c)
33+
}
34+
35+
// shouldSkipAfter returns whether we should skip the given packet number.
36+
func (ss *skipState) shouldSkip(num packetNumber) bool {
37+
return ss.skip == num
38+
}
39+
40+
// updateNumberSkip schedules a packet to be skipped after skipping lastSkipped.
41+
func (ss *skipState) updateNumberSkip(c *Conn) {
42+
// Send at least this many packets before skipping.
43+
// Limits the impact of skipping a little,
44+
// plus allows most tests to ignore skipping.
45+
const minSkip = 64
46+
47+
skip := minSkip + c.prng.Int64N(ss.maxSkip-minSkip)
48+
ss.skip += packetNumber(skip)
49+
50+
// Double the size of the skip each time until we reach 128k.
51+
// The idea here is that an attacker needs to correctly ack ~N packets in order
52+
// to send an optimistic ack for another ~N packets.
53+
// Skipping packet numbers comes with a small cost (it causes the receiver to
54+
// send an immediate ACK rather than the usual delayed ACK), so we increase the
55+
// time between skips as a connection's lifetime grows.
56+
//
57+
// The 128k cap is arbitrary, chosen so that we skip a packet number
58+
// about once a second when sending full-size datagrams at 1Gbps.
59+
if ss.maxSkip < 128*1024 {
60+
ss.maxSkip *= 2
61+
}
62+
}

quic/skip_test.go

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package quic
6+
7+
import "testing"
8+
9+
func TestSkipPackets(t *testing.T) {
10+
tc, s := newTestConnAndLocalStream(t, serverSide, uniStream, permissiveTransportParameters)
11+
connWritesPacket := func() {
12+
s.WriteByte(0)
13+
s.Flush()
14+
tc.wantFrameType("conn sends STREAM data",
15+
packetType1RTT, debugFrameStream{})
16+
tc.writeAckForLatest()
17+
tc.wantIdle("conn is idle")
18+
}
19+
connWritesPacket()
20+
21+
expectSkip:
22+
for maxUntilSkip := 256; maxUntilSkip <= 1024; maxUntilSkip *= 2 {
23+
for range maxUntilSkip + 1 {
24+
nextNum := tc.lastPacket.num + 1
25+
26+
connWritesPacket()
27+
28+
if tc.lastPacket.num == nextNum+1 {
29+
// A packet number was skipped, as expected.
30+
continue expectSkip
31+
}
32+
if tc.lastPacket.num != nextNum {
33+
t.Fatalf("got packet number %v, want %v or %v+1", tc.lastPacket.num, nextNum, nextNum)
34+
}
35+
36+
}
37+
t.Fatalf("no numbers skipped after %v packets", maxUntilSkip)
38+
}
39+
}
40+
41+
func TestSkipAckForSkippedPacket(t *testing.T) {
42+
tc, s := newTestConnAndLocalStream(t, serverSide, uniStream, permissiveTransportParameters)
43+
44+
// Cause the connection to send packets until it skips a packet number.
45+
for {
46+
// Cause the connection to send a packet.
47+
last := tc.lastPacket
48+
s.WriteByte(0)
49+
s.Flush()
50+
tc.wantFrameType("conn sends STREAM data",
51+
packetType1RTT, debugFrameStream{})
52+
53+
if tc.lastPacket.num > 256 {
54+
t.Fatalf("no numbers skipped after 256 packets")
55+
}
56+
57+
// Acknowledge everything up to the packet before the one we just received.
58+
// We don't acknowledge the most-recently-received packet, because doing
59+
// so will cause the connection to drop state for the skipped packet number.
60+
// (We only retain state up to the oldest in-flight packet.)
61+
//
62+
// If the conn has skipped a packet number, then this ack will improperly
63+
// acknowledge the unsent packet.
64+
t.Log(tc.lastPacket.num)
65+
tc.writeFrames(tc.lastPacket.ptype, debugFrameAck{
66+
ranges: []i64range[packetNumber]{{0, tc.lastPacket.num}},
67+
})
68+
69+
if last != nil && tc.lastPacket.num == last.num+2 {
70+
// The connection has skipped a packet number.
71+
break
72+
}
73+
}
74+
75+
// We wrote an ACK for a skipped packet number.
76+
// The connection should close.
77+
tc.wantFrame("ACK for skipped packet causes CONNECTION_CLOSE",
78+
packetType1RTT, debugFrameConnectionCloseTransport{
79+
code: errProtocolViolation,
80+
})
81+
}

0 commit comments

Comments
 (0)