从抓包到改字段:王者荣耀区服列表的一次协议层折腾
目录
声明:本文只记录个人设备与本地安装包上的分析过程,不涉及任何线上服务与账号,初稿由 AI 整理。东西还在改,写下的结论随时可能被下一轮验证推翻。
2026-07-27 更新:后半篇基本重写。规则从设备上的配置文件搬到了主机的工作台,又做了凭空造消息塞进收包路径的能力,顺着一次真实购买把客户端凭什么认账这件事摸了一遍。
这篇是两天的流水账,从 tcpdump 一路挖到 IL2CPP 的协议层。前半段讲怎么进去:网络层试了三种改法、Frida 试了两种进法、成熟的 hook 框架在 217MB 的库上装不进去,最后落到手写 inline hook 加运行时 dump 符号表。后半段讲进去之后能干什么:规则搬到主机上的工作台,能按字段名改任意一条收发消息,还能凭空造一条消息塞进收包路径,让客户端把它当成服务器真发的。
事情起因
有天晚上打开游戏,登录页那个选择服务器的界面,一千多个大区排在那儿,我突然好奇这堆名字是从哪来的,实时拉的还是本地缓存的,能不能改一个字看看。我估计抓个包就能看明白,两三个小时的事。
先交代环境,免得后面对不上。设备是一台 root 过的真机,HyperOS 3 / Android 16 / arm64,装了 KernelSU 和 Zygisk Next;游戏是国服 11.4.1.1,SO 构建日期 2026-06-14。文中提到的函数地址都是绑死在这个版本上的 RVA,游戏一更新就全废,具体数值我留在自己仓库的文档里了。
先说现在到哪一步了
游戏进程里现在常驻一个 Zygisk 原生模块,收包和发包各挂了一处 hook。每条消息都会被反射递归读成 JSON,报到主机上的一个工作台,规则在网页上点几下就生效。除了改收到的消息,还能凭空造一条服务器根本没发过的消息交给客户端,后半篇讲这个。
先看两个在屏幕上肉眼确认过的例子。第一个是区服名,把手Q1区那一条改掉,其余大区一个都不动:

第二个是大厅左上角的昵称。游戏自己的改名入口最多让你填 6 个中文字,下面这张图里显示了 8 个,而且后面还被控件宽度截掉了一截:

