修复 DLL 自校验 CRC 算法并加固槽位定位(频道无法连接问题)

根因:重写的 patch_dll_crc.py 用 binascii.crc32(data, 0xFFFFFFFF) 计算 CRC,
该参数是"末值异或之后的运行值",再喂回去等于重复施加了条件化,得到的是
CRC32 的另一个变体,与 90CN.cpp 里 Crc32Update 的实现不一致。结果是补丁写
入的 expected 永远校验不过,客户端在 VerifySelfIntegrity 失败后安装流程中断,
transport 钩子(codec/decode/route/dproto)全部没装上,表现为"无法连接频道"。

改动:
- tools/patch_dll_crc.py
  * CRC 改为标准 zlib.crc32(init 默认 0),与 C 端 Crc32Update 完全一致;
    已用旧版本 DLL 反向验证(10-20、11-03、10-28 三份的存储值均能复算一致)
  * 槽位改为按 PE 段表定位,只接受 .data/.rdata 内的候选,拒绝把补丁写进
    .text 机器码(此前曾命中 cmp ..., 0C0DEC0DEh 的立即数导致访问违例)
  * 新增 info/verify 子命令,patch 后自动回读校验
- 90CN.cpp
  * SelfCrcSlot 三个字段加 volatile,避免 /O2 把 offset/expected 折叠成常量
  * VerifySelfIntegrity 增加分步诊断日志,便于定位安装线程中断点
- client_build_id.go
  * 指纹重绑为本次构建产物:1215488 / C7BE377C…8247071A

验证:客户端日志 self crc check computed==expected==0x80AFC116 ok=1,
transport DLL installed codec=1 decode=1 route=1 dproto=1,
channel connect result=1,first-login watchdog server_seen=1 client_seen=1。
This commit is contained in:
hanfuit committed 2026-11-05 11:37:10 +08:00
1 parent 2ca06e113a
commit 6e570fe820
3 files changed
+162 -43

No files matched your search

