Skip to content

Commit 01d8d46

Browse files
committed
fix: Auto-sense server only falls back on CH-shaped parse errors
The DTLS 1.3 auto-sense server previously fell back to DTLS 1.2 on any ParseError or ParseIncomplete during AwaitClientHello. That meant a single corrupted fragment of a real DTLS 1.3 ClientHello, or a stray non-handshake packet from off-path traffic, could force a downgrade. Gate the parse-error fallback on a lightweight structural check: fall back only when the packet at least claims to be a Handshake record carrying a ClientHello message. Random/garbage packets still bubble the parse error up and the server stays in 1.3 auto-sense. The check runs unconditionally before matching on the parser result so the time spent in the auto-sense dispatch does not depend on which error branch was taken. The clean Dtls12Fallback path (supported_versions parsed but did not include 1.3) is unchanged.
1 parent 654ee5a commit 01d8d46

1 file changed

Lines changed: 122 additions & 7 deletions

File tree

src/lib.rs

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,27 @@ fn is_dtls12_psk_only(config: &Config) -> bool {
294294
.is_some_and(|first| first.is_psk() && suites.all(|s| s.is_psk()))
295295
}
296296

297+
/// Lightweight structural check: does this packet look like a ClientHello?
298+
///
299+
/// Used by the auto-sense server to gate the DTLS 1.2 fallback on parse
300+
/// errors. A packet that fails to parse in the DTLS 1.3 engine should
301+
/// only trigger a downgrade if it at least claims to be a ClientHello —
302+
/// otherwise random/garbage traffic could force fallback.
303+
fn looks_like_client_hello(packet: &[u8]) -> bool {
304+
// DTLS record header: content_type(1) + version(2) + epoch(2) + seq(6) + length(2) = 13
305+
if packet.len() < 13 || packet[0] != 0x16 {
306+
return false;
307+
}
308+
let record_len = u16::from_be_bytes([packet[11], packet[12]]) as usize;
309+
let Some(record_body) = packet.get(13..13 + record_len) else {
310+
return false;
311+
};
312+
313+
// Handshake header: msg_type(1) + length(3) + message_seq(2) +
314+
// fragment_offset(3) + fragment_length(3) = 12
315+
record_body.len() >= 12 && record_body[0] == 0x01
316+
}
317+
297318
/// Peek at a buffered DTLS 1.2 ClientHello to decide whether the auto-sense
298319
/// server fallback should construct a PSK-mode Server12.
299320
///
@@ -540,15 +561,25 @@ impl Dtls {
540561
match self.inner.as_mut().unwrap() {
541562
Inner::ClientPending(_) => self.handle_pending_auto_client(packet),
542563
Inner::Server13(server) if server.is_auto_mode() => {
564+
// Run the structural check unconditionally so the time
565+
// spent here does not leak which error branch the parser
566+
// took — same cost whether handle_packet returns Ok,
567+
// Dtls12Fallback, ParseError, or anything else.
568+
let is_ch_shaped = looks_like_client_hello(packet);
543569
match server.handle_packet(packet) {
544570
Ok(()) => Ok(()),
545-
Err(Error::Dtls12Fallback | Error::ParseError(_) | Error::ParseIncomplete) => {
546-
// We detected a DTLS12 ClientHello, or the very
547-
// first packet failed to parse in the
548-
// DTLS 1.3 message parser (e.g. a pure DTLS 1.2
549-
// ClientHello with no 1.3 cipher suites). Fall
550-
// back to 1.2. Later parse errors (corrupted
551-
// fragments of a 1.3 CH) are not caught here.
571+
Err(Error::Dtls12Fallback) => {
572+
// The 1.3 engine cleanly rejected a ClientHello
573+
// that did not offer DTLS 1.3 in supported_versions.
574+
self.handle_pending_auto_server()
575+
}
576+
Err(Error::ParseError(_) | Error::ParseIncomplete) if is_ch_shaped => {
577+
// The packet is structurally a ClientHello but the
578+
// 1.3 parser couldn't handle it — fall back to 1.2,
579+
// which has a more permissive parser. Random/garbage
580+
// traffic that happens to error is not caught here,
581+
// so an off-path attacker cannot force a downgrade
582+
// by spraying malformed packets.
552583
self.handle_pending_auto_server()
553584
}
554585
Err(e) => Err(e),
@@ -984,4 +1015,88 @@ mod test {
9841015
let err = dtls.close().unwrap_err();
9851016
assert!(matches!(err, Error::HandshakePending));
9861017
}
1018+
1019+
fn make_record(content_type: u8, body: &[u8]) -> Vec<u8> {
1020+
let mut pkt = Vec::with_capacity(13 + body.len());
1021+
pkt.push(content_type);
1022+
pkt.extend_from_slice(&[0xFE, 0xFD]); // version
1023+
pkt.extend_from_slice(&[0x00, 0x00]); // epoch
1024+
pkt.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); // seq
1025+
pkt.extend_from_slice(&(body.len() as u16).to_be_bytes());
1026+
pkt.extend_from_slice(body);
1027+
pkt
1028+
}
1029+
1030+
fn make_handshake_body(msg_type: u8) -> Vec<u8> {
1031+
let mut body = Vec::new();
1032+
body.push(msg_type);
1033+
body.extend_from_slice(&[0x00, 0x00, 0x00]); // length
1034+
body.extend_from_slice(&[0x00, 0x00]); // message_seq
1035+
body.extend_from_slice(&[0x00, 0x00, 0x00]); // fragment_offset
1036+
body.extend_from_slice(&[0x00, 0x00, 0x00]); // fragment_length
1037+
body
1038+
}
1039+
1040+
#[test]
1041+
fn looks_like_client_hello_accepts_handshake_with_ch_msg_type() {
1042+
let body = make_handshake_body(0x01);
1043+
let pkt = make_record(0x16, &body);
1044+
assert!(looks_like_client_hello(&pkt));
1045+
}
1046+
1047+
#[test]
1048+
fn looks_like_client_hello_rejects_non_handshake_record() {
1049+
let body = make_handshake_body(0x01);
1050+
let pkt = make_record(0x17, &body); // ApplicationData
1051+
assert!(!looks_like_client_hello(&pkt));
1052+
}
1053+
1054+
#[test]
1055+
fn looks_like_client_hello_rejects_other_handshake_msg_types() {
1056+
// ServerHello, HelloVerifyRequest, Finished, etc.
1057+
for msg_type in [0x02, 0x03, 0x04, 0x0B, 0x0E, 0x14] {
1058+
let body = make_handshake_body(msg_type);
1059+
let pkt = make_record(0x16, &body);
1060+
assert!(
1061+
!looks_like_client_hello(&pkt),
1062+
"msg_type {:#x} should not look like a CH",
1063+
msg_type
1064+
);
1065+
}
1066+
}
1067+
1068+
#[test]
1069+
fn looks_like_client_hello_rejects_truncated_packets() {
1070+
assert!(!looks_like_client_hello(&[]));
1071+
assert!(!looks_like_client_hello(&[0x16; 12])); // too short for record header
1072+
// Record header claims body length 100 but no body bytes follow.
1073+
let mut pkt = vec![0x16, 0xFE, 0xFD, 0, 0, 0, 0, 0, 0, 0, 0];
1074+
pkt.extend_from_slice(&100u16.to_be_bytes());
1075+
assert!(!looks_like_client_hello(&pkt));
1076+
}
1077+
1078+
#[test]
1079+
fn looks_like_client_hello_rejects_short_handshake_body() {
1080+
// Valid record header but handshake body too short (< 12 bytes).
1081+
let pkt = make_record(0x16, &[0x01, 0x00, 0x00]);
1082+
assert!(!looks_like_client_hello(&pkt));
1083+
}
1084+
1085+
#[test]
1086+
fn auto_server_drops_garbage_without_falling_back() {
1087+
let mut dtls = new_instance_auto();
1088+
// Random non-handshake bytes — the 1.3 engine will error, but the
1089+
// auto-sense path must not downgrade to 1.2.
1090+
let garbage = [0xFF; 64];
1091+
let _ = dtls.handle_packet(&garbage);
1092+
// Inner must remain Server13 in auto-sense mode.
1093+
let still_pending = match &dtls.inner {
1094+
Some(Inner::Server13(s)) => s.is_auto_mode(),
1095+
_ => false,
1096+
};
1097+
assert!(
1098+
still_pending,
1099+
"auto-sense server must not fall back to DTLS 1.2 on garbage input"
1100+
);
1101+
}
9871102
}

0 commit comments

Comments
 (0)