Skip to content

Commit 608cc00

Browse files
committed
quic: improve corruption detection
1 parent 66316b9 commit 608cc00

2 files changed

Lines changed: 151 additions & 2 deletions

File tree

lib/roles/quic/ops-quic.c

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,30 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
11761176
n--;
11771177
continue;
11781178
}
1179+
1180+
/*
1181+
* Fixed bit (RFC 9000 §17.2 & §17.3). This is the one bit in
1182+
* the first byte that is NOT covered by header protection (the
1183+
* HP mask only touches the low nibble of a long header and the
1184+
* low 5 bits of a short header), so we can check it here, before
1185+
* trusting any field derived from this byte and before AEAD.
1186+
*
1187+
* A compliant sender always sets it. A cleared Fixed bit means
1188+
* the byte is corrupt or not a QUIC packet; since every field we
1189+
* would parse next (form, type, version, CID lengths) is derived
1190+
* from this byte, we cannot trust the length field to find the
1191+
* next coalesced packet boundary either, so drop the rest of the
1192+
* datagram rather than advance blindly.
1193+
*
1194+
* The reserved bits (0x0c long / 0x18 short) are header-protected
1195+
* and so cannot be checked until after lws_quic_unmask_header();
1196+
* the existing post-AEAD check still handles those.
1197+
*/
1198+
if (!(p[0] & 0x40)) {
1199+
lwsl_wsi_notice(wsi, "QUIC RX: Fixed bit not set (0x%02x), "
1200+
"dropping corrupt datagram", p[0]);
1201+
break;
1202+
}
11791203
if (p[0] & 0x80) {
11801204
uint8_t type = (uint8_t)((p[0] & 0x30) >> 4);
11811205
uint32_t parsed_pkt_version = (uint32_t)((p[1] << 24) | (p[2] << 16) | (p[3] << 8) | p[4]);
@@ -1318,8 +1342,18 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
13181342

13191343
if (!(p[0] & 0x80)) { /* Short header */
13201344
uint8_t kp = (p[0] & 0x04) >> 2;
1321-
if (nwsi->quic.qn && nwsi->quic.qn->handshake_done && nwsi->quic.qn->rx_key_phase != kp) {
1322-
/* Provisional key update */
1345+
if (nwsi->quic.qn && nwsi->quic.qn->handshake_done &&
1346+
nwsi->quic.qn->rx_key_phase != kp &&
1347+
nwsi->quic.qn->kp_probe_fail < LWS_QUIC_KP_PROBE_LIMIT) {
1348+
/*
1349+
* Provisional key update. The key-phase bit is
1350+
* only 1 bit and is trivially flipped by wire
1351+
* corruption, so this probe can fire on junk.
1352+
* If it then fails AEAD we count it below and,
1353+
* once kp_probe_fail saturates, we stop trusting
1354+
* the bit entirely and treat mismatches as
1355+
* corruption rather than a peer rotation.
1356+
*/
13231357
scratch_keys = *k;
13241358
scratch_keys.aead_rx = NULL;
13251359
scratch_keys.aead_tx = NULL;
@@ -1336,9 +1370,45 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
13361370
if (dec_len < 0) {
13371371
lwsl_wsi_notice(wsi, "QUIC RX: AEAD Decryption failed (bad tag or truncated)");
13381372
if (is_key_update) {
1373+
/*
1374+
* The key-phase bit mismatched and the rotated
1375+
* keys still failed AEAD: that mismatch was
1376+
* corruption, not a peer key update. Account
1377+
* it so we stop chasing key rotations on junk
1378+
* once it becomes a pattern.
1379+
*/
13391380
lws_quic_keys_release_aead_rx(&scratch_keys);
13401381
lws_quic_keys_release_aead_tx(&scratch_keys);
1382+
if (nwsi->quic.qn)
1383+
nwsi->quic.qn->kp_probe_fail++;
1384+
}
1385+
1386+
/*
1387+
* Corruption circuit-breaker. A single undecryptable
1388+
* packet is normal (loss, a stale PN, a probe for a key
1389+
* phase we discarded). A sustained run with no progress
1390+
* is wire/path corruption that the loss machinery cannot
1391+
* recover from at this rate: rather than spin key
1392+
* derivation and stall until PTO exhaustion, close the
1393+
* connection cleanly with NO_VIABLE_PATH so the peer and
1394+
* any test harness see a decisive transport error.
1395+
*/
1396+
if (nwsi && nwsi->quic.qn) {
1397+
if (++nwsi->quic.qn->consec_decrypt_fail[pn_space] >=
1398+
LWS_QUIC_DECRYPT_FAIL_LIMIT) {
1399+
lwsl_wsi_warn(wsi,
1400+
"QUIC RX: %u consecutive decrypt "
1401+
"failures in pn_space %d with no "
1402+
"progress; path is corrupted, "
1403+
"closing",
1404+
nwsi->quic.qn->consec_decrypt_fail[pn_space],
1405+
pn_space);
1406+
lws_quic_enter_closing_state(nwsi,
1407+
LWS_QUIC_ERR_NO_VIABLE_PATH, 0, 0);
1408+
return LWS_HPI_RET_PLEASE_CLOSE_ME;
1409+
}
13411410
}
1411+
13421412
/* F-130: Stateless Reset Token detection */
13431413
if (nwsi && nwsi->quic.qn && (size_t)n >= 21) {
13441414
static const uint8_t zero_token[16] = {0};
@@ -1427,6 +1497,15 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
14271497
/* Check for duplicate/replayed packet numbers (Security Fix) */
14281498
int is_out_of_order = 0;
14291499
if (nwsi->quic.qn) {
1500+
/*
1501+
* Reaching here means the packet authenticated cleanly,
1502+
* so any prior decrypt-failure run for this pn_space is
1503+
* over: reset the corruption circuit-breaker and the
1504+
* key-phase-probe accounting.
1505+
*/
1506+
nwsi->quic.qn->consec_decrypt_fail[pn_space] = 0;
1507+
nwsi->quic.qn->kp_probe_fail = 0;
1508+
14301509
uint64_t highest = nwsi->quic.qn->highest_rx_pn[pn_space];
14311510
if ((nwsi->quic.qn->rx_pn_bitmask[pn_space] != 0 || highest != 0) && full_pn <= highest) {
14321511
uint64_t diff = highest - full_pn;
@@ -1439,6 +1518,29 @@ rops_handle_POLLIN_quic(struct lws_context_per_thread *pt, struct lws *wsi,
14391518
} else {
14401519
if (nwsi->quic.qn->rx_pn_bitmask[pn_space] != 0 || highest != 0) {
14411520
uint64_t diff = full_pn - highest;
1521+
/*
1522+
* Forward-PN plausibility guard. A
1523+
* corrupted truncated PN decodes to an
1524+
* arbitrary value, and a huge forward
1525+
* jump would rescale the receive
1526+
* bitmask and bump highest_rx_pn past
1527+
* thousands of imaginary gaps,
1528+
* poisoning ACK and loss accounting.
1529+
* QUIC senders never gap PNs by this
1530+
* much in practice, so drop the packet
1531+
* rather than trust the decode.
1532+
*/
1533+
if (diff > LWS_QUIC_PN_FORWARD_JUMP_LIMIT) {
1534+
lwsl_wsi_notice(wsi,
1535+
"QUIC RX: decoded PN %llu "
1536+
"jumps %llu ahead of "
1537+
"highest %llu; treating "
1538+
"as corrupted truncated PN",
1539+
(unsigned long long)full_pn,
1540+
(unsigned long long)diff,
1541+
(unsigned long long)highest);
1542+
goto next_packet;
1543+
}
14421544
if (diff >= 64)
14431545
nwsi->quic.qn->rx_pn_bitmask[pn_space] = 0;
14441546
else

lib/roles/quic/private-lib-roles-quic.h

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,32 @@ enum lws_quic_frame_type {
144144
#define LWS_QUIC_ERR_AEAD_LIMIT_REACHED 0x0f
145145
#define LWS_QUIC_ERR_NO_VIABLE_PATH 0x10
146146

147+
/*
148+
* Corruption-defense thresholds.
149+
*
150+
* LWS_QUIC_DECRYPT_FAIL_LIMIT: how many consecutive undecryptable packets in
151+
* a pn_space (with no successful decrypt in between) we tolerate before
152+
* declaring the path corrupted and closing with NO_VIABLE_PATH. This is
153+
* deliberately generous: a burst of losses or coalesced junk from a
154+
* pmtud probe can produce several, and the PTO/loss machinery already
155+
* recovers genuine loss. We only act on a sustained run that real loss
156+
* could not produce at this rate.
157+
*
158+
* LWS_QUIC_KP_PROBE_LIMIT: how many times we let a demasked key-phase
159+
* mismatch drive a provisional key update that then fails AEAD, before
160+
* we stop trusting the key-phase bit as a rotation signal. Past this,
161+
* a KP mismatch is treated as corruption, not a peer-initiated update.
162+
*
163+
* LWS_QUIC_PN_FORWARD_JUMP_LIMIT: largest acceptable jump of a freshly
164+
* decoded packet number ahead of highest_rx_pn. QUIC senders do not gap
165+
* PNs by hundreds for no reason; a decode that lands far ahead is almost
166+
* always a corrupted truncated PN, and committing it would wreck the
167+
* receive bitmask and ACK accounting. We drop the packet instead.
168+
*/
169+
#define LWS_QUIC_DECRYPT_FAIL_LIMIT 256u
170+
#define LWS_QUIC_KP_PROBE_LIMIT 8u
171+
#define LWS_QUIC_PN_FORWARD_JUMP_LIMIT 1024u
172+
147173
/*
148174
* A logical frame queued for transmission or in-flight waiting for ACK.
149175
*/
@@ -266,6 +292,27 @@ struct lws_quic_netconn {
266292
uint64_t rx_pn_bitmask[LWS_QUIC_LEVEL_COUNT];
267293
uint8_t needs_ack[LWS_QUIC_LEVEL_COUNT];
268294

295+
/*
296+
* Corruption defense (inbound sanity self-checks).
297+
*
298+
* If a packet passes header protection but fails AEAD, it is either a
299+
* legitimate loss (the packet belongs to a key phase we no longer have,
300+
* or it is genuinely lost in flight) or it is wire/path corruption that
301+
* happened to look plausible enough to reach decryption. We cannot tell
302+
* the two apart from a single failure, but a sustained run of failures
303+
* with no progress is corruption until proven otherwise.
304+
*
305+
* consec_decrypt_fail[] counts undecryptable packets per pn_space since
306+
* the last packet that decrypted cleanly; it is reset on every success.
307+
*
308+
* kp_probe_fail counts provisional key-update attempts (a short-header
309+
* packet whose demasked key-phase bit differs from rx_key_phase) that
310+
* subsequently failed AEAD. A single bit flip in junk flips this, so we
311+
* refuse to keep re-deriving keys once it's clear the bit is just noise.
312+
*/
313+
uint32_t consec_decrypt_fail[LWS_QUIC_LEVEL_COUNT];
314+
uint32_t kp_probe_fail;
315+
269316
/* RX Crypto Reassembly Buffers (Streams are handled by child WSIs) */
270317
uint64_t rx_crypto_offset[LWS_QUIC_LEVEL_COUNT];
271318
lws_dll2_owner_t rx_crypto_chunks[LWS_QUIC_LEVEL_COUNT];

0 commit comments

Comments
 (0)