加固一:libmsaoaidsec.so
加固二:libZzprotect.so
环境:Pixel 5 / Android 11 / root + frida-server
包名:WTI5dExuaDBkR011YzNrPQ==
该APP的反调试和自毁分三层。第一层 libmsaoaidsec.so 用自己的监控线程和 shellcode 退出发生器;第二层 FairGuard(以 libZzprotect.so 为入口)把 exit_group 桩注入每个受保护业务库;第三层 FairGuard 在运行期循环改写 libcocos2djs.so 的代码页,把合法指令异或成非法指令。
前两层用 15 个 Interceptor.replace 拆掉退出路径,600s 实战稳定。第三层不经过库里的退出桩,Hook 拦不住,改为把目标代码页锁成只读:写者的 store 变成可恢复的 SIGSEGV,漏网的坏字用 dump 下来的干净镜像恢复。
全文分四部分:第一部分分析 libmsaoaidsec.so 的线程、检测点、退出路径和 10 个 Hook;第二部分分析 FairGuard 的 dump / 修复、svc 桩和 5 个 Hook;第三部分分析代码页加固的范围、算法和页级写陷阱的实现;第四部分总结三层的组合、验证和方法。附录 A 是 OLLVM 控制流还原,附录 B 是全部运行时脚本。
进程在第二个线程创建后就退出了。msa 的检测线程在库初始化阶段就已经启动,不会等到 UI 就绪。
0x1AAA4 线程入口

0x191B0 线程入口

通过 pthread_create 交叉引用还能找到另外两个线程入口:

0x19200 线程入口

0x1885C 线程入口

pthread_create 传入的入口是 0x1AAA4,跟进后发现 IDA 没有在这里切函数,它被并进了 sub_0_1A7C0 的函数视图。所以从 0x1A7C0 往下读,依次看到 maps 扫描(0x1A7C0~0x1AA94)、0x1AAA4 处的字符串解密、以及尾部的 4 秒循环。
0x1AA94 的 ret 是未命中返回,0x1AA98 是命中后的 _exit(0);4 秒循环里 0x1B440 bl 0x1A7C0 每次重新回到这个入口。

sub_0_1A7C0流程:
strstr(s, needle_) 里的 needle_ 是 /data/local/tmp,只用于路径过滤;真正的 Frida 特征在 sub_0_16CE4。

密钥在 0x2AA1C,按 4 字节步长取前 3 字节:
解密结果

线程入口先把上面的字符串全部解密,然后进入循环:
gum-js-loop、gmain、linjector 在 0x1AAA4 中解密,供同循环的 fd 扫描(0x1A500 / 0x1A6AC)使用。

pthread_create 传入的入口是 0x191B0,跟进后发现 IDA 没有在这里切函数,它被并进了 sub_0_1900C 的函数视图。所以从 0x1900C 往下读,依次看到 task 状态扫描(0x1900C~0x191A0)、命中后置返回值 0x309(0x191A4)、以及 0x191B0 起的线程轮询循环。
0x191A0 的 ret 是未命中返回,0x191A4 是命中后置 w0 = 0x309(777);0x191B0 线程入口在 0x191CC 轮询这个返回值。

线程入口在 0x191CC 处循环:

解密脚本如下:


sub_0_133D8 包装调用的真正初始化函数是 sub_0_21B44。

解密脚本如下:
解密结果:
按 ARM64 指令集反汇编:

这一段被 OLLVM 控制流平坦化处理过,状态变量在 W24,立即数由 W8 装入。还原分三步:
完整的提取脚本、18 组映射表和 patch 脚本放在附录 A。还原后的主流程如下:
本节分析启动器 0x1B458 与 PrettyMethod 检查;fork + ptrace 和延迟环境检查分别见 5.1 节、5.2 节。
PrettyMethod 是 Android 运行时(ART)里的一个内部函数,全称是 art::ArtMethod::PrettyMethod。它的作用是把一个代表 Java 方法的 ArtMethod 对象,转换成人类可读的字符串(比如 java.lang.String.toString)。Frida 的 Java 层 Hook 会顺带 Hook PrettyMethod,用来绕过 ART 的一个 Bug(避免 Native 线程抛异常时崩溃),这个行为正好可以用来判断 Frida 是否在运行。

