修复 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

+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)