September 27, 2026
安全逆向工程AndroidNative

自研引擎渠道包 native 补丁全记录

由 LongCat-2.5-Preview with Deepseek-V4.1-Flash 生成。

测试环境:自有设备、自有账号、离线验证。全文隐去应用与厂商标识,函数偏移与哈希保留(技术内容,非身份信息)。姊妹篇是《Unity IL2CPP 移动端运行时改造技术报告》,两篇的差别本身就是信息:那次的地图是 dump.cs,这次没有地图。

0. 结论先行

功能手法字节调用面
经济货币自动拾取4 条 bl 重定向到 mov w0,#1; ret 小 stub4 + 231 个调用点共用 1 个 dword
一次性清屏道具不限量3 个 cmp w8,#1 改 cmp w8,w8,强制走游戏自己的 unlimited 分支34 / 1 / 1
免广告出口前 and w0,w9,w8 改 mov w0,#0;每日配额 b.le 改 nop2广告 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_*.shdex 到 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 全在包里,静态搜不到。三条证据:

  1. 注册表的构造函数只写虚表和空串,一条记录都不插。
  2. 111029 这类数字在整张镜像的立即数里 0 命中。
  3. 那九个 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)无(不写存储)保持游戏本来就有这个分支
改本地字段旧值失效(服务器重下发)钻石那条一次否证省下的时间比三条交付都多
改协议/配置无保持每次解码都改

两条工程结论:

  1. 要跨重启保持,就必须写协议层或配置层,因为服务器每次下发的数据都会在解码出口被改写。
  2. 共享谓词只能逐点处理。调用者数量本身就是危险度量:31 个调用者共用的位图、71 个调用者共用的取管理器,一律用重定向单点解决。

8. 防御视角

服务端权威是唯一真边界。 上述改造都只影响本地显示与本地计算。任何服务端会重算的数值,客户端改完只是「看起来变了」。这直接解释了「为什么有些改造重启后失效、有些能持久」。

反射注册表是双刃剑。 它让 Lua 侧能动态访问任意字段,代价是给逆向者留下了一张完整的字段地图。注册形状固定(adrp/add + mov w3,#imm)的引擎,一次静态扫描就能拿到全表。

STORED 打包降低了替换门槛。 未压缩的 .so 让「只换一个文件、不重压整包」成为可能,也让字节级审计变得可行。

离线断言体系是成本杠杆。 装机 6 到 10 分钟一次,离线断言毫秒级。变异测试把「校验器全绿但它检查的东西不存在」这类失败提前暴露。

9. 判据清单

定位阶段:

  1. 先确认是不是 IL2CPP(有没有 global-metadata.dat),不是的话工具链完全不同
  2. 反射注册表的注册形状是固定的,一次静态扫描换一张字段全表
  3. 下否定结论之前先验前置条件。有一次把某个页面空白归因于「服务端只下发进度」,实际是测试角色根本没推进到那一步
  4. 排除法里只能用观察到的事实。某个共享谓词函数有 43 到 71 个调用者,这种数字意味着「不能整体强答」,不意味着「这条功能不走它」

写入阶段:

  1. 优先找游戏自己已经实现的状态。清屏道具的 3 个字比任何「新造的免费标志」都稳
  2. 共享谓词逐点重定向,不整体置位
  3. 中途 hook 假设标志寄存器不可用,LR 从 prologue 落盘处读
  4. 一次性计数器放 .bss

工具阶段:

  1. 判据只用未过滤的运行结果(tail/sed 会藏错误)
  2. SystemExit 不是 Exception
  3. 全量 dump 前先 logcat -G 16M
  4. Windows 下 adb 设 MSYS_NO_PATHCONV=1

交付阶段:

  1. 交付链要能回滚:每一级 stage 都有独立目录、README、固定 sha256 与再生脚本,.so 从不手改,只由脚本产出
  2. 控制组不许用厂商原版库(带暗桩)

附:日常命令

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(含两处暗桩,不可用作控制组)。