规则只有一行,把登录响应 SCID_CMD_GAMELOGINRSP 里的 szName 换掉。实际塞进去的是 26 个中文字、78 字节,而 TDR schema 给这个字段的定长是 64 字节:
I WzryZg: [route] r msgid=1003 szName : System.Byte[] OK -> 新 Byte[78](任意长)
6 个字的限制在客户端的改名界面上,schema 的 64 字节在协议定义里,这两道都没拦住,因为改的是 unpack 之后、进游戏逻辑之前的那个对象。这条链路走通之前还踩了个 8 字节的坑,放到后面讲。先说是怎么走到这一步的。
抓包很顺利,然后就卡住了
tcpdump 这一步没什么好说的,运气还不错,区服相关的流量是明文,区服名和 LogicWorldID 都在里面明摆着。帧结构也简单,55 00 00 00 后面一个字节的长度,再跟 UTF-8 的名字。
顺着这份明文把整张表解了出来,1075 条,每条是 <平台><编号>区<诗意名> 加上 LogicWorldID 和一组 relay 地址。手Q 601 条、微信 458 条,剩下十来条是抢先服、测试服、提审服这类。诗意名基本取自英雄和皮肤词,落雪白狼、鲜血枭雄、电玩小子、长城防线都在里面。
解完之后发现一件有意思的事。我游戏里能看到的手Q区最大是 547 区(落雪白狼,LogicWorldID 1557),而 ilink 响应里一路排到手Q600区(戍鼓铭心,1610)。548 到 600 这五十多个区的名字其实早就下发到客户端了,界面只挑已开服的那部分显示。编号和 ID 大致是手QN区等于 1000+N、微信N区等于 3010+N,中间有跳号,547 区对应的是 1557,1547 被别的东西占了。当初就是拿这条跟游戏界面交叉验证的。
然后就卡住了。抓包只解决了看见,改得另找地方。能选的方案无非三个,在 libc 的收发函数上 hook、上 eBPF、或者做个透明中继把包改了再转发,我按成本从低到高挨个试。
libc hook 最便宜,我写了 Frida 脚本挂 recv / read / recvfrom,跑完一看,一个相关的包都没截到。先怀疑是自己写挂了,但诊断日志在正常输出,说明代码是跑着的。于是做了个对照:同一次 60 秒的启动,tcpdump 那边看到区服相关字段两千多次,我这边计数是零。这条流量根本没走 libc,是裸的系统调用,recvfrom 号 207。之前挂 socket 那堆函数一直没有输出,原因也在这。
eBPF 那条路倒是好使,内核侧什么都看得见,包一个不漏,稳定性也没问题,我还专门记了一份 ilink 那条 flow 的特征。可它只能看。真要改包得再搭一层机制出来。
透明中继理论上什么都能改,代价是我得先把这套私有协议实现一遍:拆帧、解 TDR、改字段、重新组包,心跳和重传也得处理。为了改一个区服名做到这个程度,怎么算都不划算(如果目标是做通用的流量改写工具那另说,可我只是想看一眼名字能不能改)。
三条都不行,网络层我就整个放弃了。这个决定还带来一个附带好处:进程内改的是已经解出来的对象,传输层用什么协议、加不加密,跟我没关系。
Frida 进不去,两种进法都试了
第一选择肯定是 Frida,脚本改起来快,出问题也好调。然后就撞上了这个游戏的反外挂,attach 主进程必崩,它会把 Frida 注入进去的 gum 运行时干掉。
第二种进法是把 gadget 塞进 Zygisk 模块里加载,避开 attach 这个动作本身的特征。主进程照样活不下来,只有那些没有反外挂保护的子进程能存活,可那些进程对我一点用没有。顺着这个方向查了一圈 Frida 特征消除的资料,要对抗的检测点太多,短时间内做不完。
最后走通的是纯 Zygisk 原生模块,跟着 zygote 一起进去,不做任何注入动作。模块能常驻在游戏进程里,代价是 gum 那套现成设施一样都没有,hook 得自己实现。
这中间有个判断标准的问题值得记一下。我一开始用 pidof 判断游戏活没活着,但有几次进程明明在,屏幕上游戏早崩了。UI 崩掉之后主进程会留在幽灵状态,另外还有个服务子进程是独立存活的,pidof 一查就是有 PID。后来改成三个条件同时成立才算数:主进程 cmdline 是包名、有活跃的渲染线程、屏幕上确实是游戏画面。标准定错的代价是排查一个根本不存在的问题。
155MB 的代码段把 hook 框架顶死了
我先试的 shadowhook 这类成熟框架,原理是四字节相对跳转,再配一小块中转代码,要求中转代码落在目标函数 ±128MB 的窗口里。平时这约束根本不是事,可 libil2cpp.so 有 217MB,光代码段就占 155MB,LTO 编译得极其密实。我扫了一遍整个代码段,只找到一处 12 字节的零填充,附近也没有空闲映射。
框架装到一半报了 errno=40。翻了下它的源码,它是想用 mmap 在目标地址减 128MB 的位置要块内存当 hint,可那个地址恰好落在这个库自己的映射里,内核直接把 hint 忽略掉,最后越界失败。自己找空洞用 MAP_FIXED 建一块也不行,没有空洞可用,ASLR 每次开出来的布局还不一样,有一次看到过 9MB 的间隙,再跑就没了。
那就手写。绝对跳转没有 ±128MB 的约束,代价是要占 16 个字节:目标函数开头覆盖成 LDR X17,#8; BR X17; .quad proxy,被盖掉的原指令搬到另一块 mmap 出来的可执行内存里,后面接一条同样的绝对跳转回到目标函数加 16 的位置。这套做法 And64InlineHook 里早就有了。唯一的要求是被搬走的头几条指令得位置无关,我挑的那几个目标开头都是 sub sp 或者 stp,adrp 都在 16 字节之外,逐条搬过去就行。
冷函数这么搞没问题。区服名相关的几个函数只在登录期被调用,我在 gcloud 载入之后、组网之前那个窗口一次性装好,四个全装上。
收发口那几个就不行了,它们从启动就被网络线程高频调用。16 字节写下去不是原子的,写到一半正好有线程执行到那儿,它拿到的就是一条撕裂的指令流。这种问题不会立刻炸,可能跑十分钟才崩一次。
热函数最后用的断点:只原子地写一条 BRK #0,编码 0xD4200000,四字节对齐写天然原子,然后自己装一个 SIGTRAP 处理函数,命中之后在信号上下文里把 PC 改到 proxy 上。frida-gum 的 exceptor 也是这个思路,算是有个参照。好处是装的时候不用停线程也不用挑时机,随时都能装。代价是每次命中多一次异常往返,另外首指令是 adrp 的函数得先做重定位才能用,这块我还没写。
配套踩的两个小坑。读写托管内存全改走 process_vm_readv,读到非法地址只返回 EFAULT,游戏照跑,实测比用 pipe 探测快四十倍左右,一千多个节点靠 pipe 根本扫不动。另一个是托管指针顶字节带着 TBI tag,不剥掉的话解析出来的类型信息全是乱码,日志里指针顶上那个 0xb4 就是线索。
符号表只能自己在运行时捞
想在 IL2CPP 层挂 hook,前提是知道函数在哪。常规做法是 Il2CppDumper 静态解析,但这个版本里 CodeRegistration 和 MetadataRegistration 都被抹成了零,auto 和 manual 模式全部失败,global-metadata.dat 大概率也做了处理。我还去翻了几年前的分析文章,那会儿这游戏的 metadata 还是明文的,手动填两个地址就能把全部符号名导出来。
这里得纠正一个我原先的认知。我之前以为 IL2CPP 类似 Java 虚拟机,那样的话应该能在解释执行的地方一把拦住所有 C# 方法。实际上 IL2CPP 是 AOT 转译器,C# 先转成 C++ 再编成原生 ARM64 机器码,libil2cpp.so 里全是真机器码,没有解释器,也没有字节码。所以别指望有什么统一执行层,dump 出来的也只是符号表,方法体是空的,真想看逻辑还得去 IDA 里读反汇编。好在每个 C# 方法都对应一个有真实地址的原生函数,元数据里还留着方法指针。
那静态拿不到,运行时呢?元数据在运行时必然是解开的,不然游戏自己也跑不起来,这一点没什么可怀疑的。想通就好办了,在模块里拿 domain,枚举全部 assembly,逐个类写出命名空间、父类、每个方法的名字和 RVA、每个字段的偏移和类型,全量落到文件里。63034 个类,412674 个方法,69.8MB 文本,不到一秒跑完,游戏没受影响。静态被封的那一步,搬到运行时重做了一遍,而且这里的偏移是运行时的真实布局。
hook 点到底该选哪个函数
有了符号表,候选点一下子多了起来。判断标准就一条:一处能覆盖多少条消息。
区服名那次挂的是构造服务器 URL 的方法,每个区服节点调一次,一次登录一千两百多次,名字在参数节点往下一层的托管字符串里。定向是够定向,可它只解决区服列表这一件事,换个接口就得重新找一遍。
通用的是协议封装那两个函数,出站序列化的 CSPkg.pack 和入站反序列化的 unpack,所有走这套协议的消息都得经过。挂上之后拿到了 CSPkg 对象:+0x10 是消息头,头里 +0xc 就是路由键 dwMsgID,+0x18 是消息体,消息体的 +0x10 才是真正的消息对象。
不过只挂这两处还不够,大厅阶段有些消息压根不经过 unpack。翻了一圈调用链,收包实际的分发口是 NetworkModule.HandleMsg,参数布局跟 CSPkg 一致,msgid 也对得上。于是收包挂分发口,发包挂 pack。当时账号还卡在登录重试里,反复出现的就 1014、1191、1193 三条,对照过来是重新登录通知、游戏服重定向通知和错误码通知,跟游戏自己日志里打出来的方法名能互相印证。msgid 能对上,说明前面那串偏移没有一个是猜错的。
等长替换的两个限制
最早的改法是在消息体的字节里搜一段、换成等长的另一段。区服名那次能成有运气成分,「王者独尊」和「总裁牛逼」在 UTF-8 和 UTF-16LE 下都恰好等长,可以直接原地覆盖。
拿它当通用能力就不行了。长度不能变是一层,更麻烦的是我只能盯着十六进制看,根本不知道自己改的是哪个字段。
后面这个先解决。反射 API 都在,那就把消息对象递归读成 JSON 落盘:il2cpp_object_get_class 拿类,沿 il2cpp_class_get_parent 把父类链上的实例字段都收集起来,这里要用 flags 把 static 跳掉,否则会撞上一堆类常量(我第一版就撞上了,输出里全是 BASEVERSION、CLASS_ID 这种东西),最后按 il2cpp_type_get_name 分派读法。落下来的一行长这样:
{"dir":"r","msgid":1193,"cmd":"SCID_NTF_ERRCODE","type":"SCPKG_NTF_ERRCODE","body":
{"stResultInfo":{"iResultId":11014,"stArgList":{"iNum":0,"astArgs":{"classId":148,
"jarrCount":5}},"m_classID":150},"m_classID":3338}}
在此之前我改一个值,得先猜它在 body 里的偏移,改完还得盯日志看有没有效果。现在字段名和嵌套结构全摆在那儿,规则直接写 stResultInfo.iResultId 就完事。这一步的提升比前面所有环节加起来都明显。JSON 我落的是 JSONL 文件,没走 logcat,logcat 单行会截断、量一大还丢行,几 KB 的消息存不住;缓冲写满的时候会补齐右括号并打上截断标记,保证每行都是合法 JSON,不然消费端读到一行坏的整行都得丢。
长度限制则是在我选对时机之后自己消失的。收包在 unpack 之后改,改完的对象直接交给游戏逻辑,不会再序列化;发包在 pack 之前改,TDR 序列化的时候自己会重算所有长度前缀,换个指针就能换任意长的内容。之前被等长卡着,纯粹是因为我在做裸字节替换,这个限制在 IL2CPP 这一层本来就不存在。
顺着这条路还发现一个当时没料到的事实:这套协议里一个 System.String 字段都没有,8868 个协议类里出现 0 次,文本一律是 System.Byte[],1155 处。所以 il2cpp_string_new 对协议消息用不上,真正管用的是 il2cpp_array_new 造一个新数组换上去。
昵称:一个 8 字节的偏移
收包侧的任意长度,我一直没能在界面上演示出来。原因很实际:账号所在区服爆满,登录卡在注册上限那个提示上,进不了大厅,能收到的响应就 1014、1191、1193 三条通知,里面没有一个带文本的字段。换了个不爆满的区服登录进去,收包的消息种类从 3 涨到 282,带文本 Byte[] 的字段有 21 个,能挑的目标一下子就多了。
挑中的是 SCID_CMD_GAMELOGINRSP(1003)的 szName,TDR schema 里定长 64 字节,原值「饿了狂吃三碗」18 字节,大厅左上角直接显示它。
第一条规则我就写了个 78 字节的名字,故意超过 schema 的 64。logcat 里 新 Byte[78](任意长) 打出来了,切到大厅一看,昵称位置是空的:

