From 6e570fe8202cefd02fd84cab6b430a801ebc0451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=AE=9D=E4=BB=99=E5=B0=8A?= Date: Thu, 5 Nov 2026 11:37:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DLL=20=E8=87=AA=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20CRC=20=E7=AE=97=E6=B3=95=E5=B9=B6=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E6=A7=BD=E4=BD=8D=E5=AE=9A=E4=BD=8D=EF=BC=88=E9=A2=91=E9=81=93?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E8=BF=9E=E6=8E=A5=E9=97=AE=E9=A2=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:重写的 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。 --- client-patch/90CN.cpp | 17 +- client-patch/tools/patch_dll_crc.py | 186 ++++++++++++++---- .../services/dnfbridge/client_build_id.go | 2 +- 3 files changed, 162 insertions(+), 43 deletions(-) diff --git a/client-patch/90CN.cpp b/client-patch/90CN.cpp index 831ed65..bc937ee 100644 --- a/client-patch/90CN.cpp +++ b/client-patch/90CN.cpp @@ -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), file); fclose(file); + LogLine("self crc step=read read=%u image=%p", (unsigned int)read, (void*)image); if (read != static_cast(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(slotOffset) + 4 > static_cast(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 的创建窗口。窗口与按钮回调全部是客户端原生实现。 diff --git a/client-patch/tools/patch_dll_crc.py b/client-patch/tools/patch_dll_crc.py index 0024499..31a7d11 100644 --- a/client-patch/tools/patch_dll_crc.py +++ b/client-patch/tools/patch_dll_crc.py @@ -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 # show slot + section layout python patch_dll_crc.py verify # check the current stored CRC python patch_dll_crc.py patch # rewrite offset+expected in place python patch_dll_crc.py patch -o # 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(" 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(" 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(" 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(" tuple[int, int]: + p, sec = item + offset = struct.unpack_from(" 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(" 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) diff --git a/server/internal/services/dnfbridge/client_build_id.go b/server/internal/services/dnfbridge/client_build_id.go index 3f8737c..0233b23 100644 --- a/server/internal/services/dnfbridge/client_build_id.go +++ b/server/internal/services/dnfbridge/client_build_id.go @@ -10,7 +10,7 @@ const ( boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1" boundMovieBytes = int64(22846532) - boundDllSHA256 = "30E41FF583893E611E6975DF96246E1320B042F7B33A32503C987F211D1776F5" + boundDllSHA256 = "C7BE377CC2795612074CAE70F071523E19E32964CED355762A2B59F48247071A" boundDllBytes = int64(1215488) )