适用:
unified/packages/native-offsets建立日期:2026-11-13 目的:让 NapCat 的封包 hook 不再依赖「每个 QQ 版本人工加一行偏移」
NapCat 的 packages/napcat-core/external/napi2native.json 长这样:
{
"9.9.31-49738-x64": { "send": "0AF9FCC", "recv": "2007901" },
"9.9.36-53644-x64": { "send": "C3EE2F", "recv": "24A6355" },
"3.2.34-53644-x64": { "send": "6E5AB20", "recv": "3A12CD0" }
}
这是硬编码 RVA。问题:
initHook 直接 segfault(Linux 3.2.35 实测)本方案:用函数入口的字节特征(签名)定位,不存偏移。QQ 升级后只要函数序言不变,签名就继续有效。
Windows QQ 9.9.36-53644,wrapper.node(117,866,024 B,PE x64):
| hook | NapCat 表值 | 按 file offset 解释 | 按 RVA 解释(正确) |
|---|---|---|---|
| send | 0xC3EE2F |
03 48 8d 54 24 28(函数中间) |
55 41 56 56 57 53 48 83 EC 60(函数序言) |
| recv | 0x24A6355 |
83 c4 28 5b 5f 5e 41 5e c3(函数尾部) |
55 41 57 41 56 41 55 41 54 56 57 53(函数序言) |
换算公式(.text 节 va=0x1000、rawptr=0x400):
file_offset = RVA - 0xC00
这个 0xC00 是本版本的差值,每个版本/每个节都可能不同,不要硬编码。 本库的
binary.ts走节表自动换算。
按 RVA 解释后,两个点都精确落在标准 x64 函数序言上。这意味着:
push rbp / push r1x / sub rsp / 栈帧初始化)call rel32 / je rel32 这类相对位移,用 ?? 通配即可| 签名 | 长度 | 全文件命中 |
|---|---|---|
| send | 53 B(含 4 字节通配) | 1 |
| recv | 110 B(含 16 字节通配) | 1 |
recv 前 82 字节有 31 处命中(x64 函数序言高度同质),延伸到 110 字节才唯一。
cd unified
pnpm install
# 解析(只读)
pnpm resolve-offsets "C:\Program Files\Tencent\QQNT\versions\9.9.36-53644\resources\app\wrapper.node"
# 机器可读
pnpm resolve-offsets <path> --json
输出:
file : wrapper.node
size : 117,866,024
sha256 : 0d8b94448f4dafe7337b88d568b004224c37fcbe2bd1142c1af7624fbd534c
format : pe arch=x64 imageBase=0x180000000
[HIT] napcat.wrapper.send
file_offset=0xc3e22f rva=0xc3ee2f va=0x180c3ee2f section=.text
[HIT] napcat.wrapper.recv
file_offset=0x24a5755 rva=0x24a6355 va=0x1824a6355 section=.text
=> 全部签名命中唯一,可直接用于 hook。
关键:输出的 rva 与 NapCat 静态表精确一致(0xc3ee2f / 0x24a6355),
说明签名方案可以无缝替代那张表。
NapCat 的调用点在 packages/napcat-core/packet/client/nativeClient.ts:47:
const success = this.napi2nativeLoader.initHook(send, recv);
改造步骤:
// 改造前
import offsets from '../../external/napi2native.json';
const entry = offsets[`${qqVersion}-${arch}`];
const success = this.napi2nativeLoader.initHook(entry.send, entry.recv);
// 改造后
import { resolve, NAPCAT_HOOK_SIGNATURES } from '@unified/native-offsets';
const data = await readFile(wrapperNodePath);
const result = resolve(wrapperNodePath, data, NAPCAT_HOOK_SIGNATURES);
if (result.failures.length > 0) {
// 签名失效 → 走 §5 的重新定特征流程
throw new Error(`hook signature lost: ${result.failures.map(f => f.signatureId)}`);
}
const send = result.sites.find(s => s.signatureId.endsWith('.send'))!;
const recv = result.sites.find(s => s.signatureId.endsWith('.recv'))!;
// NapCat 的 initHook 吃的是 RVA 十六进制字符串
const success = this.napi2nativeLoader.initHook(
send.rva!.toString(16).toUpperCase(),
recv.rva!.toString(16).toUpperCase(),
);
注意:NapCat 的
initHook接收的是 RVA 字符串(不含0x)。 用site.rva.toString(16)即可,不要传fileOffset。
建议保留静态表作为交叉验证,而不是直接删掉:
const result = resolve(path, data, NAPCAT_HOOK_SIGNATURES, {
knownRvas: offsets[versionKey], // 表里有的版本就校验
});
// result.crossCheck 会告诉你:签名算出的 RVA 与表里的是否一致
三种情况:
| 静态表 | 签名 | 处理 |
|---|---|---|
| 有 | 命中 | 交叉验证一致 → 正常;不一致 → 报警(说明签名匹配到了错误的函数) |
| 有 | 未命中 | 签名失效 → 用表里的值兜底 + 记录待修 |
| 无 | 命中 | 这正是本方案的价值:新版本自动可用 |
| 无 | 未命中 | 走 §5 |
按成本从低到高:
签名尾部若落在会变的指令上,去掉尾部即可:
# 用工具导出当前签名的前 N 字节
# 在新版本里搜,命中后人工确认上下文
本库的 Signature.minPrefixForFallback 就是给这个用的。
拿旧版本命中点附近的一段长字节(±256 B)去新版本里搜:
old = open("old_wrapper.node", "rb").read()
new = open("new_wrapper.node", "rb").read()
off = 0xC3E22F # 旧版本已知 file offset
sig = old[off - 256 : off + 256] # 512 字节上下文
idx = new.find(sig)
print(hex(idx + 256) if idx >= 0 else "not found")
找到后,把新位置周围的字节重新提取成签名。
注意:如果函数体本身大改,这段会找不到,走 5.3。
NapCat 的 hook 目标是 MSF 网络层:
MSF::MSFSDK::sendPacket —— 导出符号,nm -D 能看到(Linux 版)Linux 技巧:wrapper.node 有 885 个导出符号,可以先用 MSFSDK::sendPacket
附近的符号作为锚点,再找调用它的内部函数。
Windows 技巧:wrapper.node 是 stripped PE,但有 .pdata 节(异常处理表),
里面记录了所有函数的起止地址——可以用来枚举函数边界,大幅缩小搜索范围。
如果签名彻底失效且来不及逆向,退回 NapCat 原方案:手工找偏移填表。
napi2native.json 保留原格式,本库可与它共存。
| 平台 | wrapper.node 格式 | 现状 |
|---|---|---|
| Windows | PE x64 | ✅ 已验证(9.9.36-53644,napi send/recv + packet send/recv 各唯一命中,与 fork 表逐字节一致) |
| Linux | ELF x64 | ✅ 跨版本已验证:3.2.28-48517 与 3.2.35-54395 上 napi send + packet send/recv 三组签名均唯一命中,与 fork 表值一致(详见下表) |
| macOS | Mach-O | ❌ 未涉及 |
拿两个真实二进制逐字节验证(3.2.28 是 NAS 生产环境版本,3.2.35 是实验环境版本):
| 签名 | 3.2.28-48517 | 3.2.35-54395 | 跨版本稳定 |
|---|---|---|---|
napcat.wrapper.send.linux |
✅ 0x5E41890 | ✅ 0x6EBE4D0 | ✅ |
napcat.packet.send.linux |
✅ 0x56BE140 | ✅ 0x665CC80 | ✅ |
napcat.packet.recv.linux |
✅ 0x56BFE90 | ✅ 0x665EA20 | ✅ |
napcat.wrapper.recv.linux |
❌ | ❌ | 已移除(见 6.2) |
3.2.28 的 napi send 解析值 0x5E41890 与 fork
napi2native.json的3.2.28-48517-x64.send精确一致,跨版本可信度得到交叉印证。
该函数属于 djinni 生成的分发器家族,全二进制有 269 个函数共享同一段序言
(只有 sub rsp 的立即数和 je 位移不同)。实测四种方案全部失败:
| 方案 | 3.2.28 | 3.2.35 |
|---|---|---|
| 短序言 + FDE 入口约束 | 269 命中 | 269 命中 |
| 再排除已解析的 send 位置 | 仍多命中 | 仍多命中 |
| 300 字节长合并签名 | 0 命中 | 22 命中(不唯一) |
| 两版字节取交集通配 | 678 命中 | 165 命中 |
根因:两版之间编译器把栈帧从 0x40 改成 0x30,所有 [rsp+N] 引用整体平移,
长签名逐字节全错;而短签名又不足以区分 269 个同族函数。
影响:无。packet 层签名经流量验证可覆盖收发需求;napi recv 另有零账号实验
证实登录页 0 流量(reports/X64-CODEC-ALIVE-EXPERIMENT.md §九)。
若将来确实需要它,正确做法是按版本写死表值(NapCat 的做法),
因为该函数的可辨识特征恰好是版本相关的。
Linux 签名的两个特有机制(Windows PE 不需要):
.eh_frame_hdr FDE 函数入口约束(requireEhFrameEntry: true)——GCC 布局下函数背靠背,
PE 的「前一字节是 ret/int3」启发式在 ELF 上不可靠;FDE 二分表给出真函数入口。
recv 同族分发函数(序言相同的多个同构体)只有靠这条约束才能收敛到唯一命中。tableEncoding: 'rva-x2')——packet.json 的表值是真实偏移 ×2
(MoeHoo InitHook 对入参 >>1),napi2native.json 直接存 RVA。解析结果的
tableValue 字段已按编码输出,不要再手工换算。集成测试(真实二进制,环境变量门控,缺省跳过):
# Windows PE(117MB)
UNIFIED_WRAPPER_NODE="C:\Program Files\Tencent\QQNT\versions\<版本>\resources\app\wrapper.node"
# Linux ELF x64(152MB,3.2.35-54395)
UNIFIED_LINUX_WRAPPER_NODE="<本机路径>\wrapper.node"
ambiguous,不猜。| 路径 | 角色 |
|---|---|
packages/native-offsets/src/signature.ts |
通配符签名的解析与扫描 |
packages/native-offsets/src/binary.ts |
PE/ELF 解析 + 三套坐标换算 + .eh_frame_hdr FDE 表 |
packages/native-offsets/src/resolver.ts |
解析主流程 + 交叉验证 + eh_frame 入口约束 + 表值编码 + sha256 |
packages/native-offsets/src/signatures/napcat-hooks.ts |
napi 签名库(Windows PE + Linux ELF x64 send/recv) |
packages/native-offsets/src/signatures/packet-hooks.ts |
packet 签名库(MoeHoo 收发监听,表值 ×2 编码) |
packages/native-offsets/tests/ |
单元测试 + 真实二进制集成测试(PE + ELF) |
tools/resolve-offsets.ts |
CLI(自动按容器格式/架构筛选签名) |
D:\Project\nc+sl\LLQQNT\tools\verify-bypass\ 用同样的思路解决另一个问题
(QQ 文件校验绕过)。两者的对比:
| 维度 | LLQQNT(文件校验) | 本库(NapCat hook) |
|---|---|---|
| 目标 | 校验函数里的 call memcmp |
MSF 网络层函数入口 |
| 签名长度 | 18 B(x64) | 53 B / 110 B |
| 通配 | 无(全确定字节) | 有(rel32 位移) |
| 为什么通配 | 目标指令本身固定 | 函数序言里的 call 目标会变 |
| 命中数 | 1 | 1 |
| 跨版本 | 9.9.32 → 9.9.36 四个版本不用改 | 待验证(只有单版本实测) |
LLQQNT 那份 OFFSET-RESOLUTION.md 的 §10「sig 失效时怎么重新定特征」
可以直接套用到这里,方法论一致。
wrapper.node 做跨版本验证。真正证明「跨版本稳定」需要拿到 9.9.35 或
9.9.37 的二进制跑一遍。requireEntry 字段未实现。签名库里有这个字段,但 resolver.ts
还没用它做「命中点必须是函数入口」的额外校验(目前靠签名长度保证特异性)。