第一反应是 78 字节超了限制被丢掉了。做个对照:换成 15 字节的短名字,远小于 64。还是空白。那就跟长度无关,写进去的东西本身有问题。
于是在改写之前,先把原数组从对象首地址起的 48 字节打出来:
+0x00 e08cdd496f0000b4 klass
+0x08 0000000000000000 monitor
+0x10 4000000000000000 max_length = 0x40 = 64
+0x18 e9a5bf e4ba86 e78b82 e59083 ... 数据 = 饿了狂吃三碗
标准的 Il2CppArray 布局是 +0x10 bounds、+0x18 max_length、+0x20 数据,我的代码照这个写的。这份 SO 里没有 bounds 字段,长度在 +0x10,数据从 +0x18 开始,整体差 8 个字节。读的时候少了开头,昵称读出来是「…吃三碗」;写的时候前面留了 8 个零,游戏拿到的是空串,界面上什么都不显示。
把偏移改成数据 +0x18、长度 +0x10,短名字立刻就出来了:

再把 78 字节那条规则放回去,就是开头那张图:显示成「任意长度改写验证」,8 个中文字,后面的还被控件宽度截掉了。78 字节的数组确实被游戏读进去并渲染了,schema 写的 64 字节在收包这一侧没有约束力,客户端改名界面那 6 个字的限制更是碰都碰不到。
发包方向是另一回事。同样换一个 99 字节的数组上去,对象层面成立、游戏存活,可 char szXxx[N] 这种定长字段,pack 只按 schema 的 N 取,超出的部分发不出去。任意长度这个说法只在收包侧成立。
协议表要不要自己造
拿到 msgid 还差一张表,得知道 1193 是什么、body 里都有哪些字段。
自己从 dump 里还原完全可行,字段名和偏移都在。但我记得之前刷到过有人放出相邻小版本的完整成果,4015 条命令 ID 到名字的映射,加上按 C# 类近似还原的 proto 和 TDR 定义。差一个小版本能不能直接用是个需要验证的问题,我抽了心跳、登录、匹配、商城、聊天几条出来对,编号和类名跟自己 dump 出来的完全一致,命名空间也对得上,那就直接拿来当路由表用了,省了大半天。地址那部分不行,人家的 RVA 是自己那版的,只能我自己重新来。
自己这边从 dump 里筛出 7836 个协议消息类,连字段名、类型和偏移导成 JSON 和一份可读索引。这里有个细节容易出问题:同一个协议名在 dump 里有两份,CSProtocol.* 是运行时真正在用的引用类型,CSProtocolWrapper.* 是 ValueType 镜像,两者字段偏移不一样。我一开始按短名建索引,后者把前者覆盖掉,导出来的偏移全是错的。发现它是因为一条已经在设备上验证过的规则突然对不上号,后来改成按全名建 key,再给一份运行时索引才修好。
URL 通道:换个方向问
游戏里还有一大堆走 HTTP 的请求,我想把 URL 也记下来。第一版是按符号 hook libcrosCurl.so 导出的 cros_curl_easy_setopt,装上了,只收到四次调用,option 全是 99(CURLOPT_NOSIGNAL),一次 CURLOPT_URL 都没有。
静态反汇编跟下去:导出层转到库内一个非导出的变参 setopt,再转到 Curl_vsetopt,跳转表是标准 curl 的,说明 option 编号没被改过,CURLOPT_URL 确实是 10002。于是按地址挂库内那个,想覆盖库里的调用者。结果两边触发次数一比一同步,还是只有 CURLOPT_NOSIGNAL,库里没人从这条路设 URL。
原因在 libPluginCrosCurl.so,一个 18KB 的 GCloud 插件,它导出 CurlFuncQueryService::QueryCurlFuncs,调用方拿到的是一张函数指针表,按指针调用,按符号 hook 够不着。
换个方向就通了:不去追 URL 是怎么设进去的,改在 perform 的时候问 handle 要。hook 住两个库的 curl_easy_perform,进去先 getinfo(handle, CURLINFO_EFFECTIVE_URL),拿到就落 JSONL。MSDK 和 FaaS 那几条带完整 query 的请求就这么出来了,构造服务器地址的那条 Tdir 串也走同一个文件,用 via 区分来源。
后来搭了个工作台
规则写在设备的 wzry.cfg 里这件事,用到后面就不够了。改一条规则要编辑文件、push 过去、等热重载,而且基本是盲写:想知道某个接口长什么样,得先跑一遍看 JSONL,再回来改规则。大厅能收到的消息有 282 种,这么来回几次就受不了。
于是把看明细和定规则整个搬到主机上。设备把收发的每条消息报过去,前端实时看、能翻历史、能全文搜,规则在页面上点几下就生效,设备端只留一份引导配置。
传输走局域网的常驻 TCP,四字节大端长度加一帧 JSON,TCP_NODELAY 打开,不打开的话 Nagle 会给决策路径加 40ms。收发直接走 syscall,绕开我们自己在 libc 上装的那些 hook,免得数据绕回自己的 scan()。
动手之前先量了一轮 RTT,局域网常驻连接,300 次往返全成功:P50 2.5ms、P99 5.0ms、最大 18.1ms。这个数单看挺好,但登录那一两秒的峰值是 264 条消息,如果每条都同步问一次主机,单秒要阻塞 700ms,网络线程七成时间在等回话。大厅 idle 只有 5 条/秒,13ms/秒,可以忽略。
所以最后的做法是服务端下发一份关注集:名单内的 msgid 走同步询问,远端说了算;名单外本地直接放行、异步上报给前端看。名单本身在前端配,随时改。另外配了安全阀,单次硬超时、连续失败熔断十秒一律放行、之后自动探活恢复,服务端挂了游戏也照样跑。
服务端是 Node 22 加内置的 node:sqlite,带 FTS5,不用装原生依赖,TypeScript 直接跑不用编译。一个进程同时干三件事:TCP 决策、HTTP 控制面、托管前端的构建产物。前端是 Vite + React + react-router。
界面现在是这样:

