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
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Minecraft 分析器
|
||||
|
||||
识别 Minecraft Java 版客户端在连接建立后发出的第一个数据包(Handshake,packet ID `0x00`),从中取出客户端请求的服务器地址、协议版本和后续状态。基岩版走 UDP/RakNet,不在本分析器范围内。
|
||||
|
||||
分析器名是 `minecraft`。和其他分析器一样,**只有当某条规则的表达式里出现了 `minecraft` 这个标识符时,它才会被启用**(见 `ruleset/expr.go` 的依赖收集逻辑),不写规则就不会有任何开销。
|
||||
|
||||
## 属性
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `yes` | bool | 确认是 Minecraft 握手 |
|
||||
| `candidate` | bool | 数据还不完整,暂时无法判定,仍在等待后续分段 |
|
||||
| `protocol` | int | 协议版本号,**不是游戏版本号**,对照表见 [minecraft.wiki](https://minecraft.wiki/w/Protocol_version)(例如 767 对应 1.21) |
|
||||
| `server_addr` | string | 客户端在握手包里填写的服务器地址 |
|
||||
| `server_port` | uint16 | 客户端填写的端口 |
|
||||
| `next_state` | int | 1 = status,2 = login,3 = transfer |
|
||||
| `next_state_name` | string | 上面的可读形式:`status` / `login` / `transfer` |
|
||||
| `packet_length` | int | 握手包声明的长度(不含长度字段自身) |
|
||||
|
||||
`yes` 和 `candidate` 一定存在,其余字段只在 `yes` 为 true 时出现。
|
||||
|
||||
## 规则示例
|
||||
|
||||
只拦截真正进服的连接,放过服务器列表里的 ping:
|
||||
|
||||
```yaml
|
||||
- name: block-minecraft-login
|
||||
action: block
|
||||
log: true
|
||||
expr: minecraft != nil && minecraft.yes && minecraft.next_state_name == "login"
|
||||
```
|
||||
|
||||
只允许连接指定的服务器,其余一律拦截:
|
||||
|
||||
```yaml
|
||||
- name: minecraft-allowlist
|
||||
action: block
|
||||
expr: >
|
||||
minecraft != nil && minecraft.yes &&
|
||||
!hasPrefix(minecraft.server_addr, "mc.example.com")
|
||||
```
|
||||
|
||||
按协议版本卡老客户端:
|
||||
|
||||
```yaml
|
||||
- name: block-old-minecraft-clients
|
||||
action: block
|
||||
expr: minecraft != nil && minecraft.yes && minecraft.protocol < 763
|
||||
```
|
||||
|
||||
纯记录不拦截,先摸清楚流量情况:
|
||||
|
||||
```yaml
|
||||
- name: log-minecraft
|
||||
log: true
|
||||
expr: minecraft != nil && minecraft.yes
|
||||
```
|
||||
|
||||
## 写规则时要注意的几点
|
||||
|
||||
**`server_addr` 不一定是干净的域名。** 握手包里的地址字段被生态里几个东西复用了,客户端可能在真实域名后面追加以 `\0` 分隔的内容:Forge/FML 客户端会加 `\0FML\0`、`\0FML2\0`、`\0FML3\0` 之类的标记,BungeeCord 和 Velocity 的 IP 转发会把玩家真实 IP 和 UUID 拼在后面。分析器保存的是原始字节,不做任何清洗。所以不要用 `==` 精确比较,用 `hasPrefix(minecraft.server_addr, ...)`,或者先 `split(minecraft.server_addr, "\x00")[0]` 再比。
|
||||
|
||||
**这个字段是客户端自己填的,不可信。** 它反映客户端想连哪个域名(对 SRV 记录的情况是解析前的原始主机名),不是它实际连到的 IP。要认真限制目标,得配合 `ip.dst` 一起写。
|
||||
|
||||
**判定发生在第一个数据包。** 握手之后的登录、加密、游戏数据都不会再看。`Limit()` 是 2053 字节,正常握手包只有几十字节,绰绰有余;超过这个量还没解析成功的连接会被判为非 Minecraft。
|
||||
|
||||
**握手包被拆到多个 TCP 分段时也能正确解析。** 分析器内部有缓冲区,数据不够时返回 `candidate = true` 并继续等待,不会像只看单个分段的分析器那样被切包绕过。相关背景见 [TCP 窗口操纵规避](tcp-window-evasion.zh.md)。
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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 7323,window 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`——那会让全局最大的性能优化失效。目前没有实现,需要时建议做成只对已标记可疑的连接开启的定向跟踪。
|
||||
Reference in New Issue
Block a user