自研引擎渠道包 native 补丁全记录
测试环境:自有设备、自有账号、离线验证。全文隐去应用与厂商标识,函数偏移与哈希保留(技术内容,非身份信息)。姊妹篇是《Unity IL2CPP 移动端运行时改造技术报告》,两篇的差别本身就是信息:那次的地图是 dump.cs,这次没有地图。
0. 结论先行
| 功能 | 手法 | 字节 | 调用面 |
|---|---|---|---|
| 经济货币自动拾取 | 4 条 bl 重定向到 mov w0,#1; ret 小 stub | 4 + 2 | 31 个调用点共用 1 个 dword |
| 一次性清屏道具不限量 | 3 个 cmp w8,#1 改 cmp w8,w8,强制走游戏自己的 unlimited 分支 | 3 | 4 / 1 / 1 |
| 免广告 | 出口前 and w0,w9,w8 改 mov w0,#0;每日配额 b.le 改 nop | 2 | 广告 API 面约 570 个方法 |
最终基线 stage3 相对 stage2 恰好改 11 个指令字,全部在 .text 和一个 8 字节空洞里。三条共同点:不新增状态。
1. 目标确认与包体拆分
APK 约 2.3 GB,lib/arm64-v8a/ 下一个 97 MB 的 C++ 主库(下称 libMain.so),另有广告、支付、统计 SDK。
它不是 IL2CPP:没有 global-metadata.dat,没有 GameAssembly.dll/libil2cpp.so,assets/bin/Data 结构也不同。上一篇的 Il2CppDumper 流水线一步都用不上,工具退回 capstone + IDA + 自写的 AArch64 小解释器。
脚本层是 Lua,业务界面大量由 Lua 驱动,C++ 侧只提供带反射注册表的字段与函数。
1.1 包体:STORED 是前提
extractNativeLibs=false,.so 以 STORED(未压缩)存在 zip 里,直接从 APK 内存映射执行。于是替换它不需要重压 2.3 GB:新库按原样写回同一个 zip 条目、重写中央目录与 EOCD 即可。
硬约束:新库字节长度必须与原库完全一致,否则后面所有条目的偏移整体平移,安装失败或加载到错数据。为此留两个专职校验脚本:一个比对包内解压长度与磁盘长度,一个检查 zip 结构自洽。签名走 apksigner,v1 到 v4 全开。
1.2 ELF 三个坑
ELF64_R_SYM在r_info的高 32 位。搞反会误判dlopen没被导入,从而去做一次本可避免的 Java 层改动。Elf64_Phdr的顺序是offset, vaddr, paddr, filesz, memsz。漏掉paddr会得出「某段大小为 0」。- PLT 地址必须由桩自己的
adrp+ldr反查 GOT 再对.rela.plt得到。公式化推导会差 32 字节,从而把__android_log_print认成pthread_create,进而冤枉前面所有探针。
2. 反射注册表就是这次的 dump.cs
注册每个成员时都走同一段形状:
adrp x9, <hi> / add x9, x9, <lo> ; 类型名字符串
ldr x8, [x8, #0x58]
mov w3, #<offset> ; 就是成员的字节偏移
blr x8
把「地址落在字符串区的 adrp/add」和紧随其后的 mov w3,#imm 配对,一次静态扫描就把主存档对象(下称 StoreObj)的整张字段表吐出来:395 行,无一猜测。
这张表当场纠正了一次已经花掉装机的错误:之前有个补丁打的是 StoreObj+0x1a0,界面毫无反应,表里写着 +0x1a0 是收藏图鉴,不是道具数量表。
3. 签名校验与暗桩(stage1)
流程:原包解出、只换 libMain.so、用自建 keystore 重签名、装机看能不能跑。
结论:没有启动期签名校验,重签名可以直接安装并正常进游戏。但这一步顺手挖出两个更要命的东西:
| 暗桩 | 形态 | 后果 |
|---|---|---|
| 签名校验虚函数 | 返回常量 0 就放过 | 不处理也能跑,但留着是隐患 |
| 退出总闸 | 一个 JNI 调用通向 exit | 运行几十秒后随机退出 |
两处共 4 个指令字,构成 stage1。次序因此固定:先确认签名这一关要不要处理,再确认二进制里有没有会主动杀死进程或上报的暗桩,因为后面所有失败归因都依赖这个前提。
3.1 控制组纪律(本次最贵的一条)
控制组不许用厂商原版库。 原版库带着那两个暗桩,拿它当「未改动基线」去排查崩溃,必然复现一次启动即死,然后得出「原始内容也崩」的假结论。
控制组的正确形态是 stage1(只做最小改动那一版)配未改动的 dex,签名仍然是自己的。判据是库的 sha256 前缀。
4. 离线断言体系:把一次装机换成三百次离线断言
真实成本:一次交付要 push 2.3 GB(USB 约 40 MB/s,65 秒)加安装,装机到看到界面 6 到 10 分钟,手机还是唯一的真相来源。这个单价决定策略:凡是能在离线证伪的东西,一律不留给装机。
每个补丁都是三件套:
| 件 | 职责 |
|---|---|
patch_*.py | 从基线字节出发逐字改,改完立刻断言「这条指令原来是我想改的那条」 |
verify_*.py | 静态检查 + 真跑(自己的 AArch64 解释器执行被改函数,做收敛比对)+ 变异测试 |
rebuild_*.sh | dex 到 lib 到重打包到签名到包内字节审计,一条命令跑完,中间任何一步失败就停 |
4.1 变异测试
每改一处就派生一个「故意改错」的镜像,要求校验器必须点名抓住。错一位、位移一格、两个字对调、守卫从 64 位退成 32 位、答完不弹栈、把共享谓词整体强答,全都要被抓住。
最后一个交付版上,11 个字的归属审计配 13 个变异,逐个命名,一个不漏。它防的是同一种失败:校验器全绿,但它检查的东西根本不存在。
4.2 函数级收敛比对
改答案型补丁的判据:把 hook 点的函数整个跑完(从入口到 ret),基线版和改过版的返回值、寄存器、栈窗口逐项比对,允许有差异的地方必须写清楚是哪个用例、差在哪。
这条要求顺手暴露了工具自身的四个洞:
- capstone 会把
MOV Xd,Xm渲染成orr xd,xm,x0,小解释器没有orr。 add x8,x0,#1, lsl #12按逗号切分会算成加 1。cbz/tbz/asr/and不支持。uxtw #3这类寄存器扩展被静默忽略。
全都是「能反汇编、能装机、结果不对」那一类。
4.3 工具行为坑
- capstone 对不可解码字会静默返空,循环里没断言就等于悄悄漏扫。逐字调用它反汇编 8600 万条要走六分钟,整段丢给一次调用可以。
SystemExit不是Exception,变异测试用except Exception会直接中断整轮,而屏幕上那行ok看起来一切正常。- 只要经过去掉退出码的外壳程序就会骗人:把输出
tail或sed一截,SyntaxError会被藏起来,报出来的退出码是管道下游的。判据只用未过滤的运行结果。 - 全量 dump 一千五百行会把 logcat 默认 256 KB 环形缓冲挤掉,症状是「什么都没抓到」,要先
logcat -G 16M。 - Windows 下 adb 的远端路径会被 Git Bash 改写,不设
MSYS_NO_PATHCONV=1时 push 会失败而退出码照旧是 0,机器上留的还是旧文件,测出来的一切都不作数。
4.4 编码坑
- LDR/STR 的 imm7 在 16:10,LDP/STP 的 imm7 在 21:15。写成一套掩码扫全库,会得到十五万条假命中,同时漏掉真正要找的那个访问器。
- STP 的立即数既 scaled 又换位的组合最容易写错,错出来的字仍然合法,只是两条保存悄悄落在同一个槽位上。唯一能抓住它的是真的执行一遍。
MOV Xd, Xm要按ADD Xd, Xn, #0发;ADD的#1, lsl #12形态是另一个操作数,跟普通 imm12 分开,两条 add 相加才能得到+0x12e0这种超范围偏移。- 字段偏移超过 1016 时 64 位 LDR 无法折叠,超过 4095 时 32 位也无法折叠,编译器会先
add出偏移再访问,此时偏移寄存器是基址不是索引。按「索引寄存器」去匹配会一个都不中。
5. 交付:三个功能
5.1 经济货币自动拾取
原始形态是一个共享位图谓词(按 mask 取位):全库 31 个调用点共用 player+0x188C 这一个 dword 的 4 个位,写它的只有一个来自 Lua 的接口,本账号读出来永远是 0。
直觉做法是置位,但那等于给所有共用这几个位的逻辑发特权,属于典型的共享总线陷阱。真做法是逐点重定向:只把「掉落物被收集」这 4 条调用换向,位图一个字节都不动,其他 27 个调用点的行为完全不变。
; 4 个调用点统一改成跳到一个 2 指令 stub
bl collect_stub ; 原 bl 判定函数
; stub:
mov w0, #1
ret
hook 的两个坑:中途 hook 必须假设标志寄存器不可用,先确认下一条指令是不是重写 flags 的(本项目那两处,一处下一条就是 subs,另一处是 mov 加 bl);bl 之后 caller-saved 全废,被 hook 函数的入参(当时就在 w0 里)要从自己帧里 reload。函数内部的调用者 LR 不在 x30(前面的 bl 已经改写),只能读被 hook 函数 prologue 落盘在 [sp, #8] 的值,用 x30 打日志会指向一些根本不含 bl 的地址。
5.2 一次性清屏道具不限量
字节最少的一项。翻它的时候先假设要造一个「免费」概念,读下去发现游戏本来就有无限概念:道具包里一个标志字节配三个计数访问器,三个访问器是同样的 5 个字、只差读哪个计数,月卡持有者看到的 可用=1、花费=0 就是走这个分支。
于是补丁退化成让比较恒真:
cmp w8, #1 -> cmp w8, w8 ; 3 处
调用面 4/1/1,全在道具自己的 HUD 和消耗路径里。不写存储、不需要挑时机、不可能被服务端按回。
被排除的两条路:选包的谓词有 71 个调用者(通用的取管理器函数),功能开关谓词有 38 个调用者并且曾经把选关页冻住。
5.3 免广告
先做归属判定,因为「永久免广告是本地特权还是服务端下发」决定要不要打网络。
答案是本地:整个广告 API 面(约 570 个方法)没有任何一个 S2C_*/C2S_* 名字;网络层只在启动时下发配额数字和一个时钟偏移;两处广告唤醒入口都在 Java 层且自带日志。
and w0, w9, w8 -> mov w0, #0 ; 广告开关判定函数出口前
b.le ... -> nop ; 每日配额那条
改完 2 个字之后顺手去掉每日 20 次限制,用的是同一个函数的另一条分支。
5.4 归属判定的反面:钻石是云端权威
同一套归属判定在别处给出了相反答案:钻石与内购是云端权威。改完本地字段看到数字变了,再走一次同步就回落到服务端算的值;请求里的 r 字段常态是 0,e 字段加密,客户端不校验响应真伪(那是显示层的信任)。
这条否证把「刷钻石」这类需求从待办里永久划掉,也顺手解释了另一件事:0 元礼包的界面卡死跟签名改造无关,因为那是服务端在真付款失败时返回的正常状态。
6. id 只存在于运行时的时候怎么办
皮肤(装扮)和挂件两项卡在最基础的一步:配置里的 id 全在包里,静态搜不到。三条证据:
- 注册表的构造函数只写虚表和空串,一条记录都不插。
111029这类数字在整张镜像的立即数里 0 命中。- 那九个 map 对象本身躺在
.data/.bss,文件里全零。
id 是登录后从配置灌进 map<int, {string name, ...}> 的。
6.1 空间不够,用 dlopen
手写汇编行不通:整个可执行段里长度不小于 1 KB 的零空洞一个都没有,唯一的洞是 .rodata 里 1028 字节,扣掉前面几个 stub 只剩三百多,够放指令,不够放「遍历九棵红黑树并解 libc++ 字符串」。而 lib 真的导入了 dlopen。
做法:那三百字节里放一个一次性加载桩,逻辑放进一个用 NDK 编译的 15 KB 独立 .so,APK 里多塞一个 zip 条目,dex 与 Java 一行不动。
; 一次性加载桩(.rodata 空洞内)
adr x0, <so 路径>
mov w1, #2 ; RTLD_NOW
bl dlopen
; .bss 里一个一次性闸门,只跑一次
一次性计数器要放 .bss,不能放 .rodata 那个洞里,那是 R+X 段。
这个决定把后面所有问题都变成了免费的。
6.2 枚举结果
枚举器把九张注册表按引擎自己的 contains/id→name 两个函数扫一遍,一次跑出一千五百行日志:
| 项 | 结果 |
|---|---|
| 挂件 | 注册表正好 62 个 id:21001..21030 加 21050..21081,碎片表是 +1000 同名。玩家存档里的碎片表按名字索引({string@0, u32 count@24},32 字节一条),62 个里只有 8 个计数非零,唯一那条装备记录的名字指向其中一个特定挂件。原生侧只存在一个数量入口(Lua 导出的 GetXxxPieceCount),它查不到就返回 0。 |
| 皮肤 | avatar 表 186 条,plant 表 375 条,而 186 条逐条满足 avatarId = plantId + 200 且名字一致,无孤儿无缺口。所以「默认穿上第一套皮肤」这条规则不是猜的。 |
| 顺带否证 | 另一套「新皮肤」注册表即使把范围拉到两百万也是 0 条,本渠道根本没下发这套配置。之前照着美术资源名推断出来的 _new_avatar_1..4 那条思路可以直接埋了。 |
6.3 两次免费纠错
否定结论:把装备查询函数强答成「已穿皮肤」,装机后界面毫无变化。翻它的调用点才知道 43 个调用者一律把这个返回值当条件使用(跟某个具体皮肤 id 比较,或者在配置结构的两个浮点字段之间二选一),从来没人拿它当美术索引。真正的门在上一层,是「这个账号有没有解锁这件皮肤」,而账号 186 件里只解锁了 2 件。推导全对,下嘴点错了。
守卫过严:枚举器的只读守卫里写的是「指针必须 8 字节对齐」,而按 4 字节步进打印原始 dword 的那条路径正好踩在这条规则上,于是四张表全部在第二行自我判定为不可读。守卫不许严于它的读者,这是字节级的事,不是感觉级的事。
7. 数据源原则
| 手法 | 游戏持有的状态 | 重启后 | 说明 |
|---|---|---|---|
| 重定向单点(5.1) | 无(不改共享位图) | 保持 | 31 个调用点只动 4 个 |
| 让比较恒真(5.2) | 无(不写存储) | 保持 | 游戏本来就有这个分支 |
| 改本地字段 | 旧值 | 失效(服务器重下发) | 钻石那条一次否证省下的时间比三条交付都多 |
| 改协议/配置 | 无 | 保持 | 每次解码都改 |
两条工程结论:
- 要跨重启保持,就必须写协议层或配置层,因为服务器每次下发的数据都会在解码出口被改写。
- 共享谓词只能逐点处理。调用者数量本身就是危险度量:31 个调用者共用的位图、71 个调用者共用的取管理器,一律用重定向单点解决。
8. 防御视角
服务端权威是唯一真边界。 上述改造都只影响本地显示与本地计算。任何服务端会重算的数值,客户端改完只是「看起来变了」。这直接解释了「为什么有些改造重启后失效、有些能持久」。
反射注册表是双刃剑。 它让 Lua 侧能动态访问任意字段,代价是给逆向者留下了一张完整的字段地图。注册形状固定(adrp/add + mov w3,#imm)的引擎,一次静态扫描就能拿到全表。
STORED 打包降低了替换门槛。 未压缩的 .so 让「只换一个文件、不重压整包」成为可能,也让字节级审计变得可行。
离线断言体系是成本杠杆。 装机 6 到 10 分钟一次,离线断言毫秒级。变异测试把「校验器全绿但它检查的东西不存在」这类失败提前暴露。
9. 判据清单
定位阶段:
- 先确认是不是 IL2CPP(有没有
global-metadata.dat),不是的话工具链完全不同 - 反射注册表的注册形状是固定的,一次静态扫描换一张字段全表
- 下否定结论之前先验前置条件。有一次把某个页面空白归因于「服务端只下发进度」,实际是测试角色根本没推进到那一步
- 排除法里只能用观察到的事实。某个共享谓词函数有 43 到 71 个调用者,这种数字意味着「不能整体强答」,不意味着「这条功能不走它」
写入阶段:
- 优先找游戏自己已经实现的状态。清屏道具的 3 个字比任何「新造的免费标志」都稳
- 共享谓词逐点重定向,不整体置位
- 中途 hook 假设标志寄存器不可用,LR 从 prologue 落盘处读
- 一次性计数器放
.bss
工具阶段:
- 判据只用未过滤的运行结果(
tail/sed会藏错误) SystemExit不是Exception- 全量 dump 前先
logcat -G 16M - Windows 下 adb 设
MSYS_NO_PATHCONV=1
交付阶段:
- 交付链要能回滚:每一级 stage 都有独立目录、README、固定 sha256 与再生脚本,
.so从不手改,只由脚本产出 - 控制组不许用厂商原版库(带暗桩)
附:日常命令
cd <project>/resign
python patch_lib_stage3.py && python verify_stage3.py # 只重建主库并验证 11 个字与 13 个变异
bash rebuild_stage3.sh # dex -> lib -> 重打包 -> 签名 -> 包内字节审计
bash rebuild_stage3.sh install # 再 push 约 2.3 GB 并安装
bash archive/dumper/rebuild_costume.sh again "<cmd>" # 让已装的枚举器再跑一次(push 一个命令文件,不装机)
bash archive/dumper/rebuild_costume.sh log # 抓枚举日志
关键标识(都可在构建里复现):stage1 66ac44fecb18959f、stage2 e50ba0440a4b07ac、stage3 f8218d6e90312022;主库 97,236,880 字节,.text 覆盖 0x200000 到 0x5600000,唯一的可执行空洞在 0x17a6864(1028 字节);厂商原版库哈希 7a99017132c1b78b(含两处暗桩,不可用作控制组)。