左边消息流可以按方向、msgid、名字过滤,也能暂停;右边上半是这条消息的方向、msgid、接口名、运行时类名和 seq/ver,下半是 body 的 JSON 树。图里筛的是 npc,出来的是 CSID_ADD_NPC_REQ(2015)。树上任意字段可以直接生成改写规则,路径和当前值都预填好。比起改配置文件重登一次才能试一条规则,开房间、加 NPC 这种流程现在可以盯着实时流一条条看,改个参数就知道客户端什么反应。
处置有三种:allow 原样放行、patch 按字段路径改、drop 在发包侧丢掉。本来还想要第四种「不真发、直接给一份响应」,一开始盯上的是游戏自带的 CreateDefaultCSPKG 和 LobbyMsgRecvSimulated,后来发现这条路根本不用走,往下两节说。
顺带记一个坑:CreateDefaultCSPKG 压根挂不上,dump 给的 RVA 是 243MB,超过了 libil2cpp.so 本身的 218MB,是个无效地址;装 hook 前读 prologue 那一下用的是裸解引用,当场把进程带走了。现在改成先 safe_read 探一下,探不通就跳过。
先拿显示层练手
工作台跑起来之后,第一件事是把规则从设备上的配置文件挪到前端,拿昵称那条验证远端下发这条链路。中间踩了两个坑。synctimeout 我按 RTT 实测的 P99 设成了 5ms,可 ping 量的是 30 字节空包,真实事件的 JSON 有 5KB,同步决策直接超时,设备压根没收到回话;放宽到 50ms 之后,日志里是 决策回话 8323us。另一个是 JSON 字符串值解出来本身带引号,我又给包了一层,昵称显示成了带引号的 "远端下发的名字"。
然后轮到货币。第一反应是在实时流里全文搜界面上显示的金币数,一条都没命中,因为金币在登录响应里,而那次会话没重新登录过。换个方向,从 schema 里搜名字像货币的字段,SCPKG_CMD_GAMELOGINRSP 里有个 stCoinList,往下是 CoinCnt,一个 UInt32[],按货币类型索引。
驱动一次完整登录(得点开始游戏,1003 才会发),抓到 stCoinList.CoinCnt[1] = 6222,跟界面上的金币数对得上,下标 6 是 190,正是钻石。下标的含义来自协议参考仓库里的 COM_COIN_TYPE 枚举,0 是金币、1 是竞技币、6 是钻石。索性把那份仓库里的 1322 个枚举全导了出来,前端现在会把数组下标直接标成含义,不用再对着数字猜。
这一步还得补点代码:原来的 JSON walker 遇到数值数组只报个长度,赋值也只支持 Byte[],得让它能展开数组元素,路径语法也加上 name[idx]。
规则在页面上填两行就行:

