diff --git a/README.zh.md b/README.zh.md index 5711c81..255f4ee 100644 --- a/README.zh.md +++ b/README.zh.md @@ -39,6 +39,7 @@ Telegram 群组: https://t.me/OpGFW - [Minecraft 分析器](docs/minecraft.zh.md) - [EasyTier 分析器](docs/easytier.zh.md) - [TCP 窗口操纵规避与检测](docs/tcp-window-evasion.zh.md) +- [TLS record 分片规避与检测](docs/tls-record-fragmentation.zh.md) ## 使用场景 diff --git a/analyzer/tcp/tls.go b/analyzer/tcp/tls.go index c5f1ea9..44dc1b7 100644 --- a/analyzer/tcp/tls.go +++ b/analyzer/tcp/tls.go @@ -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 } diff --git a/analyzer/tcp/tls_test.go b/analyzer/tcp/tls_test.go index 1ebb86b..25a17be 100644 --- a/analyzer/tcp/tls_test.go +++ b/analyzer/tcp/tls_test.go @@ -7,39 +7,42 @@ import ( "github.com/apernet/OpenGFW/analyzer" ) -func TestTlsStreamParsing_ClientHello(t *testing.T) { - // example packet taken from - clientHello := []byte{ - 0x16, 0x03, 0x01, 0x00, 0xa5, 0x01, 0x00, 0x00, 0xa1, 0x03, 0x03, 0x00, - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, - 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, - 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x00, 0x00, 0x20, 0xcc, 0xa8, - 0xcc, 0xa9, 0xc0, 0x2f, 0xc0, 0x30, 0xc0, 0x2b, 0xc0, 0x2c, 0xc0, 0x13, - 0xc0, 0x09, 0xc0, 0x14, 0xc0, 0x0a, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, - 0x00, 0x35, 0xc0, 0x12, 0x00, 0x0a, 0x01, 0x00, 0x00, 0x58, 0x00, 0x00, - 0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13, 0x65, 0x78, 0x61, 0x6d, 0x70, - 0x6c, 0x65, 0x2e, 0x75, 0x6c, 0x66, 0x68, 0x65, 0x69, 0x6d, 0x2e, 0x6e, - 0x65, 0x74, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 0x00, - 0x19, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x00, 0x0d, 0x00, 0x12, 0x00, - 0x10, 0x04, 0x01, 0x04, 0x03, 0x05, 0x01, 0x05, 0x03, 0x06, 0x01, 0x06, - 0x03, 0x02, 0x01, 0x02, 0x03, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x12, - 0x00, 0x00, - } - want := analyzer.PropMap{ - "ciphers": []uint16{52392, 52393, 49199, 49200, 49195, 49196, 49171, 49161, 49172, 49162, 156, 157, 47, 53, 49170, 10}, - "compression": []uint8{0}, - "random": []uint8{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}, - "session": []uint8{}, - "sni": "example.ulfheim.net", - "version": uint16(771), - } +// tlsExampleClientHello is taken from . +var tlsExampleClientHello = []byte{ + 0x16, 0x03, 0x01, 0x00, 0xa5, 0x01, 0x00, 0x00, 0xa1, 0x03, 0x03, 0x00, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x00, 0x00, 0x20, 0xcc, 0xa8, + 0xcc, 0xa9, 0xc0, 0x2f, 0xc0, 0x30, 0xc0, 0x2b, 0xc0, 0x2c, 0xc0, 0x13, + 0xc0, 0x09, 0xc0, 0x14, 0xc0, 0x0a, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, + 0x00, 0x35, 0xc0, 0x12, 0x00, 0x0a, 0x01, 0x00, 0x00, 0x58, 0x00, 0x00, + 0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13, 0x65, 0x78, 0x61, 0x6d, 0x70, + 0x6c, 0x65, 0x2e, 0x75, 0x6c, 0x66, 0x68, 0x65, 0x69, 0x6d, 0x2e, 0x6e, + 0x65, 0x74, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 0x00, + 0x19, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x00, 0x0d, 0x00, 0x12, 0x00, + 0x10, 0x04, 0x01, 0x04, 0x03, 0x05, 0x01, 0x05, 0x03, 0x06, 0x01, 0x06, + 0x03, 0x02, 0x01, 0x02, 0x03, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x12, + 0x00, 0x00, +} +var tlsExampleClientHelloProps = analyzer.PropMap{ + "ciphers": []uint16{52392, 52393, 49199, 49200, 49195, 49196, 49171, 49161, 49172, 49162, 156, 157, 47, 53, 49170, 10}, + "compression": []uint8{0}, + "random": []uint8{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}, + "session": []uint8{}, + "sni": "example.ulfheim.net", + "version": uint16(771), +} + +func TestTlsStreamParsing_ClientHello(t *testing.T) { s := newTLSStream(nil) - u, _ := s.Feed(false, false, false, 0, clientHello) - got := u.M.Get("req") - if !reflect.DeepEqual(got, want) { - t.Errorf("%d B parsed = %v, want %v", len(clientHello), got, want) + u, _ := s.Feed(false, false, false, 0, tlsExampleClientHello) + if got := u.M.Get("req"); !reflect.DeepEqual(got, tlsExampleClientHelloProps) { + t.Errorf("parsed = %v, want %v", got, tlsExampleClientHelloProps) + } + if got := u.M.Get("req_records"); got != 1 { + t.Errorf("req_records = %v, want 1", got) } } @@ -67,3 +70,119 @@ func TestTlsStreamParsing_ServerHello(t *testing.T) { t.Errorf("%d B parsed = %v, want %v", len(serverHello), got, want) } } + +// tlsFragment re-encodes the handshake message in a single-record hello as +// records carrying at most size bytes of it each. +func tlsFragment(hello []byte, size int) []byte { + var out []byte + for msg := hello[5:]; len(msg) > 0; { + n := min(size, len(msg)) + out = append(out, hello[0], hello[1], hello[2], byte(n>>8), byte(n)) + out = append(out, msg[:n]...) + msg = msg[n:] + } + return out +} + +// feedTLS feeds each segment in turn and returns the properties as the engine +// would have merged them, along with whether the stream said it was done. +func feedTLS(s *tlsStream, rev bool, segs ...[]byte) (analyzer.PropMap, bool) { + props := analyzer.PropMap{} + done := false + for _, seg := range segs { + var u *analyzer.PropUpdate + u, done = s.Feed(rev, false, false, 0, seg) + if u != nil { + for k, v := range u.M { + props[k] = v + } + } + } + return props, done +} + +// Splitting the ClientHello across records must not hide it: the parse result +// has to be the same wherever the record boundaries fall. +func TestTlsClientHelloRecordFragmentation(t *testing.T) { + msgLen := len(tlsExampleClientHello) - 5 + for _, size := range []int{1, 2, 3, 4, 5, 9, 50, msgLen - 1} { + data := tlsFragment(tlsExampleClientHello, size) + wantRecords := (msgLen + size - 1) / size + for name, segs := range map[string][][]byte{ + "one segment": {data}, + "byte by byte": splitEvery(data, 1), + "7 byte segs": splitEvery(data, 7), + } { + props, _ := feedTLS(newTLSStream(nil), false, segs...) + if got := props.Get("req"); !reflect.DeepEqual(got, tlsExampleClientHelloProps) { + t.Errorf("size %d, %s: parsed = %v, want %v", size, name, got, tlsExampleClientHelloProps) + } + if got := props.Get("req_records"); got != wantRecords { + t.Errorf("size %d, %s: req_records = %v, want %d", size, name, got, wantRecords) + } + } + } +} + +// Fragmentation is published before the hello is complete, so that a rule can +// act on it even if the hello never completes. +func TestTlsClientHelloFragmentationReportedEarly(t *testing.T) { + data := tlsFragment(tlsExampleClientHello, 1) + s := newTLSStream(nil) + props, done := feedTLS(s, false, data[:12]) + if done { + t.Fatal("stream done after two records") + } + if props.Get("req") != nil { + t.Errorf("req published early: %v", props.Get("req")) + } + if got := props.Get("req_records"); got != 2 { + t.Errorf("req_records = %v, want 2", got) + } + + // Further records are not published one by one... + if u, _ := s.Feed(false, false, false, 0, data[12:60]); u != nil { + t.Errorf("unexpected update %v", u.M) + } + // ...but the count reached is, if the stream gets cut off. + u := s.Close(true) + if u == nil { + t.Fatal("no update on close") + } + if got := u.M.Get("req_records"); got != 10 { + t.Errorf("req_records on close = %v, want 10", got) + } +} + +func TestTlsClientHelloRejectsInterleavedRecords(t *testing.T) { + data := tlsFragment(tlsExampleClientHello, 50) + // Turn the second record into an alert. + data[5+50] = 0x15 + props, done := feedTLS(newTLSStream(nil), false, data) + if !done { + t.Error("stream not done") + } + if props.Get("req") != nil { + t.Errorf("req = %v, want nil", props.Get("req")) + } +} + +func TestTlsStreamIgnoresUnfragmentedPartialHelloOnClose(t *testing.T) { + s := newTLSStream(nil) + if props, _ := feedTLS(s, false, tlsExampleClientHello[:100]); len(props) != 0 { + t.Errorf("props = %v, want none", props) + } + if u := s.Close(true); u != nil { + t.Errorf("close update = %v, want nil", u.M) + } +} + +func splitEvery(data []byte, n int) [][]byte { + var segs [][]byte + for len(data) > 0 { + k := min(n, len(data)) + segs = append(segs, data[:k]) + data = data[k:] + } + return segs +} diff --git a/docs/tcp-window-evasion.zh.md b/docs/tcp-window-evasion.zh.md index 15cbd6c..0b665be 100644 --- a/docs/tcp-window-evasion.zh.md +++ b/docs/tcp-window-evasion.zh.md @@ -28,7 +28,7 @@ table inet filter { - **快路径**:首个分段本身就够长时(正常流量的首包都是几百字节),直接在原始切片上分类,不拷贝、不分配,开销与改动前完全一致。只有小于 16 字节的首段才会走缓冲。 - **段数上限**:最多等 `fetMaxSegments`(4)个分段。否则一个只发 1 字节然后装死的对端,就能让流一直留在活跃状态、拿不到 connmark 快速通道,直到 TCP 超时(默认 10 分钟)为止——那会变成一个可被主动利用的资源消耗面。 -`http`、`tls`、`minecraft` 等分析器本来就用 `utils.ByteBuffer` 做增量解析,不受这个手法影响。 +`http`、`tls`、`minecraft` 等分析器本来就用 `utils.ByteBuffer` 做增量解析,不受这个手法影响。注意这只针对 TCP 分段;TLS 自己记录层上的分片是另一回事,见 [TLS record 分片规避与检测](tls-record-fragmentation.zh.md)。 ## 第二层防御:把窗口改写本身当指纹 diff --git a/docs/tls-record-fragmentation.zh.md b/docs/tls-record-fragmentation.zh.md new file mode 100644 index 0000000..8b0ce46 --- /dev/null +++ b/docs/tls-record-fragmentation.zh.md @@ -0,0 +1,70 @@ +# TLS record 分片规避与检测 + +## 规避手法 + +TLS 允许一个握手消息跨任意多个 record 传输(RFC 8446 §5.1)。客户端把 ClientHello 拆成很多个小 record,服务端照常把它们拼回去完成握手;但只看第一个 record 的 DPI 就拿不到完整的 ClientHello,SNI 也就看不到了。Xray 的 `fragment`(`packets: "tlshello"`)以及各类"TLS 分片"工具用的都是这个手法。 + +它和 [TCP 窗口操纵](tcp-window-evasion.zh.md) 是两层不同的东西:TCP 分段切的是字节流,OpenGFW 的 TCP 重组会把它抹平;record 分片切的是 TLS 自己的记录层,重组后的字节流里每一片前面都还带着 5 字节的 record 头。 + +`tls` 分析器以前默认 ClientHello 只在一个 record 里,读完第一个 record 头就直接按握手长度切数据,碰到分片会: + +- 第一个 record 少于 4 字节握手数据时,把下一个 record 的头误当成握手长度(例如 `16 03 01` 被读成 0x160301),一直等到 8192 字节的上限; +- 其他情况下,后续 record 的头混进消息体导致解析失败,分析器取消。 + +两种情况都不产出任何属性,所有基于 `tls.req.sni` 的规则都会被绕过。 + +## 第一层防御:在 record 层重组 + +现在分析器先剥掉 record 层,把各个 record 的内容拼回完整的握手消息再解析,所以无论切在哪里、切成多少片,`tls.req` 的解析结果都和不分片时完全一样。ServerHello 方向也做了同样的处理。 + +- 握手消息中间夹了其他类型的 record(alert、change_cipher_spec 等)会被视为非法,分析器取消。 +- record 长度超过 2^14 视为非法。 +- 空 record 按规范不允许出现在握手中,但这里不拒绝,只是计数——拒绝的话反而给了对方一个让分析中止的开关。 +- 常见情况下整个 ClientHello 在一个 record 里,此时直接引用原始缓冲区,不做额外拷贝。 + +## 第二层防御:把分片本身当指纹 + +| 属性 | 类型 | 说明 | +| --- | --- | --- | +| `tls.req_records` | int | ClientHello 跨了几个 record | +| `tls.resp_records` | int | ServerHello 跨了几个 record | + +主流浏览器和 TLS 库都把 ClientHello 放在一个 record 里,即使是带后量子密钥交换、将近 2KB 的 ClientHello 也远小于 16KB 的 record 上限。所以 `req_records > 1` 本身就是很强的规避信号,误报率很低。 + +这个计数放在 `req` 外面,是为了保证 `tls.req != nil` 仍然表示"ClientHello 已完整解析",现有规则的语义不变。 + +### 提前上报 + +分片一出现(第二个 record 开始)就会上报一次 `req_records`,不用等消息收完。原因是分析器有 8192 字节的 `Limit()`:切成每片 1 字节时,每个字节要额外带 5 字节的 record 头,一个 1.8KB 的 ClientHello 会膨胀到 11KB 左右,永远收不完。所以规则不能依赖 SNI 才动手。 + +之后的 record 不会逐个上报(否则每片都要跑一次规则匹配);消息收完时,或者分析器因为超限被关闭时,再上报最终的计数。所以规则里看到的值可能是 2,也可能是最终值——写阈值时按 `> 1` 来写。 + +### 规则示例 + +直接拦截分片的 ClientHello: + +```yaml +- name: tls-record-fragmentation + action: block + log: true + expr: tls != nil && tls.req_records != nil && tls.req_records > 1 +``` + +> [!IMPORTANT] +> 要判断 `tls.req_records != nil`,不能只判 `tls != nil`:只看到服务端方向时,`tls` 里可能只有 `resp` 相关的字段。 + +按 SNI 拦截的规则不需要任何改动,分片后照样能匹配: + +```yaml +- name: block-example + action: block + expr: tls != nil && tls.req != nil && tls.req.sni endsWith "example.com" +``` + +## 局限 + +**只统计 hello 消息本身。** ClientHello 收完就停止计数,后面的 record 不影响 `req_records`。 + +**超长的分片拿不到 SNI。** 分片把 ClientHello 撑过 8192 字节时,`tls.req` 不会出现,只能靠 `req_records` 拦截。需要同时拿到 SNI 的话可以调大 `Limit()`,代价是每条 TLS 连接的缓冲上限相应变大。 + +**QUIC 不在此列。** QUIC 的 ClientHello 在 CRYPTO 帧里,走的是 `quic` 分析器,与这里无关。