sub_0_21D98 通过比对 PrettyMethod 函数开头 4 字节是否为 LDR X16/X17, #...(Frida Hook 的标准桩)来检测 Frida。0x58005010 / 0x58005011 就是 Frida Hook 后留下的机器码。


线程创建于 0x1A358,x3 = sp + 0x24,传入的是父线程栈上的地址;线程入口先 sleep(6),6 秒后再做检查。该函数的控制流同样被平坦化,状态变量为 W9,常量由 W8 装入,分发器位于 0x19294。
还原规则与第一部分第 4 节相同:收集 W8 常量,匹配 CMP W9, W8 及其后的 B.EQ,排除跳回 0x19294 的边,共提取 31 条映射(脚本见附录 A.2.1)。对应字符串使用同一套 key [0x99, 0xA7, 0xA9] 做 XOR,解密脚本见附录 A.2.2。
解密结果:
第二个立即数应只取低 5 字节;多取会把上一段的 FE C8 拼入,解出 u:r:zygoWQzU 这类错误结果。
检查流程:
/proc/%d/attr/prev 是 Linux 内核提供的伪文件接口,用来读取进程在上一次 execve() 之前使用的安全上下文。
在 Pixel 5 上,普通进程的 attr/prev 并不是 zygote:
sub_0_1A7C0 里命中后不返回,直接:
sub_0_21B44:
同库还有第二个生成器 0x22048(监控循环里 0x1B448 bl 0x22048 调用):0x22260 处同样把模板首字加 0xBC0 得到 mov x8, #94,命中后同样是 exit_group(0)。
只有 maps 分支走 libc 的 _exit@plt,其余路径最终都落在 0x133D8 / 0x21B44 / 0x22048 的 shellcode exit_group 上。只 hook exit / _exit 拦不住,补丁要落在检测函数、线程入口或这些退出发生器上。
第 6 节把命中后的退出路径收敛成三个公共出口,所以补丁不必覆盖每一个检测函数:只要让“命中 → 退出”的每条路径都失效,检测继续运行也杀不掉进程。msa 层实际落地的是 10 个 Interceptor.replace;FairGuard 层的 5 个桩见第二部分。
按出口重新归类第 6 节的总表:
除出口外,还要停两个监控循环:0x1AAA4(maps / fd,4 秒一轮)和 0x191B0(task / TracerPid,2 秒一轮)。Interceptor.replace 只影响调用发生之后的执行:打补丁时线程可能已经在循环体内,甚至已经判定命中、正走向退出。所以除了线程入口,循环体里会读检测结果或直接退出的函数也要替换,否则当前这一轮拦不住。
0x80C0 / 0x7F10 两个 PLT 桩不在补丁清单里,也不需要处理:maps 分支的判定和退出都在 0x1A7C0 函数体内,替换入口后不会执行到 0x1AA9C 的 _exit@plt;反过来,给 PLT 桩下补丁还会影响进程内其它调用方。
替换统一写成:
入口字节在 replace 后立即读回可能仍是原始值,判断是否生效要看之后的读回或行为,不能只看 REPLACED 那一行。最终补丁的关键片段见附录 B.4,首轮 12 点版本见附录 B.1。
msa 层 10 个点覆盖之后,前面各节分析过的检测入口都还在跑,但它们的退出都落在已替换的函数上:
所以 0x16CE4、0x21D98、0x19200、0x18E74、0x1B458 都不在替换清单里。判定继续命中没有关系,命中之后无路可退。
0x1A500 / 0x1A6AC / 0x1A7C0 / 0x18B78 / 0x1900C 是循环体里的扫描/判定,替换它们是为了让线程不再走向退出分支,并兜住“补丁生效时已经进入本轮循环”的竞态,不是因为它们是公共出口。
补完 msa 后进程仍然会退出,说明还有未覆盖的检测点。可以用 Process.setExceptionHandler 捕获异常抛出位置来定位,诊断片段见附录 B.2。
壳的检测是多线程并发的,每次运行哪个线程先命中、先执行退出并不固定,单次打印不一定能说明最终检测点。测试时用 attach 模式,等游戏 UI 线程加载完再开始 patch。
libZzprotect.so 的静态文件混淆很重,直接分析价值不大,所以改为 dump 运行时镜像。