保存,重登,大厅顶上的数字就变了:

点券不在 CoinCnt 里,它有自己的消息 SCPKG_CMD_ACNTCOUPONS(1161/1162),字段是 llCouponsCnt,后来也验过了,同样能改。
这些都还只是客户端显示层的事。改的是 unpack 之后那个对象,服务器自己那本账一分没动,拿这些钱去买东西,请求发过去照样被打回来。
这个协议不是一问一答
前端想显示一条请求的往返耗时,就得把请求和响应配上对。我找了半天请求 id,最后是从游戏自己怎么分发消息里找到答案的:RegisterMsgHandler(msgid, delegate) 建的表只按 msgid 建索引,HandleMsg 拿包头里的 msgid 查表,把整条消息交给对应的委托。整条分发路径上没有请求上下文,没有每请求的回调,也没有任何关联令牌。
另外三个方向也核了一遍,结论一致。CSPkgHead.dwSvrPkgSeq 是服务器给自己发的包编号、客户端在出包里回显最近收到的那个,同一个值会连着出现在好几条发包上,服务器主动推的通知则是 0,是 ack 语义。四千个消息类里只有 6 个带 dwClientReqID,个别新接口才加。SCID 有 2435 个而 CSID 只有 1580 个,服务器主动推的通知本来就没有对应的请求。
所以这是消息总线式的协议,服务器什么时候推什么,客户端按 msgid 认领。前端那个耗时列只能靠命名配对加时间窗去猜(CSID_X_REQ 对 SCID_X_RSP,1580 个 CSID 里 1280 个能配上),标出来的是推测值。
这个结论顺带把另一件事推到眼前:既然分发只认 msgid,那我自己造一条消息交给 HandleMsg,游戏的处理方式就跟服务器真发过来完全一样。
凭空造一条消息塞进去
实现上麻烦的是两个地方。
一是对象怎么建。CSPkg、CSPkgHead、CSPkgBody 这三个容器类不用按名字查,每条真实收包都会经过,收包 hook 里缓存下来就行;只有 dataObject 那个协议类必须按名字找,因为要注入的消息可能从来没收到过,用 il2cpp_class_from_name 逐个 image 试,112 个程序集里能找到,耗时可以忽略。建出来之后必须跑一遍 il2cpp_runtime_object_init:object_new 只分配不初始化,内层引用字段全是 null,游戏一取值就崩,而 TDR 生成的 .ctor 会把子结构和定长数组都建出来,这是能往里塞字段的前提。
二是在哪个线程执行。托管 API 只能在 attach 过 domain 的线程上调,我在轮询线程直接调,进程当场就没了。改成远端来的命令先入队,等下一次 HandleMsg 在网络线程上触发时就地执行,那个线程是游戏自己的,天然 attach 好。大厅里每秒都有消息,延迟可以忽略。另外要在分发当前消息之前把队列清空,因为真实的顺序就是到账通知先于 _RSP。
先拿一条效果确定可见的验证:注入 1162 SCPKG_CMD_ACNTCOUPONS,llCouponsCnt 填 777777,大厅顶栏的点券当场变成 777777。再注入 1823 SCPKG_HEROSKIN_ADD,商城里那个皮肤的按钮从「购买」变成了「已拥有」。游戏全程存活,没有新的 tombstone。
伪造一次购买要凑齐三样
光会造消息还不够,得知道造哪几条。先抓了一次真实购买的完整时间线,买英雄小乔,一条请求换来服务端连发七条:
s 1817 CSID_BUYHERO_REQ dwBillNo=... bBuyType=2 dwHeroID=106
r 1010 SCID_ACNT_INFO_UPD 货币扣除
r 1504 CSID_TASKUPD_NTF 任务进度
r 48601 SCID_COLLECT_VALUE_TOTAL_INFO_NTF
r 4403 SCID_ACHIEVEMENT_DONE_DATA_CHG_NTF
r 1810 SCID_HERO_INFO_UPD ← 英雄到账,排在 _RSP 前面
r 1818 SCID_BUYHERO_RSP iResultId=0 dwHeroID=106 dwCoinCost=5888
r 51457 SCID_BUY_REGRET_INFO_NTF 反悔期信息
到账先落地,确认后到。失败的购买长得完全不一样:皮肤那次货币不足,服务端只回一条 1820,iResultId=13,而且 wCoinType 和 dwCoinCost 都是 0,说明根本没走到扣费那一步。
所以只配一条「把 _RSP 的结果码改成 0」的规则是不够的,客户端没收到到账通知,它并不认为自己拿到了东西。这条我实测确认过,弹窗不会出来。
补上到账通知之后,客户端会自己往下走,然后撞回来:
s 8566 CSID_CMD_SKIN_SUIT_REQ 客户端自动发的穿戴请求
r 8569 SCID_CMD_WEAR_SKIN_SUIT_RSP iResultId=122009
r 8567 SCID_CMD_SKIN_SUIT_RSP iResultId=0
122009 就是下面这个提示的来源。商城按钮已经是「已拥有」,可穿戴那步被真实服务端拒了:

