QUIC: Handle CRYPTO frames across multiple INITIAL packets

Instead of sending the accumulated CRYPTO frames after processing an
INITIAL packet, add logic to determine the total length of the TLS
Client or Server Hello (by peeking into the first 4 byte). Once all
CRYPTO frames have arrived, flush the reassembled data to the TLS
analyzer at once.
This commit is contained in:
Arne Welzel 2025-04-30 12:39:40 +02:00
parent e459d96fb6
commit ae90524027
11 changed files with 169 additions and 76 deletions

View file

@ -22,21 +22,88 @@ public function decrypt_crypto_payload(
############## ##############
# Can we decrypt? # Can we decrypt?
function can_decrypt(long_header: LongHeaderPacket, context: ConnectionIDInfo, is_client: bool): bool { function can_decrypt(long_header: LongHeaderPacket, context: ConnectionIDInfo, crypto: CryptoSinkUnit&): bool {
if ( ! long_header.is_initial ) if ( ! long_header.is_initial )
return False; return False;
if ( is_client ) if ( crypto == Null )
return ! context.client_initial_processed; return False;
# This is the responder, can only decrypt if we have an initial # Can only decrypt the responder if we've seen the initial destination conn id.
# destination_id from the client if ( ! crypto.is_orig && |context.initial_destination_conn_id| == 0 )
return context.client_initial_processed return False;
&& |context.initial_destination_conn_id| > 0
&& ! context.server_initial_processed; # Only attempt decryption if we haven't flushed some SSL data yet.
return ! crypto.finished;
} }
# This unit is connected with the server and client sinks receiving
# CRYPTO frames and forwards data to the SSL handle in the context.
type CryptoSinkUnit = unit(is_orig: bool, context: ConnectionIDInfo&) {
var buffered: bytes;
var length: uint32 = 0;
var is_orig: bool = is_orig;
var finished: bool;
# The first 4 bytes of crypto data contain the expected tag and a
# 24bit length from the TLS HandshakeMessage. Extract the length
# so we can determine when all CRYPTO frames have arrived.
#
# https://datatracker.ietf.org/doc/html/rfc8446#section-4
#
# struct {
# HandshakeType msg_type; /* handshake type */
# uint24 length; /* remaining bytes in message */
# ...
#
: uint8 {
self.buffered += $$;
}
len: uint8[3] {
self.length = (cast<uint32>($$[0]) << 16) + (cast<uint32>($$[1]) << 8) + cast<uint32>($$[2]) + 4;
self.buffered += $$[0];
self.buffered += $$[1];
self.buffered += $$[2];
}
: void &requires=(self.length <= 2**14 + 256) { # The length MUST NOT exceed 2^14 + 256 bytes (RFC 8446)
# The client or server hello data is forwarded to the SSL analyzer as a
# TLSPlaintext record with legacy_record_version set to \x03\x03 (1.3).
#
# enum {
# invalid(0),
# change_cipher_spec(20),
# alert(21),
# handshake(22),
# application_data(23),
# (255)
# } ContentType;
#
# struct {
# ContentType type;
# ProtocolVersion legacy_record_version;
# uint16 length;
# opaque fragment[TLSPlaintext.length];
# } TLSPlaintext;
#
# https://datatracker.ietf.org/doc/html/rfc8446#section-5.1
local length_bytes = pack(cast<uint16>(self.length), spicy::ByteOrder::Big);
zeek::protocol_data_in(is_orig, b"\x16\x03\x03" + length_bytes + self.buffered, context.ssl_handle);
}
: bytes &chunked &size=(self.length - 4) {
zeek::protocol_data_in(is_orig, $$, context.ssl_handle);
}
: void {
self.finished = True;
}
};
type ConnectionIDInfo = struct { type ConnectionIDInfo = struct {
client_cid_len: uint8; client_cid_len: uint8;
server_cid_len: uint8; server_cid_len: uint8;
@ -48,24 +115,11 @@ type ConnectionIDInfo = struct {
# https://quicwg.org/base-drafts/rfc9001.html#appendix-A # https://quicwg.org/base-drafts/rfc9001.html#appendix-A
initial_destination_conn_id: bytes; initial_destination_conn_id: bytes;
# Currently, this analyzer assumes that ClientHello # Track crypto state.
# and ServerHello fit into the first INITIAL packet (and client_crypto: CryptoSinkUnit&;
# that there is only one that we're interested in. client_sink: sink&;
# server_crypto: CryptoSinkUnit&;
# But minimally the following section sounds like this might not server_sink: sink&;
# hold in general and the Wireshark has samples showing
# the handshake spanning across more than two INITIAL packets.
# (quic-fragmented-handshakes.pcapng.gz)
#
# https://datatracker.ietf.org/doc/html/rfc9001#section-4.3
#
# Possible fix is to buffer up all CRYPTO frames across multiple
# INITIAL packets until we see a non-INITIAL frame.
#
# We also rely heavily on getting originator and responder right.
#
client_initial_processed: bool;
server_initial_processed: bool;
ssl_handle: zeek::ProtocolHandle &optional; ssl_handle: zeek::ProtocolHandle &optional;
}; };
@ -272,7 +326,7 @@ public type LongHeaderPacket = unit {
}; };
# A QUIC Frame. # A QUIC Frame.
public type Frame = unit(header: LongHeaderPacket, from_client: bool, crypto_sink: sink&) { public type Frame = unit(header: LongHeaderPacket, from_client: bool, crypto: CryptoSinkUnit, crypto_sink: sink&) {
frame_type : uint8 &convert=cast<FrameType>($$); frame_type : uint8 &convert=cast<FrameType>($$);
# TODO: add other FrameTypes as well # TODO: add other FrameTypes as well
@ -282,6 +336,18 @@ public type Frame = unit(header: LongHeaderPacket, from_client: bool, crypto_sin
FrameType::CRYPTO -> c: CRYPTOPayload(from_client) { FrameType::CRYPTO -> c: CRYPTOPayload(from_client) {
# Have the sink re-assemble potentially out-of-order cryptodata # Have the sink re-assemble potentially out-of-order cryptodata
crypto_sink.write(self.c.cryptodata, self.c.offset.result_); crypto_sink.write(self.c.cryptodata, self.c.offset.result_);
# If the crypto unit has determined a valid length, ensure we
# don't attempt to write more bytes into the sink. If it doesn't,
# use 2000 bytes as an arbitrary limit required to observe the
# length of the contained Client Hello or Server Hello.
if ( crypto.length > 0 ) {
if ( |crypto_sink| > crypto.length )
throw "too much crypto data received %s > %s" % ( |crypto_sink|, crypto.length);
} else {
if ( |crypto_sink| > 2000 )
throw "too much crypto data without length received %s" % |crypto_sink|;
}
} }
FrameType::CONNECTION_CLOSE1 -> : ConnectionClosePayload(header); FrameType::CONNECTION_CLOSE1 -> : ConnectionClosePayload(header);
FrameType::PADDING -> : skip /\x00*/; # eat the padding FrameType::PADDING -> : skip /\x00*/; # eat the padding
@ -408,18 +474,6 @@ public type ShortPacketPayload = unit {
payload: skip bytes &eod; payload: skip bytes &eod;
}; };
# Buffer all crypto messages (which might be fragmented and unordered)
# into the following unit.
type CryptoBuffer = unit() {
var buffered: bytes;
: bytes &chunked &eod {
self.buffered += $$;
# print "crypto_buffer got data", |$$|, |self.buffered|;
}
};
############## ##############
# QUIC packet parsing # QUIC packet parsing
# #
@ -430,8 +484,8 @@ type Packet = unit(from_client: bool, context: ConnectionIDInfo&) {
var packet_size: uint64 = 0; var packet_size: uint64 = 0;
var start: iterator<stream>; var start: iterator<stream>;
sink crypto_sink; var crypto: CryptoSinkUnit&;
var crypto_buffer: CryptoBuffer&; var crypto_sink: sink&;
# Attach an SSL analyzer to this connection once. # Attach an SSL analyzer to this connection once.
on %init { on %init {
@ -440,6 +494,26 @@ type Packet = unit(from_client: bool, context: ConnectionIDInfo&) {
} }
self.start = self.input(); self.start = self.input();
# Initialize crypto state in context for both sides if not already done.
if ( context.client_crypto == Null ) {
assert ! context.server_crypto;
context.client_crypto = new CryptoSinkUnit(True, context);
context.client_sink = new sink;
context.client_sink.connect(context.client_crypto);
context.server_crypto = new CryptoSinkUnit(False, context);
context.server_sink = new sink;
context.server_sink.connect(context.server_crypto);
}
if ( from_client ) {
self.crypto = context.client_crypto;
self.crypto_sink = context.client_sink;
} else {
self.crypto = context.server_crypto;
self.crypto_sink = context.server_sink;
}
} }
# Peek into the first byte and determine the header type. # Peek into the first byte and determine the header type.
@ -453,7 +527,6 @@ type Packet = unit(from_client: bool, context: ConnectionIDInfo&) {
self.set_input(self.start); # rewind self.set_input(self.start); # rewind
} }
# Depending on the header, parse it and update the src/dest ConnectionID's # Depending on the header, parse it and update the src/dest ConnectionID's
switch ( self.first_byte.header_form ) { switch ( self.first_byte.header_form ) {
HeaderForm::SHORT -> short_header: ShortHeader(context.client_cid_len); HeaderForm::SHORT -> short_header: ShortHeader(context.client_cid_len);
@ -463,19 +536,25 @@ type Packet = unit(from_client: bool, context: ConnectionIDInfo&) {
# If we see a retry packet from the responder, reset the decryption # If we see a retry packet from the responder, reset the decryption
# context such that the next DCID from the client is used for decryption. # context such that the next DCID from the client is used for decryption.
if ( self.long_header.is_retry ) { if ( self.long_header.is_retry ) {
context.client_initial_processed = False;
context.server_initial_processed = False;
context.initial_destination_conn_id = b"";
# Allow re-opening the SSL analyzer the next time around. # Recreate all the crypto state on the next %init of Packet.
zeek::protocol_handle_close(context.ssl_handle); zeek::protocol_handle_close(context.ssl_handle);
unset context.ssl_handle; unset context.ssl_handle;
context.client_crypto = Null;
context.server_crypto = Null;
context.client_sink = Null;
context.server_sink = Null;
self.crypto = Null;
self.crypto_sink = Null;
# Reset crypto state!
context.initial_destination_conn_id = b"";
} }
} }
}; };
: void { : void {
if (self?.long_header && can_decrypt(self.long_header, context, from_client)) if ( self?.long_header && can_decrypt(self.long_header, context, self.crypto ) )
# If we have parsed an initial packet that we can decrypt the payload, # If we have parsed an initial packet that we can decrypt the payload,
# determine the size to store into a buffer. # determine the size to store into a buffer.
self.packet_size = self.offset(); self.packet_size = self.offset();
@ -483,8 +562,6 @@ type Packet = unit(from_client: bool, context: ConnectionIDInfo&) {
# Buffer the whole packet if we determined we have a chance to decrypt. # Buffer the whole packet if we determined we have a chance to decrypt.
packet_data: bytes &parse-at=self.start &size=self.packet_size if ( self.packet_size > 0 ) { packet_data: bytes &parse-at=self.start &size=self.packet_size if ( self.packet_size > 0 ) {
self.crypto_buffer = new CryptoBuffer();
self.crypto_sink.connect(self.crypto_buffer);
if ( from_client ) { if ( from_client ) {
context.server_cid_len = self.long_header.dest_conn_id_len; context.server_cid_len = self.long_header.dest_conn_id_len;
@ -537,33 +614,7 @@ type Packet = unit(from_client: bool, context: ConnectionIDInfo&) {
# If this was packet with a long header and decrypted data exists, attempt # If this was packet with a long header and decrypted data exists, attempt
# to parse the plain QUIC frames from it. # to parse the plain QUIC frames from it.
frames: Frame(self.long_header, from_client, self.crypto_sink)[] &parse-from=self.decrypted_data if (self.first_byte.header_form == HeaderForm::LONG && |self.decrypted_data| > 0); frames: Frame(self.long_header, from_client, self.crypto, self.crypto_sink)[] &parse-from=self.decrypted_data if (self.first_byte.header_form == HeaderForm::LONG && |self.decrypted_data| > 0);
# Once the Packet is fully parsed, pass the accumulated CRYPTO frames
# to the SSL analyzer as handshake data.
on %done {
# print "packet done", zeek::is_orig(), self.first_byte.header_form, |self.decrypted_data|;
if ( self.crypto_buffer != Null && |self.crypto_buffer.buffered| > 0 ) {
local handshake_data = self.crypto_buffer.buffered;
# The data is passed to the SSL analyzer as part of a HANDSHAKE (0x16) message with TLS1.3 (\x03\x03).
# The 2 length bytes are also passed, followed by the actual CRYPTO blob which contains a CLIENT HELLO or SERVER HELLO
local length_bytes = pack(cast<uint16>(|handshake_data|), spicy::ByteOrder::Big);
zeek::protocol_data_in(
from_client
, b"\x16\x03\x03" + length_bytes + handshake_data
, context.ssl_handle
);
# Stop decryption attempts after processing the very first INITIAL
# INITIAL packet for which we forwarded data to the SSL analyzer.
if ( from_client )
context.client_initial_processed = True;
else
context.server_initial_processed = True;
}
}
}; };
############## ##############

View file

@ -0,0 +1,3 @@
### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
ts uid history service
XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 D quic,ssl

View file

@ -0,0 +1,3 @@
### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
ts uid server_name history
XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 googleads.g.doubleclick.net IIIS

View file

@ -0,0 +1,3 @@
### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
ts uid version cipher curve server_name resumed last_alert next_protocol established ssl_history
XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 - - - googleads.g.doubleclick.net F - - F C

View file

@ -0,0 +1,3 @@
### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
ts uid history service
XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 Dd quic,ssl

View file

@ -0,0 +1,3 @@
### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
ts uid server_name history
XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 googleads.g.doubleclick.net IIISZZZiIiIIIIIIZ

View file

@ -0,0 +1,3 @@
### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
ts uid version cipher curve server_name resumed last_alert next_protocol established ssl_history
XXXXXXXXXX.XXXXXX CHhAvVGS1DHFjwGM9 - - - googleads.g.doubleclick.net F - - F C

View file

@ -0,0 +1,12 @@
# @TEST-DOC: Pcap with CRYPTO frames fragemented over multiple INITIAL packets. The pcap only contains 3 INITIAL packets. Check what logs are created.
# @TEST-REQUIRES: ${SCRIPTS}/have-spicy
# @TEST-EXEC: zeek -Cr $TRACES/quic/quic-multiple-initial-fragmented-crypto-only-initial.pcap base/protocols/quic
# @TEST-EXEC: test ! -f analyzer.log
# @TEST-EXEC: test ! -f dpd.log
# @TEST-EXEC: zeek-cut -m ts uid history service < conn.log > conn.log.cut
# @TEST-EXEC: btest-diff conn.log.cut
# @TEST-EXEC: zeek-cut -m ts uid server_name history < quic.log > quic.log.cut
# @TEST-EXEC: btest-diff quic.log.cut
# @TEST-EXEC: zeek-cut -m ts uid version cipher curve server_name resumed last_alert next_protocol established ssl_history < ssl.log > ssl.log.cut
# @TEST-EXEC: btest-diff ssl.log.cut

View file

@ -0,0 +1,12 @@
# @TEST-DOC: Pcap with CRYPTO frames fragemented over multiple INITIAL packets.
# @TEST-REQUIRES: ${SCRIPTS}/have-spicy
# @TEST-EXEC: zeek -Cr $TRACES/quic/quic-multiple-initial-fragmented-crypto.pcap base/protocols/quic
# @TEST-EXEC: test ! -f analyzer.log
# @TEST-EXEC: test ! -f dpd.log
# @TEST-EXEC: zeek-cut -m ts uid history service < conn.log > conn.log.cut
# @TEST-EXEC: btest-diff conn.log.cut
# @TEST-EXEC: zeek-cut -m ts uid server_name history < quic.log > quic.log.cut
# @TEST-EXEC: btest-diff quic.log.cut
# @TEST-EXEC: zeek-cut -m ts uid version cipher curve server_name resumed last_alert next_protocol established ssl_history < ssl.log > ssl.log.cut
# @TEST-EXEC: btest-diff ssl.log.cut