Files
OpenGFW/docs/tls-record-fragmentation.zh.md
T
Mxmilu666andClaude Opus 5.5 ae7135ab64 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>
2026-09-24 17:31:57 +08:00

4.2 KiB
Raw Blame History

TLS record 分片规避与检测

规避手法

TLS 允许一个握手消息跨任意多个 record 传输(RFC 8446 §5.1)。客户端把 ClientHello 拆成很多个小 record,服务端照常把它们拼回去完成握手;但只看第一个 record 的 DPI 就拿不到完整的 ClientHello,SNI 也就看不到了。Xray 的 fragment(packets: "tlshello")以及各类"TLS 分片"工具用的都是这个手法。

它和 TCP 窗口操纵 是两层不同的东西: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:

- 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 拦截的规则不需要任何改动,分片后照样能匹配:

- 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 分析器,与这里无关。