dump 通过 adb 完成,脚本如下。

用 SoFix 修复后,模块名显示为 libFairGuard.so。

在修复后的镜像里搜 8 字节:
反汇编是:
arm64 上 x8 = 0x5e 是 exit_group,x8 = 0x5d 是 exit。这里没有走 libc 的 exit/_exit,而是直接发 svc,所以只 hook libc 的退出函数拦不住。
修复后的镜像里只有两处:
对 msa 和 libZzprotect 的这些地址都做了插桩,进程仍会退出,说明还有未覆盖的检测。于是换个思路,直接从 svc 字节特征出发做全量扫描,结果如下。

扫描片段见附录 B.3。
经过上面分析完,实际FairGuard 把退出桩注入到每个受保护业务库中。
libZzprotect.so 内部确实只有 0x345C00 / 0x417100,但 FairGuard 在另外三个受保护库各放了一个相同 needle 的静态桩。把全部可执行映射扫一遍,结果如下:
业务库名做了替换,偏移是原样本的实测值。
四个静态桩都不在库的普通 .text 起点,而是固定在运行时 r-xp 段起始 + 0x1C100:
FairGuard 在每个受保护库尾部保留一段未加密的 r-xp,退出桩放在段首 + 0x1C100,所以四个库可以按同一结构一次定位。libZzprotect.so+0x345C00 是另一类:位于 rwxp 解密段,文件里该偏移全 0,进程跑起来后才生成,结构检查找不到,必须全量扫描。
扫描只搜一个特征 c8 0b 80 d2 01 00 00 d4 就够覆盖这套壳的全部退出桩。全量结果里以下命中不要打:
写入方式和 msa 层一致,都是 Interceptor.replace 成 NativeCallback(() => ptr(0))。区别是这 5 个地址替换前要先校验 8 字节 needle:静态桩在文件里就是 c8 0b 80 d2 01 00 00 d4;0x345C00 在文件里是 0,只有运行时才会出现,所以必须在进程内按 maps 定位后再校验。
这一层最容易出错的是打桩时机:要等游戏 UI 就绪(主 Activity resumed)后再替换。更早落在 bundle 解密窗口里改桩,GLThread 会执行到未解密的数据,表现为 SIGILL / SIGSEGV,与 bypass 是否生效无关。最终补丁的关键片段见附录 B.4。
上面解决了游戏 UI 加载界面杀死进程的检测,但游戏数据加载开始后,进程还是会崩。
游戏集成了 libxcrash.so,崩溃时会在 APP 私有目录 /data/data/<包名>/cache/ 下留下 tombstone_*__<包名>.native.hitbug 日志。
日志里的信号是 SIGILL,code 1 (ILL_ILLOPC),pc = 0x6e0c2af588,减掉基址 0x6e0ba92000 得到 cocos + 0x81D588,落在 libcocos2djs.so 的 r-xp 段。

日志里 *pc=0xb9000260,反查 clean 镜像 0x81D588 的 4 字节是 60 02 00 b9,也就是 0xb9000260,一条合法的 32 位 store 指令。内存读回合法指令、执行时却报 ILL_ILLOPC,说明 I-cache 里的字和内存里的字不一致——有线程在运行期反复改写这 4 字节,而且没有做 I-cache 维护。
xcrash 会 dump 全部线程。pc 落在 Zz 段内的是 Thread-22(tid 22964) 和 Thread-5(tid 22965):


