feat(tls): reassemble hello messages split across TLS records
A handshake message may span any number of records, and splitting the ClientHello into tiny records hides the SNI from analyzers that expect it in the first one. The tls analyzer read the first record header and then cut the handshake body straight out of the stream, so fragmented hellos either stalled until Limit() or failed to parse, and every SNI rule was bypassed. The record layer is now peeled off and the hello rebuilt from the record bodies before parsing, for both ClientHello and ServerHello. The number of records a hello spanned is exposed as tls.req_records / tls.resp_records, kept outside req/resp so a non-nil req still means a fully parsed hello. Fragmentation is published as soon as a second record shows up, since 1-byte records can stretch a hello past Limit() and it never completes. docs: add TLS record fragmentation doc, correct the claim in the TCP window evasion doc that tls is unaffected by splitting. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+169
-168
@@ -22,38 +22,68 @@ func (a *TLSAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) anal
|
||||
return newTLSStream(logger)
|
||||
}
|
||||
|
||||
const (
|
||||
// tlsRecordHeaderSize is content type (1 byte) + legacy protocol version
|
||||
// (2 bytes) + length (2 bytes).
|
||||
tlsRecordHeaderSize = 5
|
||||
// tlsMaxPlaintextLen is the largest body a plaintext record may carry.
|
||||
tlsMaxPlaintextLen = 1 << 14
|
||||
// tlsHandshakeHeaderSize is message type (1 byte) + length (3 bytes).
|
||||
tlsHandshakeHeaderSize = 4
|
||||
)
|
||||
|
||||
type tlsStream struct {
|
||||
logger analyzer.Logger
|
||||
|
||||
reqBuf *utils.ByteBuffer
|
||||
reqMap analyzer.PropMap
|
||||
reqUpdated bool
|
||||
reqLSM *utils.LinearStateMachine
|
||||
reqDone bool
|
||||
req tlsHello
|
||||
resp tlsHello
|
||||
}
|
||||
|
||||
respBuf *utils.ByteBuffer
|
||||
respMap analyzer.PropMap
|
||||
respUpdated bool
|
||||
respLSM *utils.LinearStateMachine
|
||||
respDone bool
|
||||
// tlsHello reads one hello message off one direction of the stream.
|
||||
//
|
||||
// A handshake message may be split across any number of records (RFC 8446
|
||||
// 5.1), and splitting the ClientHello into tiny records is a common way to
|
||||
// hide the SNI from middleboxes that expect it in the first record. So the
|
||||
// record layer is peeled off first and the message is rebuilt from the record
|
||||
// bodies before it is parsed.
|
||||
type tlsHello struct {
|
||||
rec utils.ByteBuffer // record layer bytes not consumed yet
|
||||
msg utils.ByteBuffer // handshake layer bytes collected so far
|
||||
lsm *utils.LinearStateMachine
|
||||
|
||||
clientHelloLen int
|
||||
serverHelloLen int
|
||||
msgType byte
|
||||
minLen int // smallest valid message body
|
||||
parse func(*utils.ByteBuffer) analyzer.PropMap // parses the message body
|
||||
|
||||
recLeft int // body bytes of the current record not consumed yet
|
||||
msgLen int // message body length, -1 until the handshake header is read
|
||||
records int // records that have carried the message so far
|
||||
reported int // value of records last published
|
||||
|
||||
m analyzer.PropMap
|
||||
updated bool
|
||||
done bool
|
||||
}
|
||||
|
||||
func newTLSStream(logger analyzer.Logger) *tlsStream {
|
||||
s := &tlsStream{logger: logger, reqBuf: &utils.ByteBuffer{}, respBuf: &utils.ByteBuffer{}}
|
||||
s.reqLSM = utils.NewLinearStateMachine(
|
||||
s.tlsClientHelloPreprocess,
|
||||
s.parseClientHelloData,
|
||||
)
|
||||
s.respLSM = utils.NewLinearStateMachine(
|
||||
s.tlsServerHelloPreprocess,
|
||||
s.parseServerHelloData,
|
||||
)
|
||||
s := &tlsStream{logger: logger}
|
||||
// minimal ClientHello body: protocol version (2 bytes) + random (32 bytes) +
|
||||
// + session ID (1 byte) + cipher suites (4 bytes) +
|
||||
// + compression methods (2 bytes) + no extensions
|
||||
s.req.init(internal.TypeClientHello, 41, internal.ParseTLSClientHelloMsgData)
|
||||
// minimal ServerHello body: server version (2 bytes) + random (32 bytes) +
|
||||
// + session ID (>=1 byte) + cipher suite (2 bytes) +
|
||||
// + compression method (1 byte) + no extensions
|
||||
s.resp.init(internal.TypeServerHello, 38, internal.ParseTLSServerHelloMsgData)
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *tlsHello) init(msgType byte, minLen int, parse func(*utils.ByteBuffer) analyzer.PropMap) {
|
||||
h.msgType, h.minLen, h.parse = msgType, minLen, parse
|
||||
h.msgLen = -1
|
||||
h.lsm = utils.NewLinearStateMachine(h.readMessage, h.parseMessage)
|
||||
}
|
||||
|
||||
func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, done bool) {
|
||||
if skip != 0 {
|
||||
return nil, true
|
||||
@@ -61,166 +91,137 @@ func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyz
|
||||
if len(data) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var update *analyzer.PropUpdate
|
||||
var cancelled bool
|
||||
h, key := &s.req, "req"
|
||||
if rev {
|
||||
s.respBuf.Append(data)
|
||||
s.respUpdated = false
|
||||
cancelled, s.respDone = s.respLSM.Run()
|
||||
if s.respUpdated {
|
||||
update = &analyzer.PropUpdate{
|
||||
Type: analyzer.PropUpdateMerge,
|
||||
M: analyzer.PropMap{"resp": s.respMap},
|
||||
h, key = &s.resp, "resp"
|
||||
}
|
||||
if h.done {
|
||||
return nil, false
|
||||
}
|
||||
h.rec.Append(data)
|
||||
h.updated = false
|
||||
var cancelled bool
|
||||
cancelled, h.done = h.lsm.Run()
|
||||
// A fragmented hello is published as soon as it shows up, not only once
|
||||
// the message is complete: a peer can make it too large to ever complete
|
||||
// within Limit(), and a rule should not need the SNI to act on it.
|
||||
if m := h.update(key, h.updated || (h.records > 1 && h.reported <= 1)); m != nil {
|
||||
u = &analyzer.PropUpdate{Type: analyzer.PropUpdateMerge, M: m}
|
||||
}
|
||||
return u, cancelled || (s.req.done && s.resp.done)
|
||||
}
|
||||
|
||||
// update returns the properties to publish for this direction, or nil if
|
||||
// there is nothing to publish. The parsed message goes under key, the record
|
||||
// count under key + "_records". The record count is kept out of the message
|
||||
// map so that a non-nil message map still means a fully parsed hello.
|
||||
func (h *tlsHello) update(key string, publish bool) analyzer.PropMap {
|
||||
if !publish || h.records == 0 {
|
||||
return nil
|
||||
}
|
||||
m := analyzer.PropMap{key + "_records": h.records}
|
||||
if h.updated {
|
||||
m[key] = h.m
|
||||
h.updated = false
|
||||
}
|
||||
h.reported = h.records
|
||||
return m
|
||||
}
|
||||
|
||||
// readMessage consumes records until a whole hello message is collected in
|
||||
// h.msg.
|
||||
func (h *tlsHello) readMessage() utils.LSMAction {
|
||||
for {
|
||||
if h.msgLen < 0 && h.msg.Len() >= tlsHandshakeHeaderSize {
|
||||
header := h.msg.Buf
|
||||
if header[0] != h.msgType {
|
||||
return utils.LSMActionCancel
|
||||
}
|
||||
h.msgLen = int(header[1])<<16 | int(header[2])<<8 | int(header[3])
|
||||
if h.msgLen < h.minLen {
|
||||
return utils.LSMActionCancel
|
||||
}
|
||||
s.respUpdated = false
|
||||
}
|
||||
} else {
|
||||
s.reqBuf.Append(data)
|
||||
s.reqUpdated = false
|
||||
cancelled, s.reqDone = s.reqLSM.Run()
|
||||
if s.reqUpdated {
|
||||
update = &analyzer.PropUpdate{
|
||||
Type: analyzer.PropUpdateMerge,
|
||||
M: analyzer.PropMap{"req": s.reqMap},
|
||||
if h.msgLen >= 0 && h.msg.Len() >= tlsHandshakeHeaderSize+h.msgLen {
|
||||
return utils.LSMActionNext
|
||||
}
|
||||
if h.recLeft == 0 {
|
||||
header, ok := h.rec.Get(tlsRecordHeaderSize, true)
|
||||
if !ok {
|
||||
return utils.LSMActionPause
|
||||
}
|
||||
s.reqUpdated = false
|
||||
if header[0] != internal.RecordTypeHandshake {
|
||||
// Nothing else may come between the records of a handshake
|
||||
// message.
|
||||
return utils.LSMActionCancel
|
||||
}
|
||||
h.recLeft = int(header[3])<<8 | int(header[4])
|
||||
if h.recLeft > tlsMaxPlaintextLen {
|
||||
return utils.LSMActionCancel
|
||||
}
|
||||
// Empty records are forbidden for handshake messages, but still
|
||||
// counted rather than rejected: a peer that sends them anyway is
|
||||
// fragmenting, and rejecting them would stop the analysis.
|
||||
h.records++
|
||||
continue
|
||||
}
|
||||
n := min(h.recLeft, h.rec.Len())
|
||||
if n == 0 {
|
||||
return utils.LSMActionPause
|
||||
}
|
||||
body, _ := h.rec.Get(n, true)
|
||||
h.recLeft -= n
|
||||
if h.msg.Len() == 0 {
|
||||
// Usually the whole message sits in this one chunk, so reference
|
||||
// it instead of copying. The capacity is capped so that a later
|
||||
// Append reallocates rather than writing into h.rec.
|
||||
h.msg.Buf = body[:n:n]
|
||||
} else {
|
||||
h.msg.Append(body)
|
||||
}
|
||||
}
|
||||
return update, cancelled || (s.reqDone && s.respDone)
|
||||
}
|
||||
|
||||
// tlsClientHelloPreprocess validates ClientHello message.
|
||||
// parseMessage converts the collected hello message into h.m.
|
||||
//
|
||||
// During validation, message header and first handshake header may be removed
|
||||
// from `s.reqBuf`.
|
||||
func (s *tlsStream) tlsClientHelloPreprocess() utils.LSMAction {
|
||||
// headers size: content type (1 byte) + legacy protocol version (2 bytes) +
|
||||
// + content length (2 bytes) + message type (1 byte) +
|
||||
// + handshake length (3 bytes)
|
||||
const headersSize = 9
|
||||
|
||||
// minimal data size: protocol version (2 bytes) + random (32 bytes) +
|
||||
// + session ID (1 byte) + cipher suites (4 bytes) +
|
||||
// + compression methods (2 bytes) + no extensions
|
||||
const minDataSize = 41
|
||||
|
||||
header, ok := s.reqBuf.Get(headersSize, true)
|
||||
if !ok {
|
||||
// not a full header yet
|
||||
return utils.LSMActionPause
|
||||
}
|
||||
|
||||
if header[0] != internal.RecordTypeHandshake || header[5] != internal.TypeClientHello {
|
||||
return utils.LSMActionCancel
|
||||
}
|
||||
|
||||
s.clientHelloLen = int(header[6])<<16 | int(header[7])<<8 | int(header[8])
|
||||
if s.clientHelloLen < minDataSize {
|
||||
return utils.LSMActionCancel
|
||||
}
|
||||
|
||||
// TODO: something is missing. See:
|
||||
// const messageHeaderSize = 4
|
||||
// fullMessageLen := int(header[3])<<8 | int(header[4])
|
||||
// msgNo := fullMessageLen / int(messageHeaderSize+s.serverHelloLen)
|
||||
// if msgNo != 1 {
|
||||
// // what here?
|
||||
// }
|
||||
// if messageNo != int(messageNo) {
|
||||
// // what here?
|
||||
// }
|
||||
|
||||
return utils.LSMActionNext
|
||||
}
|
||||
|
||||
// tlsServerHelloPreprocess validates ServerHello message.
|
||||
//
|
||||
// During validation, message header and first handshake header may be removed
|
||||
// from `s.reqBuf`.
|
||||
func (s *tlsStream) tlsServerHelloPreprocess() utils.LSMAction {
|
||||
// header size: content type (1 byte) + legacy protocol version (2 byte) +
|
||||
// + content length (2 byte) + message type (1 byte) +
|
||||
// + handshake length (3 byte)
|
||||
const headersSize = 9
|
||||
|
||||
// minimal data size: server version (2 byte) + random (32 byte) +
|
||||
// + session ID (>=1 byte) + cipher suite (2 byte) +
|
||||
// + compression method (1 byte) + no extensions
|
||||
const minDataSize = 38
|
||||
|
||||
header, ok := s.respBuf.Get(headersSize, true)
|
||||
if !ok {
|
||||
// not a full header yet
|
||||
return utils.LSMActionPause
|
||||
}
|
||||
|
||||
if header[0] != internal.RecordTypeHandshake || header[5] != internal.TypeServerHello {
|
||||
return utils.LSMActionCancel
|
||||
}
|
||||
|
||||
s.serverHelloLen = int(header[6])<<16 | int(header[7])<<8 | int(header[8])
|
||||
if s.serverHelloLen < minDataSize {
|
||||
return utils.LSMActionCancel
|
||||
}
|
||||
|
||||
// TODO: something is missing. See example:
|
||||
// const messageHeaderSize = 4
|
||||
// fullMessageLen := int(header[3])<<8 | int(header[4])
|
||||
// msgNo := fullMessageLen / int(messageHeaderSize+s.serverHelloLen)
|
||||
// if msgNo != 1 {
|
||||
// // what here?
|
||||
// }
|
||||
// if messageNo != int(messageNo) {
|
||||
// // what here?
|
||||
// }
|
||||
|
||||
return utils.LSMActionNext
|
||||
}
|
||||
|
||||
// parseClientHelloData converts valid ClientHello message data (without
|
||||
// headers) into `analyzer.PropMap`.
|
||||
//
|
||||
// Parsing error may leave `s.reqBuf` in an unusable state.
|
||||
func (s *tlsStream) parseClientHelloData() utils.LSMAction {
|
||||
chBuf, ok := s.reqBuf.GetSubBuffer(s.clientHelloLen, true)
|
||||
if !ok {
|
||||
// Not a full client hello yet
|
||||
return utils.LSMActionPause
|
||||
}
|
||||
m := internal.ParseTLSClientHelloMsgData(chBuf)
|
||||
// Anything after the message, such as the rest of the server's flight, is
|
||||
// ignored.
|
||||
func (h *tlsHello) parseMessage() utils.LSMAction {
|
||||
body := &utils.ByteBuffer{Buf: h.msg.Buf[tlsHandshakeHeaderSize : tlsHandshakeHeaderSize+h.msgLen]}
|
||||
m := h.parse(body)
|
||||
h.rec.Reset()
|
||||
h.msg.Reset()
|
||||
if m == nil {
|
||||
return utils.LSMActionCancel
|
||||
} else {
|
||||
s.reqUpdated = true
|
||||
s.reqMap = m
|
||||
return utils.LSMActionNext
|
||||
}
|
||||
}
|
||||
|
||||
// parseServerHelloData converts valid ServerHello message data (without
|
||||
// headers) into `analyzer.PropMap`.
|
||||
//
|
||||
// Parsing error may leave `s.respBuf` in an unusable state.
|
||||
func (s *tlsStream) parseServerHelloData() utils.LSMAction {
|
||||
shBuf, ok := s.respBuf.GetSubBuffer(s.serverHelloLen, true)
|
||||
if !ok {
|
||||
// Not a full server hello yet
|
||||
return utils.LSMActionPause
|
||||
}
|
||||
m := internal.ParseTLSServerHelloMsgData(shBuf)
|
||||
if m == nil {
|
||||
return utils.LSMActionCancel
|
||||
} else {
|
||||
s.respUpdated = true
|
||||
s.respMap = m
|
||||
return utils.LSMActionNext
|
||||
}
|
||||
h.m = m
|
||||
h.updated = true
|
||||
return utils.LSMActionNext
|
||||
}
|
||||
|
||||
func (s *tlsStream) Close(limited bool) *analyzer.PropUpdate {
|
||||
s.reqBuf.Reset()
|
||||
s.respBuf.Reset()
|
||||
s.reqMap = nil
|
||||
s.respMap = nil
|
||||
return nil
|
||||
// Report how far a fragmented hello got if it never completed, e.g.
|
||||
// because it was stretched past Limit().
|
||||
m := s.req.update("req", s.req.records > 1 && s.req.records > s.req.reported)
|
||||
if rm := s.resp.update("resp", s.resp.records > 1 && s.resp.records > s.resp.reported); rm != nil {
|
||||
if m == nil {
|
||||
m = rm
|
||||
} else {
|
||||
for k, v := range rm {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
s.req.close()
|
||||
s.resp.close()
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &analyzer.PropUpdate{Type: analyzer.PropUpdateMerge, M: m}
|
||||
}
|
||||
|
||||
func (h *tlsHello) close() {
|
||||
h.rec.Reset()
|
||||
h.msg.Reset()
|
||||
h.m = nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user