Files
OpenGFW/docs/tcp-window-evasion.zh.md
T
Mxmilu666 f6342c4bcf feat(tcp): add Minecraft analyzer for handshake detection
feat(tcp): implement FETAnalyzer with segment buffering
feat(tcp): enhance TCP engine with window scale detection
test(tcp): add tests for Minecraft and FET analyzers
docs: add documentation for Minecraft analyzer and TCP evasion
2026-07-27 01:09:41 +08:00

8.0 KiB
Raw Blame History

TCP 窗口操纵规避与检测

规避手法

服务端在本机加一条这样的规则,就能让不少 DPI 失效:

table inet filter {
        chain output {
                type filter hook output priority filter;
                oifname eth0 tcp sport <本地端口> tcp flags & (syn | ack) == (syn | ack) tcp window set 1 accept
        }
}

它只匹配服务端发出的 SYN-ACK,把里面通告的接收窗口改成 1。按 RFC 7323window scale 选项不作用于 SYN/SYN-ACK 自身的窗口字段,所以这里的 1 就是字面意义的 1 字节,不会被 wscale 放大。

握手完成后,客户端认为对端只能收 1 个字节,第一个数据包就只带 1 字节负载。服务端内核回 ACK 时通告的是真实窗口(那条规则不匹配纯 ACK),客户端随即把剩下的数据正常发出。净效果是首包被切成「1 字节 + 剩余部分」两段,代价只有一个 RTT,之后传输速度不受影响。

对于只看第一个 TCP 分段就下结论的分析器,这一刀就够了:拿到 1 个字节,任何需要 3 字节、6 字节的特征都匹配不上,于是误判放行。更糟的是 OpenGFW 在所有分析器都判完且无人拦截时会下发 ACCEPT_STREAM,通过 connmark 让这条连接的后续包在内核里直接 accept,根本不再进入用户态——一次误判就是永久放行。

第一层防御:分析器攒够数据再判决

fet(全加密流量检测)原先在第一次 Feed 就返回 done = true,是这个手法的主要受害者。现在它会缓冲到至少 fetMinDataLen16)字节再分类。

为了不增加正常流量的开销,实现上做了两件事:

  • 快路径:首个分段本身就够长时(正常流量的首包都是几百字节),直接在原始切片上分类,不拷贝、不分配,开销与改动前完全一致。只有小于 16 字节的首段才会走缓冲。
  • 段数上限:最多等 fetMaxSegments(4)个分段。否则一个只发 1 字节然后装死的对端,就能让流一直留在活跃状态、拿不到 connmark 快速通道,直到 TCP 超时(默认 10 分钟)为止——那会变成一个可被主动利用的资源消耗面。

httptlsminecraft 等分析器本来就用 utils.ByteBuffer 做增量解析,不受这个手法影响。

第二层防御:把窗口改写本身当指纹

分析器只能看到重组后的载荷,拿不到任何 TCP 头字段。所以这一层做在引擎里:engine/tcp.goAccept 每个包都会被调用,其中检查 SYN-ACK 的窗口值,异常时把握手元数据挂到 tcpmeta 属性下,供规则引擎使用。

属性 类型 说明
tcpmeta.synack_window int SYN-ACK 通告的接收窗口(未经 wscale 放大的字面值)
tcpmeta.synack_wscale int SYN-ACK 协商的 window scale 因子,没有该选项时不存在
tcpmeta.first_seg_len int 客户端第一个数据分段的字节数

tcpmeta 本身只在有东西值得报告时才会创建:SYN-ACK 窗口 ≤ tcpSynAckWindowMax4096),或者客户端首段 ≤ tcpFirstSegLenMax(64)字节。正常连接一次分配都不会发生,代价是规则里看不到超过门槛的值——门槛只是为了省开销,真正的判定阈值请在规则里写。

Important

每个字段都要单独判空,不能只判 tcpmeta != nil。这两个信号是独立触发的:一条 SYN-ACK 窗口完全正常、只是首包偏短的连接(SSH 的客户端版本标识只有二十几字节,就是典型)会创建 tcpmeta,但里面没有 synack_window。此时 tcpmeta != nil && tcpmeta.synack_window <= 16 会在运行期抛 invalid operation: <nil> <= int。这类错误不会让进程崩溃,ruleset/expr.go 里的 Match 记录一条 MatchError 后就跳到下一条规则——也就是说这条检测会静默失效,日志里不翻出来根本发现不了。

