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

113 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`,是这个手法的主要受害者。现在它会缓冲到至少 `fetMinDataLen`16)字节再分类。
为了不增加正常流量的开销,实现上做了两件事:
- **快路径**:首个分段本身就够长时(正常流量的首包都是几百字节),直接在原始切片上分类,不拷贝、不分配,开销与改动前完全一致。只有小于 16 字节的首段才会走缓冲。
- **段数上限**:最多等 `fetMaxSegments`(4)个分段。否则一个只发 1 字节然后装死的对端,就能让流一直留在活跃状态、拿不到 connmark 快速通道,直到 TCP 超时(默认 10 分钟)为止——那会变成一个可被主动利用的资源消耗面。
`http``tls``minecraft` 等分析器本来就用 `utils.ByteBuffer` 做增量解析,不受这个手法影响。
## 第二层防御:把窗口改写本身当指纹
分析器只能看到重组后的载荷,拿不到任何 TCP 头字段。所以这一层做在引擎里:`engine/tcp.go``Accept` 每个包都会被调用,其中检查 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 窗口 ≤ `tcpSynAckWindowMax`4096),或者客户端首段 ≤ `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-ACK`tcpmeta` 里就一定带 `synack_window``tcpMeta()` 在创建时会把握手字段一并填进去)。但单向可见的部署看不到返回方向,所以判空仍然是必须的。`synack_wscale` 则是本来就可能不存在——对端没协商窗口缩放时就没有这个字段。
`synack_window` 看的是**手法**`first_seg_len` 看的是**结果**。前者要求 OpenGFW 能看到返回方向的流量,后者只需要看到客户端方向;前者能被改用窗口 40 之类的取值绕开,后者不管对方怎么调窗口都会留下痕迹。两个一起用最稳。
### 规则示例
窗口小到不可能是真实协议栈:
```yaml
- name: tcp-tiny-synack-window
action: block
log: true
expr: >
tcpmeta != nil && tcpmeta.synack_window != nil &&
tcpmeta.synack_window <= 16
```
更锋利的判据是**自相矛盾的握手**。接收缓冲区真的只有几个字节的协议栈,不会同时去协商 128 倍的窗口缩放;这两者同时出现,基本只可能是字段在传输途中被改写:
```yaml
- name: tcp-window-rewritten
action: block
log: true
expr: >
tcpmeta != nil && tcpmeta.synack_wscale != nil &&
tcpmeta.synack_window <= 16
```
直接拦被切开的首包,不管对方是怎么切的:
```yaml
- name: tcp-split-first-segment
action: block
log: true
expr: >
tcpmeta != nil && tcpmeta.first_seg_len != nil &&
tcpmeta.first_seg_len <= 8
```
配合 `fet` 一起用,把「全加密流量」和「还试图掩盖自己」两个信号叠加。因为有第二个条件兜底,首包长度的阈值可以放宽到 32:
```yaml
- 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`——那会让全局最大的性能优化失效。目前没有实现,需要时建议做成只对已标记可疑的连接开启的定向跟踪。