Files
90CNServer/docs/客户端补丁与CRC校验.md
T
hanfuit da502b3524 docs:补上 90CN.dll 自校验 CRC 工具的文档说明
- 新增 docs/客户端补丁与CRC校验.md:SelfCrcSlot 结构、CRC32 算法与种子坑、
  槽位为什么只在 .data/.rdata 里找、info/verify/patch 用法与退出码、
  build_90cn.ps1 集成、与「服务端 DLL 指纹」的区别、六类故障排查
- 新增 client-patch/tools/README.md:脚本清单与常用命令速查
- docs/README.md:文档索引与上手路径补一条
- 根 README:关键设计说明补「客户端自校验(CRC32)」小节,文档索引补一行
- 90CN.cpp:清掉过期的重复注释(旧的 patch_selfcrc.py / .text 段说法),与实现对齐
2026-11-05 16:40:33 +08:00

134 lines
7.5 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.
# 客户端补丁自校验(CRC32)与 patch_dll_crc.py
`client-patch/tools/patch_dll_crc.py` 是 `90CN.dll` 的**自校验补齐工具**。DLL 编译出来自带一个 CRC 槽位(初值全 0),必须由这个脚本在构建后把「整个文件的 CRC32 + 槽位偏移」写进去;不写,客户端启动时自校验直接失败。
> 一句话:**改完 DLL 必须跑 patch,跑完 CRC 变了必须重编服务端**(服务端绑的是 DLL 的 SHA-256,见第六节)。
---
## 一、它解决什么问题
90CN 补丁是外挂式 DLL,谁都能拿十六进制编辑器改两个字节。加了自校验之后:
- DLL 每次启动先读**磁盘上自己**的字节,重算 CRC32,与构建时写入的值比对
- 不一致 → 弹「客户端补丁文件已被修改,无法启动」→ `TerminateProcess`
- 因此「patch 掉校验分支」这条路走不通:改分支就等于改 CRC,CRC 对不上直接启动失败
代价是每次重编 DLL 都要补一次 CRC,这就是本工具的职责。
## 二、两端机制
### DLL 侧(`client-patch/90CN.cpp` → `SelfCrcSlot` / `VerifySelfIntegrity`)
```c
static struct SelfCrcSlot {
volatile unsigned int magic; // 固定 0xC0DEC0DE,脚本靠它定位槽位
volatile unsigned int offset; // expected 字段在文件中的绝对偏移(脚本写入)
volatile unsigned int expected; // 整个文件的 CRC32(脚本写入)
} g_selfCrcSlot = { 0xC0DEC0DEu, 0u, 0u };
```
三个要点,都踩过坑:
1. **必须 `volatile`**:这三个字段只由脚本改文件字节,C++ 代码里从不写。不加 `volatile`,`/O2` 会把 `offset`/`expected` 当常量折叠成 0,脚本写对了运行时也读不到。
2. **用数据地址解析自身模块**:`GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, &g_selfCrcSlot, ...)`。用函数地址在部分加载方式下解析不到(实测 `err=126`)。
3. **校验发生在窗口出来之前**:`VerifySelfIntegrity()` 在 `InstallTclsCompatibility()` 之后、影片哈希之前调用,失败即弹框并退出。
CRC 计算严格按下面这套,和脚本必须逐字节一致:
```c
unsigned int crc = 0xFFFFFFFFu;
crc = Crc32Update(crc, image, slotOffset); // 槽位之前的全部字节
crc = Crc32Update(crc, image + slotOffset + 4, size - slotOffset - 4); // 跳过 expected 那 4 字节
crc ^= 0xFFFFFFFFu;
```
即标准的反射 CRC-32(poly `0xEDB88320`、init `0xFFFFFFFF`、final XOR `0xFFFFFFFF`),等价于 `zlib.crc32()` 的**默认**用法。
### 脚本侧(`patch_dll_crc.py`)
| 动作 | 做什么 |
|------|--------|
| 定位槽位 | 解析 PE 节表,只在 `.data` / `.rdata` 里找 `0xC0DEC0DE`,可执行节里的命中一律拒绝 |
| 写 `offset` | `offset = 槽位文件偏移 + 8`(指向 `expected` 自己) |
| 写 `expected` | 先写 `offset` 再算 CRC —— 因为 `offset` 本身也在被哈希的范围内 |
| 复核 | 写完立刻跑一次 `verify`,退出码即结果 |
**为什么不直接扫第一个 `0xC0DEC0DE`**:MSVC 会把 `magic != 0xC0DEC0DEu` 这个比较编译成 `.text` 里的立即数,于是镜像里有多处命中。早期脚本扫到机器码就往上写,运行时表现为「读槽位后立刻访问冲突」。现在按节属性过滤,并且只接受 `offset` 为 0(新编译出来)或已自指(已 patch 过)的候选。
**CRC 种子坑(曾上线一版错误实现)**:不要给 `binascii.crc32(data, 0xFFFFFFFF)` 传种子。那个参数是**已经过 final XOR** 的运行中 CRC,再传进去等于把 conditioning 应用两次,算出来是另一个变体,客户端会拒绝自己的 DLL。正确写法是 `zlib.crc32(part1)` 再用返回值续算 `part2`。
## 三、命令行用法
```bash
# 看节表布局与槽位现状(不改文件)
python patch_dll_crc.py info Client/90CN.dll
# 只校验:当前文件与槽位里存的 CRC 是否一致
python patch_dll_crc.py verify Client/90CN.dll
# 补齐/重写 CRC(原地写)
python patch_dll_crc.py patch Client/90CN.dll
# 写到副本,原文件不动(发布前比对用)
python patch_dll_crc.py patch Client/90CN.dll -o build/90CN_patched.dll
```
输出示例(当前发布版 `90CN.dll` 的真实结果):
```
slot : file 0x0009B2AC in .data offset=0x0009B2B4 expected=0x9AFDA31F
crc32 : computed=0x9AFDA31F stored=0x9AFDA31F
OK 90CN.dll: self-integrity CRC verified
```
**退出码**:`0` 成功 / `1` 校验失败或找不到槽位 / `2` 用法错误(参数或文件不存在)。
## 四、构建集成
`client-patch/tools/build_90cn.ps1`(双击走 `build_90cn.bat`)三步走:
1. MSBuild 编 `Release|Win32` 出 `90CN.dll`
2. 收集产物
3. 调 `patch_dll_crc.py patch <dll>` 写入 CRC(找不到 Python 且没加 `-SkipCrc` 就直接抛错,不会静默产出未 patch 的 DLL)
`-SkipCrc` 只用于「先编出来看看能不能过编译」,**不要**拿这种 DLL 发布。
## 五、手动改过 DLL 之后
任何对 DLL 的字节级改动(换图标、改资源、再打一次补丁)都会让 CRC 失效,按顺序来:
```bash
python patch_dll_crc.py verify Client/90CN.dll # 1. 先确认现在是否已经是坏的
python patch_dll_crc.py patch Client/90CN.dll # 2. 重写 CRC
python patch_dll_crc.py verify Client/90CN.dll # 3. 复核
```
然后**必须重编服务端**,见下一节。
## 六、和「服务端 DLL 指纹」是两回事
| 机制 | 在哪 | 校验什么 | 失败表现 |
|------|------|----------|----------|
| 自校验 CRC32 | `90CN.dll` 内部 | 自己文件字节没被改 | 启动即弹框「客户端补丁文件已被修改」并退出 |
| DLL 指纹 SHA-256 | `DNF90Server.exe`(编译期 `boundDllSHA256`) | 客户端上报的 DLL 哈希是否等于服务端编进去的那一个 | 能进登录,但连接被拒 / 掉线 |
所以**换 DLL 必须重编服务端**,否则 CRC 自己是对了,指纹对不上照样连不上。发布包里 `Client\90CN.dll` 与 `Server\runtime\bin\DNF90Server.exe` 必须成对。
## 七、排查手册
| 现象 | 原因 | 处理 |
|------|------|------|
| 启动弹「客户端补丁文件已被修改」 | CRC 不符,或槽位根本没 patch(`magic`/`offset` 检查不过) | 跑 `patch`;看日志 `self crc check: computed=... expected=...` 两值 |
| 读槽位后立刻访问冲突(AV) | 早期脚本把 `0xC0DEC0DE` 当普通字符串扫,写进了 `.text` 机器码 | 用当前脚本重编重 patch;`info` 确认槽位在 `.data` |
| `verify` 报 `offset does not point at itself` | 文件被别的工具改写,或用了错误版本的脚本 | `patch` 重写后复核 |
| `0xC0DEC0DE only found in an executable section` | 说明槽位没编进数据段(被优化掉/常量折叠) | 检查 `volatile` 是否还在,重编 |
| 每台机器都「校验失败」但本地 `verify` 通过 | 分发过程中文件被改(签壳、压缩包截断、杀软修复) | 对比分发前后 SHA-256,禁用会改二进制的「修复」 |
| 校验通过但登录掉线 | DLL 指纹与 `DNF90Server.exe` 不配套 | 用同一份 DLL 重编服务端 |
日志前缀统一是 `self crc `(`step=` 为流程、`check:` 为结果、`diag:` 为失败原因),排查时直接 grep 这个前缀。
## 八、为什么不用数字签名
自签名证书要装根、要处理时间戳与过期;WinVerifyTrust 还要依赖系统服务。CRC32 不防「有动机的攻击者」——他们可以直接改期望值——它防的是**误改与低级篡改**,对单机模拟器这个场景够用,且零依赖、构建期一次写入。