两个线程的 pc 都是 0x6e1f44a004(Zz+0x347004),x8=0x65(101,nanosleep 的系统调用号),x2 都是 Zz+0x347000——它们停在同一个 sleep 包装里,blr x2 的目标就是它。各自的返回地址是:
顺着返回地址往前看,是两个结构相同的改写循环。第一个在 Zz+0x3b4a8:
第二个循环在 Zz+0x27fca0,返回地址 +0x27fd2c 前一条 +0x27fd28 同样是 blr x2,之后是窗口自增;写入点在 +0x27fcf8。它的扫描上界没有放在通用寄存器里,而是用 fmov x0,d8 从 d8 取。
两个循环的寄存器和值对照:
每个 64KB 窗口里,下标满足 i % 0x197 == 0x27 的 qword 被异或,约 20 个;睡 1 秒左右,窗口加 0x10000,从 0x818800 一直扫到 0x1B98440。key 每次用 keybase - ctx->counter 现算,同一个字被改两次不一定恢复原值,不能指望它自己改回来。
崩溃点 cocos+0x81D588 代入循环 1 的窗口和取模:
余数是 0x27,和两个线程寄存器里的选中槽位一致。崩溃点可以确定由这两个循环写入造成。
llvm-readelf -l libcocos2djs.so:
运行期第一个 LOAD 被壳映射成 rwx,所以 maps 里 off=0 的第一段起点就是 base。上界取 GNU_RELRO 的起始 0x1fe4e60,按页对齐是 0x1fe4000;再往后是 RELRO 和 RW 段,属于数据区,不锁。第 0 页是 ELF 头所在页,跳过。
GKI 内核页大小是 4KB,mprotect 的粒度就是 4KB,ELF 的 p_align=0x4000 不影响实际分页。锁的范围:
长度 0x1fe3000,约 31.9MB,8163 页。与加固范围对照:
两段都在锁区间内。
编写一个 so,启动后做四件事:
之后壳每次往代码页写,store 都会触发 SEGV_ACCERR;handler 里把 pc 加 4,丢掉这次写,进程继续跑。
必须先修一遍。注入发生在壳跑起来之后,目标页里已经有被 XOR 过的字;不先修回来就直接锁,锁住的还是坏字,游戏执行到就是 SIGILL。clean 镜像来自运行期 dump 的 libcocos2djs.so,长度 0x2235000(35,868,672 字节),覆盖整个锁区间,放到 /data/local/tmp/cocos_clean.bin。dump 时壳已经把所有页面解密完成,这份镜像就是“没有被改写”的基准。
不提前到壳启动前解决的原因:更早 attach 会落进 msa 的检测窗口,进程在 UI 之前就干净退出(见第一部分);静态改 APK 不在本文范围。
第二行和壳无关:xcrash 在崩溃回调线程里 dump V8 栈,没有 isolate 时 0x11FA758 会读 [x1+0x2c60],x1=0 直接空指针崩溃。日志里会看到 pc = base+0x11FA778、fault addr 0x2c60,看起来像保护失效,实际是 xcrash 自己的二次崩溃,跳过即可。
第三行是给 sweep 和上锁之间那几十毫秒的 RWX 窗口兜底:漏网的毒字执行时是 SIGILL。日志里第一现场的内存可能已经被写者改回干净值,但 I-cache 里仍是坏的,所以不管内存是否一致都要刷 I-cache 再重试;同一地址连续 8 次还失败才交还原 handler,避免死循环。
用 NDK r26 编译:
push 到 /data/local/tmp/libcodeguard.so。SELinux 下不能直接 dlopen 这个路径,boot2.js 用 memfd_create 把文件读进匿名内存,再 android_dlopen_ext 加载,按 /memfd:codeguard 在 maps 里找基址,按 llvm-readelf --dyn-syms 读出的偏移调导出:
guard_start 的四个参数是 base、lo、hi 和 clean 镜像路径:
启动顺序是先正常启动游戏、等 UI 就绪,再 attach 加载两个脚本:quick.js 处理前面的 15 个退出桩,boot2.js 装页保护。
前两层的出口都是 svc,用 Interceptor.replace 截断;第三层不经过任何退出桩,只能按页接管写权限。
前两层 600s 实战:UI 就绪后 attach,15/15 替换成功,心跳到 598s,线程 89~106,HOOKS 每轮 15/15,没有 exit_group 发生;detach 后 replace 自动撤销,游戏继续运行,重新挂载需要重跑脚本。
第三层的验收指标:注入日志 swept>0,denied 持续增长,foreign=0,不再出现 SIGILL 崩溃和干净退出。
筛选规则:
运行结果:
实际只 patch 到 11 条,因此遍历函数内所有“操作数 0 为 W24”的指令,检查遗漏的分支。
遍历输出:
0x19200 的状态变量为 W9,常量由 W8 装入,分发器在 0x19294;提取规则与 A.1.1 相同。
输出 kept 31 / ...,保留的 31 条即 state → target 映射。
密钥与第一部分 2.2 节相同,为 [0x99, 0xA7, 0xA9]。脚本按明文的实际长度取数,不拼接 0x00 结尾;否则该字节会参与异或,在输出末尾多出一个字节。
输出:
正文的 15 个 Hook 是分阶段收敛的。下表按调试顺序列出七份脚本 / 文件;正文只保留每个阶段的关键片段,完整脚本不随文发布。
这一版在 msa 内替换 12 个点,并对两个 PLT 桩写 ret。与最终版的差异:
每个点都是同一种替换,区别只在偏移。替换逻辑(示意):
脚本不写内存:定位 libmsaoaidsec.so 与 libZzprotect.so 的基址,打印各目标地址前 16 字节,并安装 Process.setExceptionHandler 记录异常类型、地址和回溯。findModule 直接用 r.s - r.off,只用于观察,不能替代基于 PT_LOAD 的严格基址计算。回溯部分(示意):
扫描分两层:先按 r-xp 段起始 + 0x1C100 做结构位检查,再对 r-x / rwx / --x 全量分块扫描。HIT 每地址只报一次,输出库名 + 偏移或 anon@地址;HB 每 5 秒给出线程数和命中数。正文图 22 对应这一阶段的扫描结果。扫描主体(示意):
最终版先按 needle 校验并替换 FairGuard 的 5 个桩,再替换 msa 的 10 个点。REPLACED 行里的 before->after 只作参考,是否生效以周期性的 HOOKS 读回为准。补丁清单就是第四部分第 1 节的 15 个点,替换动作只有一种(示意):
替代 B.4 的 Interceptor.replace,直接用 Memory.patchCode 把 15 个点写成 mov w0,#0; ret;FairGuard 的 5 个桩先校验 needle 再写。和 B.7 用同一条 frida 命令加载。核心部分(示意):
编译与推送:
guard_start 等导出的偏移从 llvm-readelf --dyn-syms libcodeguard.so 读,换 NDK 或改源码后要重新确认;B.7 里的偏移是当前产物的实测值。
handler 的分支就是第三部分 5.2 节那张表。代码骨架如下,省略日志、错误处理和上锁线程:
memfd 加载、调 guard_start、上报计数。guard_start 的三个地址参数是 base、lo、hi;类型用 'pointer',Frida 的 'uint64' 只接受 UInt64 对象。加载骨架(示意):
'use strict';
const TARGET_SO = 'libmsaoaidsec.so';
function hook_pthread_create() {
const pthread_create = Module.findExportByName('libc.so', 'pthread_create');
if (pthread_create === null) {
console.log('[-] pthread_create not found');
return;
}
console.log('[+] pthread_create @ ' + pthread_create);
Interceptor.attach(pthread_create, {
onEnter(args) {
const fn = args[2];
if (fn.isNull()) return;
const mod = Process.findModuleByAddress(fn);
if (mod === null || !mod.name.includes(TARGET_SO)) return;
console.log('\n[pthread_create]');
console.log(' module : ' + mod.name);
console.log(' base : ' + mod.base);
console.log(' entry : ' + fn);
console.log(' offset : 0x' + fn.sub(mod.base).toString(16));
console.log(' arg : ' + args[3]);
}
});
}
setImmediate(hook_pthread_create);
[pthread_create]
module : libmsaoaidsec.so
base : 0x71d3201000
entry : 0x71d321baa4
offset : 0x1aaa4
arg : 0x71e1d05be4
[pthread_create]
module : libmsaoaidsec.so
base : 0x71d3201000
entry : 0x71d321a1b0
offset : 0x191b0
arg : 0x0
Process terminated
0x151B0 init_proc(DT_INIT)
└─ 0x1A460
└─ 0x1A0FC(状态机,线程创建都在这里)
├─ 0x1A3CC bl 0x1B458 ; 同步检查入口
├─ 0x1A3B8 pthread_create → 0x191B0
├─ 0x1A358 pthread_create → 0x19200 ; 延迟 6 秒线程
└─ 0x1A414 bl 0x18E74 ; fork + ptrace
KEY = 0x99, 0xA7, 0xA9
算法:plain[i] = enc[i] ^ KEY[i % 3]
import ida_bytes, idaapi
B = idaapi.get_imagebase()
K = [ida_bytes.get_byte(B + 0x2AA1C + i * 4) for i in range(3)]
def dec(a, n):
r = (ida_bytes.get_bytes(B + a, n) or b'').split(b'\x00', 1)[0]
return bytes(x ^ K[i % 3] for i, x in enumerate(r))
for a, n in [(0x3901C,16),(0x3902C,26),(0x39046,12),(0x39052,6),
(0x39066,10),(0x39070,16),(0x39080,16),(0x39090,11),(0x3909B,12)]:
print(hex(a), dec(a, n))
0x1B438 bl 0x1A500 ; fd_scan
0x1B43C bl 0x1A6AC ; fd_link_scan
0x1B440 bl 0x1A7C0 ; maps_scan
0x1B448 bl 0x22048 ; agg_genC
0x1B44C mov w0, #4
0x1B450 bl 0x7C00 ; sleep(4) (0x7C00 = sleep@plt)
0x1B454 b 0x1B438
0x191CC mov w0, w19
0x191D0 bl 0x7790 ; usleep(2 秒)
0x191D4 bl 0x18B78 ; tracerpid
0x191D8 cmn w0, #1
0x191DC b.eq 0x191F8
0x191E0 cbz w0, 0x191EC
0x191E4 bl 0x18884 ; 二次检查
0x191E8 tbz w0, #0, 0x191F8
0x191EC bl 0x1900C ; task_scan
0x191F0 cmp w0, #0x309 ; 777
0x191F4 b.ne 0x191CC ; 未命中 → 继续轮询
0x191F8 bl 0x133D8 ; 退出
import ida_bytes, idaapi
B = idaapi.get_imagebase()
K = [ida_bytes.get_byte(B + 0x2A984 + i*4) for i in range(3)]
print("KEY:", [hex(k) for k in K])
def d(x): return bytes(b ^ K[i%3] for i,b in enumerate(x))
# v32: /proc/%d/status
print("v32 :", d(ida_bytes.get_bytes(B+0x2A853, 16)).split(b'\0')[0])
# needle_: TracerPid
c = bytes([0xCD,0xD5,0xC8,0xFA,0xC2,0xDB,0xC9,0xCE,0xCD,0xA3,0])
print("needle_:", d(c).split(b'\0')[0])