只要 OpenGFW 看得到 SYN-ACKtcpmeta 里就一定带 synack_windowtcpMeta() 在创建时会把握手字段一并填进去)。但单向可见的部署看不到返回方向,所以判空仍然是必须的。synack_wscale 则是本来就可能不存在——对端没协商窗口缩放时就没有这个字段。

synack_window 看的是手法first_seg_len 看的是结果。前者要求 OpenGFW 能看到返回方向的流量,后者只需要看到客户端方向;前者能被改用窗口 40 之类的取值绕开,后者不管对方怎么调窗口都会留下痕迹。两个一起用最稳。

规则示例

窗口小到不可能是真实协议栈:

- name: tcp-tiny-synack-window
  action: block
  log: true
  expr: >
    tcpmeta != nil && tcpmeta.synack_window != nil &&
    tcpmeta.synack_window <= 16

更锋利的判据是自相矛盾的握手。接收缓冲区真的只有几个字节的协议栈,不会同时去协商 128 倍的窗口缩放;这两者同时出现,基本只可能是字段在传输途中被改写:

- name: tcp-window-rewritten
  action: block
  log: true
  expr: >
    tcpmeta != nil && tcpmeta.synack_wscale != nil &&
    tcpmeta.synack_window <= 16

直接拦被切开的首包,不管对方是怎么切的:

- name: tcp-split-first-segment
  action: block
  log: true
  expr: >
    tcpmeta != nil && tcpmeta.first_seg_len != nil &&
    tcpmeta.first_seg_len <= 8

配合 fet 一起用,把「全加密流量」和「还试图掩盖自己」两个信号叠加。因为有第二个条件兜底,首包长度的阈值可以放宽到 32:

- name: evasive-encrypted-traffic
  action: block
  log: true
  expr: >
    tcpmeta != nil && tcpmeta.first_seg_len != nil &&
    tcpmeta.first_seg_len <= 32 && fet != nil && fet.yes

建议先用 log: true 不带 action 跑一段时间,看清楚基线再决定要不要拦。

局限

判定要等到第一个数据包。 ruleset.Match 只在 ReassembledSG 里调用,也就是必须有载荷。握手异常但从不发数据的连接不会被匹配到——不过那种连接本来也没什么可拦的。

阈值可以被绕。 对方把窗口改成 40 或 100,既躲开 <= 16,又照样能把 ClientHello 切开。所以第二层是辅助,真正根治的是第一层的缓冲——它消灭的是整类切包规避,而不是某一种参数取值。

小窗口不等于恶意。 部分嵌入式协议栈的初始窗口确实只有几百到一两千字节,某些 CDN 和负载均衡设备也会改写窗口。阈值别设太高,<= 16 是安全的,上到 1024 就要先看基线。synack_wscale 那条组合判据的误报率要低得多。

first_seg_len 的阈值尤其要保守。 有不少协议的首包本来就很短:Minecraft 握手包只有二十几字节,SSH 客户端版本标识约二十到四十字节,还有各种短命令的二进制 RPC。把阈值设到 16 以上会误伤这些正常流量——特别是如果你同时在用 Minecraft 分析器,<= 32 会命中每一条 Minecraft 连接。单独拿它当拦截依据时用 <= 8;想放宽到 32,就必须像上面那样再叠加一个条件(fet.yes、目标端口、或者 synack_window)。

首包短不代表后面还有数据。 目前只记录第一个分段的长度,不区分「被切开的大请求」和「本来就只有几字节的完整请求」。前者才是规避,后者是正常的。这也是建议把阈值压到 8 或者叠加条件的原因之一。

只改 SYN-ACK 会留下窗口跃变。 服务端内核对改写并不知情,后续纯 ACK 通告的还是真实窗口,线上必然出现 1 → 64240 这种一跳到顶的序列,而真实协议栈的接收窗口是随缓冲区占用平滑变化的。这个指纹更难规避,但要观察到它就必须持续跟踪 server→client 方向的窗口序列,也就意味着不能再提前下发 ACCEPT_STREAM——那会让全局最大的性能优化失效。目前没有实现,需要时建议做成只对已标记可疑的连接开启的定向跟踪。