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:
2026-09-24 17:31:57 +08:00
co-authored by Claude Opus 5.5
parent 8f8cc1d4d9
commit ae7135ab64
5 changed files with 391 additions and 200 deletions
+1
View File
@@ -39,6 +39,7 @@ Telegram 群组: https://t.me/OpGFW
- [Minecraft 分析器](docs/minecraft.zh.md) - [Minecraft 分析器](docs/minecraft.zh.md)
- [EasyTier 分析器](docs/easytier.zh.md) - [EasyTier 分析器](docs/easytier.zh.md)
- [TCP 窗口操纵规避与检测](docs/tcp-window-evasion.zh.md) - [TCP 窗口操纵规避与检测](docs/tcp-window-evasion.zh.md)
- [TLS record 分片规避与检测](docs/tls-record-fragmentation.zh.md)
## 使用场景 ## 使用场景
+169 -168
View File
@@ -22,38 +22,68 @@ func (a *TLSAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) anal
return newTLSStream(logger) 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 { type tlsStream struct {
logger analyzer.Logger logger analyzer.Logger
reqBuf *utils.ByteBuffer req tlsHello
reqMap analyzer.PropMap resp tlsHello
reqUpdated bool }
reqLSM *utils.LinearStateMachine
reqDone bool
respBuf *utils.ByteBuffer // tlsHello reads one hello message off one direction of the stream.
respMap analyzer.PropMap //
respUpdated bool // A handshake message may be split across any number of records (RFC 8446
respLSM *utils.LinearStateMachine // 5.1), and splitting the ClientHello into tiny records is a common way to
respDone bool // 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 msgType byte
serverHelloLen int 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 { func newTLSStream(logger analyzer.Logger) *tlsStream {
s := &tlsStream{logger: logger, reqBuf: &utils.ByteBuffer{}, respBuf: &utils.ByteBuffer{}} s := &tlsStream{logger: logger}
s.reqLSM = utils.NewLinearStateMachine( // minimal ClientHello body: protocol version (2 bytes) + random (32 bytes) +
s.tlsClientHelloPreprocess, // + session ID (1 byte) + cipher suites (4 bytes) +
s.parseClientHelloData, // + compression methods (2 bytes) + no extensions
) s.req.init(internal.TypeClientHello, 41, internal.ParseTLSClientHelloMsgData)
s.respLSM = utils.NewLinearStateMachine( // minimal ServerHello body: server version (2 bytes) + random (32 bytes) +
s.tlsServerHelloPreprocess, // + session ID (>=1 byte) + cipher suite (2 bytes) +
s.parseServerHelloData, // + compression method (1 byte) + no extensions
) s.resp.init(internal.TypeServerHello, 38, internal.ParseTLSServerHelloMsgData)
return s 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) { func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, done bool) {
if skip != 0 { if skip != 0 {
return nil, true 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 { if len(data) == 0 {
return nil, false return nil, false
} }
var update *analyzer.PropUpdate h, key := &s.req, "req"
var cancelled bool
if rev { if rev {
s.respBuf.Append(data) h, key = &s.resp, "resp"
s.respUpdated = false }
cancelled, s.respDone = s.respLSM.Run() if h.done {
if s.respUpdated { return nil, false
update = &analyzer.PropUpdate{ }
Type: analyzer.PropUpdateMerge, h.rec.Append(data)
M: analyzer.PropMap{"resp": s.respMap}, 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 { if h.msgLen >= 0 && h.msg.Len() >= tlsHandshakeHeaderSize+h.msgLen {
s.reqBuf.Append(data) return utils.LSMActionNext
s.reqUpdated = false }
cancelled, s.reqDone = s.reqLSM.Run() if h.recLeft == 0 {
if s.reqUpdated { header, ok := h.rec.Get(tlsRecordHeaderSize, true)
update = &analyzer.PropUpdate{ if !ok {
Type: analyzer.PropUpdateMerge, return utils.LSMActionPause
M: analyzer.PropMap{"req": s.reqMap},
} }
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 // Anything after the message, such as the rest of the server's flight, is
// from `s.reqBuf`. // ignored.
func (s *tlsStream) tlsClientHelloPreprocess() utils.LSMAction { func (h *tlsHello) parseMessage() utils.LSMAction {
// headers size: content type (1 byte) + legacy protocol version (2 bytes) + body := &utils.ByteBuffer{Buf: h.msg.Buf[tlsHandshakeHeaderSize : tlsHandshakeHeaderSize+h.msgLen]}
// + content length (2 bytes) + message type (1 byte) + m := h.parse(body)
// + handshake length (3 bytes) h.rec.Reset()
const headersSize = 9 h.msg.Reset()
// 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)
if m == nil { if m == nil {
return utils.LSMActionCancel 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 { func (s *tlsStream) Close(limited bool) *analyzer.PropUpdate {
s.reqBuf.Reset() // Report how far a fragmented hello got if it never completed, e.g.
s.respBuf.Reset() // because it was stretched past Limit().
s.reqMap = nil m := s.req.update("req", s.req.records > 1 && s.req.records > s.req.reported)
s.respMap = nil if rm := s.resp.update("resp", s.resp.records > 1 && s.resp.records > s.resp.reported); rm != nil {
return 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
} }
+150 -31
View File
@@ -7,39 +7,42 @@ import (
"github.com/apernet/OpenGFW/analyzer" "github.com/apernet/OpenGFW/analyzer"
) )
func TestTlsStreamParsing_ClientHello(t *testing.T) { // tlsExampleClientHello is taken from <https://tls12.xargs.org/#client-hello/annotated>.
// example packet taken from <https://tls12.xargs.org/#client-hello/annotated> var tlsExampleClientHello = []byte{
clientHello := []byte{ 0x16, 0x03, 0x01, 0x00, 0xa5, 0x01, 0x00, 0x00, 0xa1, 0x03, 0x03, 0x00,
0x16, 0x03, 0x01, 0x00, 0xa5, 0x01, 0x00, 0x00, 0xa1, 0x03, 0x03, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x00, 0x00, 0x20, 0xcc, 0xa8,
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x00, 0x00, 0x20, 0xcc, 0xa8, 0xcc, 0xa9, 0xc0, 0x2f, 0xc0, 0x30, 0xc0, 0x2b, 0xc0, 0x2c, 0xc0, 0x13,
0xcc, 0xa9, 0xc0, 0x2f, 0xc0, 0x30, 0xc0, 0x2b, 0xc0, 0x2c, 0xc0, 0x13, 0xc0, 0x09, 0xc0, 0x14, 0xc0, 0x0a, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f,
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, 0x35, 0xc0, 0x12, 0x00, 0x0a, 0x01, 0x00, 0x00, 0x58, 0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13, 0x65, 0x78, 0x61, 0x6d, 0x70,
0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x75, 0x6c, 0x66, 0x68, 0x65, 0x69, 0x6d, 0x2e, 0x6e,
0x6c, 0x65, 0x2e, 0x75, 0x6c, 0x66, 0x68, 0x65, 0x69, 0x6d, 0x2e, 0x6e, 0x65, 0x74, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x65, 0x74, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 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,
0x19, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x00, 0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x01, 0x04, 0x03, 0x05, 0x01, 0x05, 0x03, 0x06, 0x01, 0x06,
0x10, 0x04, 0x01, 0x04, 0x03, 0x05, 0x01, 0x05, 0x03, 0x06, 0x01, 0x06, 0x03, 0x02, 0x01, 0x02, 0x03, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x12,
0x03, 0x02, 0x01, 0x02, 0x03, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x12, 0x00, 0x00,
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),
}
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) s := newTLSStream(nil)
u, _ := s.Feed(false, false, false, 0, clientHello) u, _ := s.Feed(false, false, false, 0, tlsExampleClientHello)
got := u.M.Get("req") if got := u.M.Get("req"); !reflect.DeepEqual(got, tlsExampleClientHelloProps) {
if !reflect.DeepEqual(got, want) { t.Errorf("parsed = %v, want %v", got, tlsExampleClientHelloProps)
t.Errorf("%d B parsed = %v, want %v", len(clientHello), got, want) }
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) 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
}
+1 -1
View File
@@ -28,7 +28,7 @@ table inet filter {
- **快路径**:首个分段本身就够长时(正常流量的首包都是几百字节),直接在原始切片上分类,不拷贝、不分配,开销与改动前完全一致。只有小于 16 字节的首段才会走缓冲。 - **快路径**:首个分段本身就够长时(正常流量的首包都是几百字节),直接在原始切片上分类,不拷贝、不分配,开销与改动前完全一致。只有小于 16 字节的首段才会走缓冲。
- **段数上限**:最多等 `fetMaxSegments`(4)个分段。否则一个只发 1 字节然后装死的对端,就能让流一直留在活跃状态、拿不到 connmark 快速通道,直到 TCP 超时(默认 10 分钟)为止——那会变成一个可被主动利用的资源消耗面。 - **段数上限**:最多等 `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)。
## 第二层防御:把窗口改写本身当指纹 ## 第二层防御:把窗口改写本身当指纹
+70
View File
@@ -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` 分析器,与这里无关。