Skip to content

Commit 6c8ce01

Browse files
author
Mohamed Sayed
committed
quic: drop version negotiation packets with oversized CIDs
Signed-off-by: Mohamed Sayed <k@3zrv.com>
1 parent d3ed6c1 commit 6c8ce01

2 files changed

Lines changed: 102 additions & 0 deletions

File tree

src/quic/endpoint.cc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1802,6 +1802,24 @@ void Endpoint::Receive(const uint8_t* data,
18021802
// successfully decoded. Send a Version Negotiation response
18031803
// per RFC 9000 Section 6. The VN packet's DCID is the client's
18041804
// SCID and vice versa (mirrored back to the client).
1805+
//
1806+
// ngtcp2_pkt_decode_version_cid() only enforces the
1807+
// NGTCP2_MAX_CIDLEN limit for *supported* versions; for an
1808+
// unsupported version it returns the raw connection ID lengths
1809+
// taken from the single-byte length fields on the wire, which can
1810+
// be up to 255. Constructing a CID -- backed by a fixed
1811+
// NGTCP2_MAX_CIDLEN-byte buffer -- from such a length writes past
1812+
// the buffer (an assertion abort in release builds). A single
1813+
// unauthenticated UDP datagram could therefore crash the endpoint
1814+
// before any handshake. Drop these packets, mirroring the
1815+
// CID-length policy applied below for supported versions.
1816+
if (pversion_cid.dcidlen > NGTCP2_MAX_CIDLEN ||
1817+
pversion_cid.scidlen > NGTCP2_MAX_CIDLEN) {
1818+
Debug(this,
1819+
"Version negotiation packet had incorrectly sized CIDs, "
1820+
"ignoring");
1821+
return;
1822+
}
18051823
Debug(this,
18061824
"Packet version %d is not supported, sending version negotiation",
18071825
pversion_cid.version);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
// Regression test for an unauthenticated remote crash in the QUIC Version
4+
// Negotiation path.
5+
//
6+
// ngtcp2_pkt_decode_version_cid() does not clamp the connection ID lengths
7+
// to NGTCP2_MAX_CIDLEN (20) when the packet's version is unsupported -- it
8+
// returns the raw single-byte length fields from the wire, which can be up
9+
// to 255. Endpoint::Receive() used to build CID objects (each backed by a
10+
// fixed 20-byte buffer) directly from those lengths before any bound check,
11+
// so a single crafted UDP datagram with an oversized DCID/SCID length would
12+
// overflow the buffer and abort the process before the handshake.
13+
//
14+
// This test sends such a datagram directly with node:dgram and asserts the
15+
// endpoint drops it without crashing, while a well-formed unsupported-version
16+
// datagram still produces exactly one Version Negotiation response.
17+
18+
import { hasQuic, skip, mustCall } from '../common/index.mjs';
19+
import assert from 'node:assert';
20+
21+
const { strictEqual } = assert;
22+
23+
if (!hasQuic) {
24+
skip('QUIC is not enabled');
25+
}
26+
27+
const { createSocket } = await import('node:dgram');
28+
const { listen } = await import('../common/quic.mjs');
29+
30+
// A long-header QUIC packet must be at least NGTCP2_MAX_UDP_PAYLOAD_SIZE
31+
// (1200) bytes for an unsupported version to decode as a VN trigger.
32+
const PACKET_SIZE = 1200;
33+
34+
// Build a QUIC long-header packet with an unsupported version and the given
35+
// DCID/SCID lengths. Lengths greater than 20 are only expressible because the
36+
// on-wire length field is a single byte (0-255).
37+
function buildLongHeaderPacket(dcidLen, scidLen) {
38+
const packet = Buffer.alloc(PACKET_SIZE);
39+
// Header form bit (0x80) + fixed bit (0x40).
40+
packet[0] = 0xc0;
41+
// Version 0x0a0a0a0a: nonzero (so it is not a real VN packet) and not a
42+
// version Node supports, which forces the NGTCP2_ERR_VERSION_NEGOTIATION
43+
// decode path.
44+
packet.writeUInt32BE(0x0a0a0a0a, 1);
45+
packet[5] = dcidLen; // DCID length byte
46+
// DCID bytes occupy [6, 6 + dcidLen); SCID length byte follows them.
47+
packet[6 + dcidLen] = scidLen;
48+
// Remaining bytes stay zero-filled as padding to reach PACKET_SIZE.
49+
return packet;
50+
}
51+
52+
const serverEndpoint = await listen(mustCall(() => {}, 0));
53+
const { address, port } = serverEndpoint.address;
54+
55+
const socket = createSocket('udp4');
56+
57+
function send(packet) {
58+
return new Promise((resolve, reject) => {
59+
socket.send(packet, port, address, (err) => err ? reject(err) : resolve());
60+
});
61+
}
62+
63+
// 1. Oversized DCID (21 > NGTCP2_MAX_CIDLEN). Before the fix this aborted the
64+
// process. After the fix it must be dropped silently.
65+
await send(buildLongHeaderPacket(21, 0));
66+
67+
// 2. A well-formed unsupported-version packet with valid (<= 20 byte) CIDs.
68+
// This must still elicit exactly one Version Negotiation response, proving
69+
// the fix did not break legitimate version negotiation.
70+
await send(buildLongHeaderPacket(8, 8));
71+
72+
// Poll until the valid packet has been processed into a VN response.
73+
const deadline = Date.now() + 2000;
74+
while (serverEndpoint.stats.versionNegotiationCount === 0n) {
75+
if (Date.now() > deadline) break;
76+
await new Promise((resolve) => setTimeout(resolve, 25));
77+
}
78+
79+
// Exactly one VN response: the oversized packet was dropped (not crashed, not
80+
// negotiated), the valid packet was negotiated.
81+
strictEqual(serverEndpoint.stats.versionNegotiationCount, 1n);
82+
83+
socket.close();
84+
await serverEndpoint.close();

0 commit comments

Comments
 (0)