到这一步要三样凑齐:模块推 1823 让客户端认为到账、规则把 1820 的结果码改成 0、规则把 8569 的 122009 也改成 0。这个形状是通用的,伪造任何「我拥有 X」都得先注入到账通知,再把后续所有拿 X 去校验的响应挡回成功。客户端下一步问什么,抓包就知道。
顺带记一下资源号的换算:购买请求只给 dwResID,到账通知要的是 dwHeroID 加 bSkinID,dwHeroID = dwResID / 100、bSkinID = dwResID % 100。18801 就是英雄 188 的 1 号皮肤,实测对得上。
拥有列表另有来源
英雄那条链补完之后购买成功的弹窗出来了,可商城里那个英雄还是显示未拥有。
原因是拥有列表跟到账通知不是同一份数据。1886 SCID_ACNTHEROINFO_PARTTWO_NTY 在登录时下发英雄拥有列表,结构是 dwHeroNum 加一个 200 槽位的数组,我这个账号 dwHeroNum=12;1810 SCID_HERO_INFO_UPD 是对已有条目的增量更新,账号里没这个英雄,就没得更新,商城自然读不到。
做法是在 1886 上追加:dwHeroNum 加一,往空槽位写英雄号。这要求路径解析能穿过对象数组并按元素类补建,因为 .ctor 只把数组建出来、元素全是空的。改写走不补建、造消息走补建,两条路分开。
皮肤是同一个形状,区别在拥有关系的记法:COMDT_HERO_SKIN 是一个英雄一条,ullSkinBits 的第 N 位表示拥有 N 号皮肤,补皮肤得先按英雄归拢再置位,比英雄那边多一层换算。这段代码照结构写好了,还没验证过,因为承载皮肤列表的 1802 至今一次都没捕获到,登录那批 45 条消息里没有它,同一组的 1886 却有。
规则之外还得有脚本
到这儿声明式的规则不够用了。规则能做的是把某条消息的某个字段改成某个固定值,而上面这些要从触发消息里算值(dwResID 除以 100 得英雄号、取余得皮肤号)、要跨消息记状态(这次会话补发过什么,下次登录 1886 一到还得再补一遍)、还要推新消息。
所以加了模块,脚本跑在 node:vm 里,上下文只给 msg、body、get、push、patch、store、grants、log 这几样,on 声明触发条件,handle 里干活。买皮肤那个模块就十来行:
on = { dir: 's', msgid: 1819 };
handle = function (ctx) {
const resid = ctx.get('dwResID');
ctx.push({ msgid: 1823, type: 'SCPKG_HEROSKIN_ADD', patch: [
{ path: 'dwHeroID', value: String(Math.floor(resid / 100)) },
{ path: 'bSkinID', value: String(resid % 100) },
]});
};
状态分两层:ctx.store 是模块自己的 KV,ctx.grants 是发放台账,按类型记物品号。前端有个存储页,两样都能看能改,手工往台账里加一条等于直接发一个,不用真去点购买。
规则和模块两个都留着。规则是表单点几下、列表上一眼看得全,默认用它;模块能力上是规则的超集,但配置成本高得多,规则表达不了的时候才写。
这里踩了个自己挖的坑:一开始把模块的命中次数 upsert 进 modules 表,给仓库里的模块记一次命中就写出一行 code 为空的记录,下次加载时这行空的把文件里的实现盖掉,模块静默失效,前端还报「脚本要定义 handle 函数」。计数后来拆到单独的表存。
尾声
写这段的时候正卡在一个死锁上:设备端的事件缓冲是 17000 字节,1802、1886 这种大消息会被截断,而截断修复只补了对象和字符串、没补数组,服务端解不出来就不回话,设备那边同步超时设的是无上限,网络线程一直等着,选服进游戏直接冻住。关掉这两个模块就正常。三个地方都要改。
其他欠着的:补发的到账通知只让客户端本地认账,切场景或者重登会被真实数据刷掉,要持久得连 1010 SCID_ACNT_INFO_UPD 和后续的查询响应一起接管;1802 为什么捕获不到还没查;开箱奖励 SCID_CMD_GIFT_BATCHUSE 的 stRewardList 是同一个形状,做通了就能伪造开箱。所有 RVA 依然绑死在 11.4.1.1,游戏一更新全部作废。
往前看一步,私服这件事的地基其实已经铺得差不多了。消息历史里躺着几千条真实往返,这是录;造消息注入让我们能以服务端的身份把任意一条消息交给客户端,这是放;台账和场景负责把一套状态存下来。剩下的是让服务端自己算,照着真实服务器的收发把逻辑实现出来,客户端连过来就是一个私服。区服列表那条链路早就 hook 住了,往响应里塞一个自己的服务器条目也是通的。
回头看这两天,费时间的地方不在写代码。每次一条路走不通,都得先判断到底是方案本身不行、还是我用错了。前者要掉头,后者只要接着修。判断错了就是几个小时白花。