+14 -3
View File
@@ -13169,9 +13169,11 @@ static bool VerifyBoundMoviePresent()
// expected = 整个文件的 CRC32(计算时跳过 [offset, offset+4),避免自指)
// 运行时重新读盘、用同样规则重算并比对;任何字节被改动 → 不符 → 客户端无法启动。
static struct SelfCrcSlot {
unsigned int magic; // 固定 0xC0DEC0DE
unsigned int offset; // expected 字段的文件偏移(脚本写入)
unsigned int expected; // 文件 CRC32(脚本写入)
// volatile:这三个字段只由构建后脚本改文件字节,代码里从不写。
// 不加 volatile 时 /O2 会把 offset/expected 当常量折叠成 0,补丁写对了也读不到。
volatile unsigned int magic; // 固定 0xC0DEC0DE
volatile unsigned int offset; // expected 字段的文件偏移(脚本写入)
volatile unsigned int expected; // 文件 CRC32(脚本写入)
} g_selfCrcSlot = { 0xC0DEC0DEu, 0u, 0u };
static unsigned int Crc32Update(unsigned int crc, const unsigned char* data, size_t length)
@@ -13189,6 +13191,8 @@ static unsigned int Crc32Update(unsigned int crc, const unsigned char* data, siz
static bool VerifySelfIntegrity()
{
wchar_t path[MAX_PATH] = {};
// 崩溃定位断点:worker 在 TCLS 之后踩 AV 时靠这些行判断停在哪一步。
LogLine("self crc step=enter slot=%p", (void*)&g_selfCrcSlot);
// 用数据变量地址解析自身模块;函数地址在某些构建/加载方式下解析不到(实测 err=126)。
HMODULE selfModule = nullptr;
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
@@ -13197,10 +13201,12 @@ static bool VerifySelfIntegrity()
LogLine("self crc diag: GetModuleHandleEx failed err=%u", GetLastError());
return false;
}
LogLine("self crc step=module module=0x%p", (void*)selfModule);
if (!GetModuleFileNameW(selfModule, path, MAX_PATH)) {
LogLine("self crc diag: GetModuleFileName failed err=%u", GetLastError());
return false;
}
LogLine("self crc step=path");
FILE* file = nullptr;
if (_wfopen_s(&file, path, L"rb") != 0 || !file) {
LogLine("self crc diag: open failed");
@@ -13209,6 +13215,7 @@ static bool VerifySelfIntegrity()
fseek(file, 0, SEEK_END);
const long size = ftell(file);
fseek(file, 0, SEEK_SET);
LogLine("self crc step=size size=%ld", size);
if (size < 4096) {
LogLine("self crc diag: file too small size=%ld", size);
fclose(file);
@@ -13221,6 +13228,7 @@ static bool VerifySelfIntegrity()
}
const size_t read = fread(image, 1, static_cast<size_t>(size), file);
fclose(file);
LogLine("self crc step=read read=%u image=%p", (unsigned int)read, (void*)image);
if (read != static_cast<size_t>(size)) {
free(image);
return false;
@@ -13228,6 +13236,7 @@ static bool VerifySelfIntegrity()
const unsigned int slotOffset = g_selfCrcSlot.offset;
const unsigned int expected = g_selfCrcSlot.expected;
LogLine("self crc step=slot offset=0x%X expected=0x%08X", slotOffset, expected);
bool ok = false;
if (g_selfCrcSlot.magic != 0xC0DEC0DEu || slotOffset < 16 ||
static_cast<size_t>(slotOffset) + 4 > static_cast<size_t>(size)) {
@@ -14995,7 +15004,9 @@ DWORD WINAPI InstallTransportWorkerInner(void*)
TerminateProcess(GetCurrentProcess(), 1);
}
// 影片绑定校验:文件缺失/损坏直接终止,客户端无法启动。
LogLine("self crc verified, entering movie binding check");
EnforceBoundMovieOrExit();
LogLine("movie binding check passed");
// 攻坚队创建入口默认写死 owner 0x76(RaidCreateWindow),把该立即数改成
// 0x7A 后打开原生 RaidSelectSingleModeWindow(团队/单人选择),单人按钮
// 再把 mode 0 传回 0x76 的创建窗口。窗口与按钮回调全部是客户端原生实现。
+147 -39
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""90CN.dll self-integrity CRC32 patcher (reimplementation of patch_dll_crc.py).
"""90CN.dll self-integrity CRC32 patcher.
The client patch embeds this struct (see client-patch/90CN.cpp, VerifySelfIntegrity):
@@ -12,10 +12,19 @@ The client patch embeds this struct (see client-patch/90CN.cpp, VerifySelfIntegr
At runtime the DLL re-reads itself, recomputes that CRC and compares. Any byte
change (e.g. patching out the check) makes the client refuse to start.
IMPORTANT — the magic is NOT unique in the image. MSVC encodes the comparison
`magic != 0xC0DEC0DEu` as an immediate inside .text, so a naive "find the first
0xC0DEC0DE" scan lands on machine code and overwrites instructions, which shows
up at runtime as an access violation right after the slot read. This tool
therefore resolves the slot through the PE section table and only accepts a
candidate that lives in a data section (.data/.rdata) and whose offset/expected
fields are either zero (fresh build) or already self-consistent.
The CRC is the standard reflected CRC-32 (poly 0xEDB88320, init 0xFFFFFFFF,
final XOR 0xFFFFFFFF) == zlib.crc32 / binascii.crc32.
Usage:
python patch_dll_crc.py info <dll> # show slot + section layout
python patch_dll_crc.py verify <dll> # check the current stored CRC
python patch_dll_crc.py patch <dll> # rewrite offset+expected in place
python patch_dll_crc.py patch <dll> -o <out.dll> # write to a copy instead
@@ -29,75 +38,171 @@ import argparse
import binascii
import struct
import sys
import zlib
from pathlib import Path
MAGIC = 0xC0DEC0DE
MAGIC_BYTES = struct.pack("<I", MAGIC)
# struct layout is three consecutive little-endian uint32
SLOT_SIZE = 12
OFFSET_FIELD = 4
EXPECTED_FIELD = 8
# Section names whose raw data may hold the slot. MSVC puts a zero-initialised
# (but non-const) static object in .data; if it is truly constant it can end up
# in .rdata. Anything executable is rejected outright.
SLOT_SECTIONS = (".data", ".rdata")
class SlotError(RuntimeError):
pass
def find_slot(blob: bytes) -> int:
"""Return the file offset of the SelfCrcSlot struct.
The magic must be followed by a plausible `offset` field pointing at the
`expected` field it lives in; that rejects accidental magic collisions in
code or data.
"""
start = 0
candidates: list[int] = []
while True:
pos = blob.find(MAGIC_BYTES, start)
if pos < 0:
def parse_sections(blob: bytes) -> list[dict]:
if len(blob) < 0x40 or blob[:2] != b"MZ":
raise SlotError("not a PE image (missing MZ header)")
pe = struct.unpack_from("<I", blob, 0x3C)[0]
if blob[pe : pe + 4] != b"PE\0\0":
raise SlotError("not a PE image (missing PE signature)")
nsec = struct.unpack_from("<H", blob, pe + 6)[0]
opt_size = struct.unpack_from("<H", blob, pe + 20)[0]
sh = pe + 24 + opt_size
sections = []
for i in range(nsec):
base = sh + i * 40
if base + 40 > len(blob):
break
candidates.append(pos)
start = pos + 1
name = blob[base : base + 8].rstrip(b"\0").decode("latin1")
vsize, va, rsize, roff = struct.unpack_from("<IIII", blob, base + 8)
chars = struct.unpack_from("<I", blob, base + 36)[0]
sections.append(
{
"name": name,
"vsize": vsize,
"rva": va,
"rawsize": rsize,
"rawoff": roff,
"executable": bool(chars & 0x20000000),
"writable": bool(chars & 0x80000000),
}
)
if not sections:
raise SlotError("PE has no section table")
return sections
def section_of(sections: list[dict], file_offset: int) -> dict | None:
for sec in sections:
if sec["rawoff"] <= file_offset < sec["rawoff"] + sec["rawsize"]:
return sec
return None
def find_slot(blob: bytes) -> tuple[int, dict]:
"""Return (file offset, section) of the SelfCrcSlot struct."""
sections = parse_sections(blob)
candidates: list[tuple[int, dict]] = []
pos = blob.find(MAGIC_BYTES)
while pos >= 0:
sec = section_of(sections, pos)
if sec is not None and sec["name"] in SLOT_SECTIONS:
candidates.append((pos, sec))
pos = blob.find(MAGIC_BYTES, pos + 1)
if not candidates:
# Distinguish "magic only in code" from "magic nowhere": give a useful hint.
if blob.find(MAGIC_BYTES) >= 0:
raise SlotError(
"0xC0DEC0DE only found in an executable section — refusing to "
"patch machine code (the real slot lives in .data/.rdata)"
)
raise SlotError("0xC0DEC0DE slot magic not found")
for pos in candidates:
if pos + SLOT_SIZE > len(blob):
continue
offset = struct.unpack_from("<I", blob, pos + OFFSET_FIELD)[0]
if offset == pos + EXPECTED_FIELD and offset + 4 <= len(blob):
return pos
# Fall back to the first candidate so `patch` can still repair an unpatched
# or corrupted slot (fresh build has offset == 0).
return candidates[0]
def score(item: tuple[int, dict]) -> tuple[int, int]:
p, sec = item
offset = struct.unpack_from("<I", blob, p + OFFSET_FIELD)[0]
fresh = offset == 0
patched = offset == p + EXPECTED_FIELD
# Prefer a patched slot, then a fresh one, then .data over .rdata.
return (2 if patched else 1 if fresh else 0, 1 if sec["name"] == ".data" else 0)
candidates.sort(key=score, reverse=True)
best = candidates[0]
if score(best)[0] == 0:
raise SlotError(
"slot magic found in data but offset field is neither 0 nor self-referential"
)
return best
def compute_crc(blob: bytes, expected_offset: int) -> int:
"""CRC32 of the whole file, skipping the 4 `expected` bytes."""
"""CRC32 of the whole file, skipping the 4 `expected` bytes.
Must match 90CN.cpp VerifySelfIntegrity() exactly:
unsigned int crc = 0xFFFFFFFF;
crc = Crc32Update(crc, image, slotOffset);
crc = Crc32Update(crc, image + slotOffset + 4, size - slotOffset - 4);
crc ^= 0xFFFFFFFF;
Crc32Update is the reflected CRC-32 (poly 0xEDB88320) with no internal
conditioning, so "init 0xFFFFFFFF ... final XOR 0xFFFFFFFF" is exactly
zlib.crc32()/binascii.crc32() with the DEFAULT seed 0.
Do NOT seed binascii.crc32 with 0xFFFFFFFF: that argument is the running
CRC *after* the final XOR, so seeding it re-applies the conditioning and
yields a different, non-matching variant (this bug shipped once and made
the client reject its own DLL).
"""
if expected_offset < 16 or expected_offset + 4 > len(blob):
raise SlotError(
f"slot offset 0x{expected_offset:X} out of range (size 0x{len(blob):X})"
)
crc = 0xFFFFFFFF
crc = binascii.crc32(blob[:expected_offset], crc)
crc = binascii.crc32(blob[expected_offset + 4 :], crc)
crc = zlib.crc32(blob[:expected_offset])
crc = zlib.crc32(blob[expected_offset + 4 :], crc)
return crc & 0xFFFFFFFF
def read_slot(blob: bytes) -> tuple[int, int, int]:
pos = find_slot(blob)
def read_slot(blob: bytes) -> tuple[int, dict, int, int]:
pos, sec = find_slot(blob)
magic, offset, expected = struct.unpack_from("<III", blob, pos)
return pos, offset, expected
if magic != MAGIC:
raise SlotError("slot magic mismatch after read")
return pos, sec, offset, expected
def cmd_info(path: Path) -> int:
blob = path.read_bytes()
sections = parse_sections(blob)
print(f"file : {path} ({len(blob)} bytes)")
for sec in sections:
print(
f" {sec['name']:9s} rva=0x{sec['rva']:08X} vsize={sec['vsize']:8d} "
f"raw=0x{sec['rawoff']:08X} rawsize={sec['rawsize']:8d}"
f"{' EXEC' if sec['executable'] else ''}"
f"{' WRITABLE' if sec['writable'] else ''}"
)
try:
pos, sec, offset, expected = read_slot(blob)
except SlotError as exc:
print(f"slot : NOT FOUND ({exc})")
return 1
print(
f"slot : file 0x{pos:08X} in {sec['name']} "
f"offset=0x{offset:08X} expected=0x{expected:08X}"
)
return 0
def cmd_verify(path: Path) -> int:
blob = path.read_bytes()
try:
pos, offset, expected = read_slot(blob)
pos, sec, offset, expected = read_slot(blob)
except SlotError as exc:
print(f"FAIL {path.name}: {exc}")
return 1
print(f"slot : file 0x{pos:08X} offset=0x{offset:08X} expected=0x{expected:08X}")
print(
f"slot : file 0x{pos:08X} in {sec['name']} "
f"offset=0x{offset:08X} expected=0x{expected:08X}"
)
if offset != pos + EXPECTED_FIELD:
print(f"FAIL {path.name}: slot offset does not point at itself")
return 1
@@ -113,7 +218,7 @@ def cmd_verify(path: Path) -> int:
def cmd_patch(src: Path, dst: Path | None) -> int:
blob = bytearray(src.read_bytes())
try:
pos = find_slot(blob)
pos, sec = find_slot(blob)
except SlotError as exc:
print(f"FAIL {src.name}: {exc}")
return 1
@@ -126,16 +231,17 @@ def cmd_patch(src: Path, dst: Path | None) -> int:
out = dst or src
out.write_bytes(bytes(blob))
print(f"slot : file 0x{pos:08X} offset=0x{expected_offset:08X} expected=0x{crc:08X}")
print(
f"slot : file 0x{pos:08X} in {sec['name']} "
f"offset=0x{expected_offset:08X} expected=0x{crc:08X}"
)
print(f"OK {out.name}: patched ({len(blob)} bytes)")
# Re-read and verify what we just wrote.
return cmd_verify(out)
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="90CN.dll self-integrity CRC patcher")
parser.add_argument("action", choices=["verify", "patch"])
parser.add_argument("action", choices=["info", "verify", "patch"])
parser.add_argument("dll", type=Path)
parser.add_argument("-o", "--out", type=Path, help="output path for patch (default: in place)")
args = parser.parse_args(argv)
@@ -143,6 +249,8 @@ def main(argv: list[str]) -> int:
if not args.dll.is_file():
print(f"FAIL {args.dll}: not a file")
return 2
if args.action == "info":
return cmd_info(args.dll)
if args.action == "verify":
return cmd_verify(args.dll)
return cmd_patch(args.dll, args.out)