Unidbg学习笔记(十一):初始化问题
环境补得完美无缺、函数调用也没有报错,但返回值是空的、或者是错的 —— 这几乎必然是初始化问题。这是从初级到中级的分界线:能稳定地排查并解决初始化问题,你才算真正进入了 Unidbg 的“中段使用阶段”。
上一篇把你留在了哪里
前面四篇我们把“补环境”这件事翻来覆去讲了一遍:
- 第七篇:JNI 层 — 让 Java 反射调用不再返回 null
- 第八篇:文件系统层 — 让 SO 拿到一个干净的 Linux
- 第九篇:系统调用层 — 给那些走野路径的 SO 兜底
- 第十篇:库函数层 — 在 libc 这一层做最优雅的拦截
读到这里你大概会觉得:“工具我都有了,环境我也补全了,剩下的就是把函数调出来。”
然后你就会遇到这本系列书里第一个真正难受的问题:
函数能调出来,没报错,但返回值是
null。
或者更阴险一点:
返回值看起来“像那么回事”,但和真机对一下不一样。
这就是初始化问题。它不是补环境的延伸,而是补环境之外的另一个世界。
一个让人崩溃的小故事
先来一段我自己踩过的坑。有一次我在分析某 App 的加密 SO,目标函数是 Java_com_xxx_Sec_encrypt(env, thiz, byte[] input),应该返回加密后的字节数组。
我做完了所有该做的事:
- JNI 补全 —
findClass/getMethodID/callXXXMethod都不报错 - 文件系统补全 —
/proc/self/maps假数据已经返回 - 系统调用兜底 — 没有
unsupported syscall警告 - 库函数补全 —
__system_property_get都返回了真机值
跑下来,函数返回了 null。
没有报错。emulator 没有抛任何异常。Unidbg 老老实实地告诉我:“你的函数执行完了,返回值是 null。”
我一开始以为是某个 JNI 字段没补全,检查一遍,没有。然后怀疑是 byte[] 编码问题,检查一遍也没有。然后我开始怀疑人生。
后来怎么解决的?我把 SO 在 Frida 里附加上去,先调用了一次 Java_com_xxx_Sec_init,然后调用 encrypt,发现可以了。
进一步看 init 里在做什么 —— 它在校验签名,签名通过后设置一个全局变量 g_initialized = 1。encrypt 进来第一步就检查这个变量,不为 1 直接 return null。
这就是初始化问题。 没有任何报错,唯一的症状是返回值不对。
为什么会有初始化问题
回到上面的故事 —— encrypt 返回 null 的真正原因是什么?
Java 层本该调的 init 没有被调。
真机上 App 启动时,System.loadLibrary 之后,Java 代码会接着调一串 native 函数(init / prepare / setupKeys / ...),把全局状态准备好。然后业务函数才能跑。
Unidbg 不执行 DEX,所以这一串由 Java 主动调的 native 函数永远不会自动发生。你必须手动模拟。
Unidbg 模拟的是”被加载进 ART 进程后的 SO 视角”,它不模拟 ART 自己。
这就是初始化问题的根源 —— 一句话能说清,但排起来是地狱。
别和 JNI_OnLoad 混为一谈
很多人会问:”JNI_OnLoad 不是会自动执行吗?”
这里得把真机和 Unidbg 分开看:
- 真机上,JNI_OnLoad 是 linker 加载 SO 时自动触发的回调
- Unidbg 里,JNI_OnLoad 不会自动执行 —— 你必须在
loadLibrary之后显式调dm.callJNI_OnLoad(emulator)(注意方法名带下划线)。如果你拿不到DalvikModule实例,等价的低层写法是module.findSymbolByName("JNI_OnLoad").call(emulator, vm.getJavaVM(), null)——前者就是后者的封装
即便 JNI_OnLoad 跑过,它也只是 SO 自己的”装载完毕通知”,和”业务 init”(Java 代码主动调的那一串 native)依旧是两回事。
被”真机上 JNI_OnLoad 会自动执行”的直觉误导、以为 SO 在 Unidbg 里也已经就绪 —— 这是初始化问题 debug 的首要死因。
loadLibrary 第二个参数到底控制什么
容易和 JNI_OnLoad 混起来的是 vm.loadLibrary("xxx", true) 的第二个 boolean。源码里叫 forceCallInit(BaseVM.java:314)—— 它控制的不是 JNI_OnLoad,是 ELF 的 .init_array / DT_INIT,也就是 SO 里所有 __attribute__((constructor)) 函数和全局对象的构造器。
绝大多数时候保持 true 就对了。SO 常在构造器里做 RegisterNatives、设置全局变量、启动内部子系统 —— 跳过的话后续 JNI_OnLoad 乃至业务调用都可能直接挂掉。
什么时候要关掉?只有一种情况:构造器本身在 Unidbg 里过不去。典型场景:
- 构造器启动了持续运行的后台线程(心跳 / 上报 / 反调试),Unidbg 单线程模型直接卡死
- 构造器做了 Unidbg 环境下失败的检测(读
/proc/self/status的TracerPid、pthread_create失败等),抛异常退出
这两种情况的标准姿势是”先关掉构造器、补环境、再手动触发”:
DalvikModule dm = vm.loadLibrary("xxx", false); // forceCallInit=false, 跳过 .init_array
// 在这里补上构造器会用到的环境(syscall 拦截、反调试绕过等)
dm.callJNI_OnLoad(emulator); // 显式触发 JNI_OnLoad (方法名带下划线)
代码里的两件事要分清:
loadLibrary(x, false)—— 延后.init_array/ 构造器dm.callJNI_OnLoad(emulator)—— 显式触发 JNI_OnLoad。无论forceCallInit传什么,这一行都是必要的 —— JNI_OnLoad 在 Unidbg 里从来不是自动跑的
这是”暂停 - 补环境 - 继续”的流程控制。它只影响 SO 自己的构造器和 OnLoad —— 解决不了 Java 层主动调的那一串 init,那才是本篇要讲的主题。
四步定位法
下面是我个人沉淀的工作流程。每一步都有明确的输入和输出,按顺序执行。
代码约定:下面几段 JS / Java 代码以意图示意为主 —— 像
callViaJava/callTarget/decodeArgs这类函数名是占位符,实际实现随样本而异(类名、签名、参数类型都得按你手头的样本补)。另外每一步开头我会标注 @Frida 或 @Unidbg,表示这一步要在哪个环境里做 —— 新手最容易搞混工位。
第一步:@Frida — 直接 Call 目标函数
目的:确认在“完整运行环境”下,目标函数的正确行为是什么。
为什么必须做这一步:你需要一个“标准答案”。否则你在 Unidbg 里得到的结果是对是错,永远没法判断。
// Frida 脚本
Java.perform(function() {
var Sec = Java.use("com.xxx.Sec");
var inst = Sec.$new();
var input = [0x01, 0x02, 0x03];
var output = inst.encrypt(input);
console.log("standard answer:", output);
});
如果这一步在 Frida 里都得不到正确结果 —— 那说明你 Frida 的调用姿势就不对,没必要继续 Unidbg。先把 Frida 里调通。
第二步:@Frida — JNI_OnLoad 之后立即 Call,确认是否是初始化问题
目的:区分“环境补全没做好”和“初始化没跑”。
在 Frida 里 Hook android_dlopen_ext + JNI_OnLoad,在 JNI_OnLoad 返回之后立刻直接调用目标函数(此时 Java 层的 init 还没机会跑):
Java.perform(function() {
var dlopen = Module.findExportByName(null, "android_dlopen_ext");
Interceptor.attach(dlopen, {
onLeave: function(retval) {
// 等到 libxxx.so 加载完
var libxxx = Process.findModuleByName("libxxx.so");
if (libxxx) {
// 立刻 Call 目标函数, 不让 Java 层的 init 有机会运行
var Sec = Java.use("com.xxx.Sec");
var output = Sec.encrypt([0x01, 0x02, 0x03]);
console.log("naked call:", output);
}
}
});
});
- 如果这次返回结果和第一步一致 → 没有初始化依赖,你的问题在别处(回去检查 JNI/IOResolver/syscall)
- 如果返回 null 或不一致 → 确定存在初始化依赖,进入第三步
第三步:@Frida — 逐个 Call 所有导出函数,找出关键 init
目的:找出“使得目标函数恢复正常”的那一个(或那一组)函数。
策略是把 SO 的所有导出函数都列出来,然后一个一个调用,每调一次就尝试调一次目标函数,看返回值有没有变化:
Java.perform(function() {
var libxxx = Process.findModuleByName("libxxx.so");
var exports = libxxx.enumerateExports();
exports.forEach(function(exp) {
if (exp.name.indexOf("Java_") == 0 && exp.name != targetName) {
try {
// 找到对应的 Java 方法名
callViaJava(exp.name);
// 然后试调目标函数
var result = callTarget();
if (result != null) {
console.log("KEY init found:", exp.name);
}
} catch (e) {}
}
});
});
实战中你会发现:
- 90% 的情况只有 1-2 个关键 init
- 10% 的情况是一条链(需要按顺序调多个)
- 极少数情况是参数敏感(同一函数,参数不同效果不同)
这一步是初始化定位的核心。它把“理论分析”换成了“经验定位”,可以稳定复现。
第四步:@IDA + @Unidbg — 静态分析确认逻辑,在 Unidbg 中按序调用
第三步的结果是“经验性的”,你还需要确认它在做什么 —— 否则你不知道这个 init 在 Unidbg 里能不能正确执行(它可能依赖某些第三步没注意到的环境)。
打开 IDA / Ghidra,看那几个 init 函数在做什么:
- 是不是在调
JNIEnv->FindClass缓存类对象? - 是不是在调
__system_property_get做指纹? - 是不是在打开某个文件读密钥?
把这些依赖事先在 Unidbg 里补好,然后:
// 在 Unidbg 主程序里
DvmClass cSec = vm.resolveClass("com/xxx/Sec");
DvmObject<?> sec = cSec.newObject(null); // null = 只造空壳, 不走构造器. 见下一小节
// 1. 先按静态分析得到的顺序调初始化(可能不止一个)
// callJniMethod 用于 void 返回; 有返回值用 callJniMethodObject / callJniMethodInt 等
sec.callJniMethod(emulator, "init");
sec.callJniMethod(emulator, "setupKeys");
// 2. 再调业务函数 (有返回值, 所以用 callJniMethodObject)
DvmObject<?> result = sec.callJniMethodObject(emulator,
"encrypt", new ByteArray(vm, new byte[]{1, 2, 3}));
到这一步,你应该能拿到和 Frida 一致的结果了。
newObject(null) 和 Frida $new() 的隐性断层
这一段代码里最容易被忽视的细节是 cSec.newObject(null)。
Frida 里的 Sec.$new() 会真正走一遍 Java 构造器 —— 如果这个类在 Java 层写成这样:
public class Sec {
static { nativeRegisterCallbacks(); } // 静态块里调 native
public Sec() { init(); } // 构造器里也调 native
}
那么 $new() 返回之前,nativeRegisterCallbacks 和 init 就已经被调用过了。在 Frida 里你根本感知不到它们的存在,初始化“自动”就发生了。
Unidbg 的 newObject(null) 不是这样 —— 它只在 DalvikVM 的类表里造一个“这个类存在、类型是 Sec”的壳子,不执行任何 Java 字节码。构造器里的那行 init()、静态块里的那行 nativeRegisterCallbacks() —— 对它来说等于不存在。
所以在 Frida 里看起来完全自动的一次初始化,到 Unidbg 里必须你自己手动补上对应的 callJniMethod。这是最隐蔽的一类初始化依赖 —— 第二步的“立即 Call”能暴露它,但新手往往意识不到为什么 Frida 里跑得好好的,换到 Unidbg 就不行。
记这一条的价值在于:看到 Java 构造器/静态块里调了 native 方法,就要把这几个方法补到 Unidbg 的 init 列表里,不要依赖 newObject 替你做。
带参数的 init 怎么调
如果静态分析发现 init 函数的签名不是无参,而是 init(Context ctx, String mode),就不能再用最简形式的 callJniMethod 了,参数要当作 DVM 对象塞进去:
// 参数 1:Context — 在大多数场景下只是个占位符,不需要是"真的" Context
DvmObject<?> ctx = vm.resolveClass("android/content/Context").newObject(null);
// 参数 2:字符串 — 用 StringObject 包装
StringObject mode = new StringObject(vm, "PROD");
// 按签名顺序调用
// 前提: init 没有同名重载 (或 SO 通过 RegisterNatives 注册了 init,
// 此时 unidbg 优先查 nativesMap 裸名查表, 不走符号 mangle —— 多数现代样本走这条).
// 如果同时存在 init() 和 init(Context, String) 且未走 RegisterNatives,
// 裸名 "init" 只能命中无参版本 (符号 Java_<class>_init),
// 此时改用 module.callFunction(emulator, "Java_<class>_init__Landroid_content_Context_2Ljava_lang_String_2", ...) 拿全限定符号
sec.callJniMethod(emulator, "init", ctx, mode);
这里有两个要点:
- 占位符思路:很多时候 init 只是把 Context 存起来,不会真的对它做
GetObjectClass + CallObjectMethod一整套反射调用。空壳就够用了。如果 init 里真的要用 Context 的方法(比如getPackageName),再按第七篇的AbstractJni套路 override 对应方法即可。 - 参数从哪来:Context / ActivityThread / Activity 这类对象,如果 init 里会做反射调用,最稳妥的做法是先用 Frida 把真机上传进去的对象 dump 出来(toString / getClass / 关键字段),再在 Unidbg 里用
AbstractJni返回同样的值。凭空猜参数,往往猜不对。
一个实用的小判断:看 init 函数的 IDA 反编译里有没有 (*env)->GetObjectClass 或 CallObjectMethod,有就要认真造参数,没有就空壳搞定。
另一条路:从异常分支反推 init
四步定位法的前提是 Frida 能用、且导出函数不太多。一旦反调试 / Root 检测把 Frida 踢出去,或者 SO 有几百个导出挨个试代价过高,就得换条路 —— 直接在 Unidbg 里跑目标函数(接受它返回错值),从 trace 反推哪个全局没初始化,再反推哪个函数是 init。
具体步骤:
-
打开 trace 跑目标函数。trace 怎么开、怎么读、怎么重定向到文件,下一篇会专门讲,这里只用结论
-
找到"错的分支跳点"。重点看目标函数早期的
cmp/cbz/tbz后面那种"跳到早早 return 0/null/-1"的判断 —— 在扁平 trace 里筛"一去不回的失败分支" -
反编译那个分支的判断条件。大概率长这样:
if (g_initialized != 1) return NULL; if (g_keys[0] == 0) return NULL; if (*(int*)dword_xxxx) return NULL; -
对那个全局按
X找 xref。写它的函数通常只有 1-2 个,挨个看哪个长得像初始化(偏前面、带RegisterNatives或一堆setX调用)—— 那个就是要补的 init
这条路和四步法本质上是同一个问题的两端切入:
- 四步法从"调用方"出发:知道哪些函数要调,再补它们的依赖
- 反推法从"被消费方"出发:知道哪个全局缺了,再去找填它的人
反调试不严的样本优先四步法(快),反调试严或导出函数多的样本用反推法(稳)。
不适用场景:
- 目标函数压根跑不到(在更早位置就 abort)—— 问题在更上游,先解那个 abort
- OLLVM 控制流平坦化 —— trace 出来全是 bogus block 跳来跳去,噪声太大,得先去混淆才能用这条路
找到 init 之后:它在做什么
四步定位法告诉你"哪个函数是 init"。但不同 init 做的事不一样,症状也不一样。按"它在做什么"可以分三类:
| 类型 | 做什么 | 不调用会发生什么 |
|---|---|---|
| 安全门卫型 | 验证签名 / 包名 / 调试器 / Root | 后续函数静默返回 null 或假数据 |
| 缓存预热型 | 批量获取 MethodID / FieldID / Class | 后续函数 NPE 或 Find 失败 |
| 数据准备型 | 生成密钥 / 加载配置 / 算白盒表 | 后续函数返回乱码或全零 |
为什么要分类?因为症状不一样,定位思路也不一样:
- 安全门卫型:表现为返回
null或固定的"失败值" - 缓存预热型:表现为运行时 JNI 异常(日志里能看到
NoSuchMethodError) - 数据准备型:表现为业务结果"对而不对"(密钥变了,密文跟着变)
反过来也成立 —— 还没定位到 init 时,从症状先猜类型,再去找对应特征的函数,比盲目枚举快得多。
初始化函数链的复杂性
如果你以为只要调一个 init 就完事,那你太天真了。真实样本里我见过:
形式 1:链式 init
init() → setupContext() → loadKeys() → finalizeSession()
四个函数必须按顺序调用,缺一个或顺序错都会失败。中间任何一步出错,最后一个函数返回 null。
形式 2:参数版本
同一个函数 init(int mode),必须先用 mode=0 调一次(初始化),再用 mode=1 调一次(激活),最后业务函数才能用。
形式 3:跨 SO 链
libA.so 的 init 必须在 libB.so 的 init 之前调用,因为 B 依赖 A 设置的全局状态。
形式 4:命名陷阱
最坑的一类 —— 初始化函数不叫 init。我见过的命名:
nativeRegisterCallbackssetEnvattachJVMnativeOnCreate- 一个看起来是业务函数的名字,其实是初始化
教训:不要看名字猜功能,要看行为。
形式 5:类继承链预解析(loadLibrary 之前就要做)
前面四种"初始化"都是等 SO 跑起来再调一些方法,但有一种更隐蔽的初始化是在 SO 加载之前就要把类的继承关系告诉 Unidbg——SO 在 JNI_OnLoad(甚至 .init_array 构造器)期间会通过 IsAssignableFrom 这类 JNI API 校验"A 是不是 B 的子类"。要是这时候 Unidbg 的 DalvikVM 还没把这两个类挂上父子关系,校验失败 SO 就在初始化阶段抛异常。
抖音 metasec_ml.so 就是典型例子。它在 JNI_OnLoad 里要求 com.bytedance.mobsec.metasec.ml.MS 必须能识别成 ms.bd.c.a0 的子类,而后者又要识别成 ms.bd.c.k 的子类。Unidbg 默认不知道这层关系(它只在被显式调用时按需建类),所以必须在 loadLibrary 之前就预解析整条链:
// resolveClass(name, parent) 重载允许手动指定父类
// 内层先建 k, 把它作为 a0 的父类传进去; 再把 a0 作为 MS 的父类传进去
vm.resolveClass("com/bytedance/mobsec/metasec/ml/MS",
vm.resolveClass("ms/bd/c/a0",
vm.resolveClass("ms/bd/c/k")));
// 这一步做完, 再 loadLibrary 才不会在 JNI_OnLoad 里触发 IsAssignableFrom 失败
DalvikModule dm = vm.loadLibrary(new File("libmetasec_ml.so"), true);
dm.callJNI_OnLoad(emulator);
怎么发现这种情况:症状是 loadLibrary 或 callJNI_OnLoad 直接抛异常,verbose 日志里出现 IsAssignableFrom 调用并返回 false,或者 SO 在 OnLoad 早期就 abort——而你完全没有机会进 callObjectMethodV 之类的回调,因为 SO 根本没跑到那一步。
怎么找到正确的继承链:从 JADX 反编译目标 APK 找到那几个类,往上追 extends 关键字。混淆过的 SDK 里类名都是单字母,但 extends 关系仍然能从字节码里读出来。
为什么 Unidbg 不自动做:因为继承关系是 Java 语义,不在 SO 二进制里——Unidbg 模拟的是 SO 运行环境,不是 ART 的类加载器。任何依赖类继承的 JNI 校验都需要你预先告诉 Unidbg 答案。这是和"补 callObjectMethodV 返回值"性质完全不同的初始化——它发生在 SO 跑起来之前,错过了就再也补不上。
中段执行的高级场景
这里说的"中段",指的是补环境已经做完、业务函数还没开始调之间的那段时间 —— 初始化就发生在这里。上面的四步法适用于"初始化逻辑相对简单"的样本。中段还有几种"高级版"问题,值得单独提一下。
场景一:JNI_OnLoad 死锁
某些 SO 的 JNI_OnLoad(或 .init_array 构造器)会启动后台线程做持续工作(心跳/上报/反调试)。Unidbg 是单线程模型,这种死锁会直接卡住整个虚拟机,没有错误提示,就是不返回。
症状:卡的位置取决于后台线程是从哪里起的 —— 如果是 .init_array 构造器里启动,会卡在 vm.loadLibrary(...);如果是 JNI_OnLoad 里启动,会卡在 dm.callJNI_OnLoad(emulator)。主线程不动了。
解决方法:
- 用 Frida 看 JNI_OnLoad 内部到底在等什么(通常是
pthread_join/pthread_cond_wait) - 在 Unidbg 中 Hook 那个等待函数,让它直接返回成功
- 或者更暴力一点 —— 整个 patch 掉 JNI_OnLoad 里启动后台线程的部分
场景二:初始化链过长
有些大型 SO 的初始化要调用 30+ 个 native 函数,顺序还很关键。手动调每个都不现实。
思路:录制 + 重放。
// Frida 录制阶段
var calls = [];
Java.perform(function() {
var clazz = Java.use("com.xxx.Sec");
var methods = clazz.class.getDeclaredMethods();
methods.forEach(function(m) {
if (m.toString().indexOf("native") >= 0) {
// hook 每个 native 方法,记录调用顺序和参数
}
});
});
把录制结果导出来(通常是一个 JSON:方法名 + 参数 + 返回值),在 Unidbg 中按序回放:
// Unidbg 回放阶段
List<CallRecord> records = loadRecords("init-trace.json");
DvmObject<?> sec = vm.resolveClass("com/xxx/Sec").newObject(null);
for (CallRecord r : records) {
DvmObject<?>[] args = decodeArgs(vm, r.args); // 按签名还原参数类型
DvmObject<?> ret = sec.callJniMethodObject(emulator, r.method, args);
// 可选:和真机返回值比对,一旦偏差立刻打断
if (!Objects.equals(String.valueOf(ret), r.returnValue)) {
throw new IllegalStateException("mismatch at step " + r.method);
}
}
// 所有初始化回放完,才调目标业务函数
DvmObject<?> result = sec.callJniMethodObject(emulator, "encrypt",
new ByteArray(vm, new byte[]{1, 2, 3}));
这种做法的价值,在于把“每一步都要静态分析 init 做了什么”的成本整体跳过了 —— 你不懂无所谓,真机跑对了我就照抄。
但它也有明确的适用边界:
- 录制那一遍依赖真机能跑通:加了反调试 / Root 检测的样本,Frida hook 可能还没挂上就被踢掉
- 参数类型复杂时序列化成本高:如果 init 参数是一个自定义的配置对象,
decodeArgs要自己实现,有时候比手工补还累 - 副作用类初始化回放会失败:init 里如果申请网络、读真机特有文件,Unidbg 里没对应的环境,回放也跑不动
所以它是“初始化链超长 + 静态分析代价很高”时的兜底方案,不是第一选择。大多数样本,四步定位法已经够用。
场景三:没有明确初始化函数
有些样本设计上就没有 init —— 它的“初始化”是懒加载式的:
第一次调用业务函数时,内部会自己初始化;但内部的初始化又依赖某些“上下文”,比如 Java 层的某个类已经被注册过、某个静态变量已经被赋值。
典型例子:设备指纹生成。函数本身就是 init + 业务一体的,但它会读 Build.MODEL / Build.BRAND 这些 Java 静态字段。
应对:
- 用 AbstractJni 把
getStaticObjectField拦截下来,返回真机上的值 - 或者直接 Hook 那个业务函数里读 Java 字段的位置,把读到的值写成假数据
这一类问题已经不是“补初始化”了,而是补“被初始化”消费的环境。
一些让我少走弯路的经验
最后留几个口子,留给以后想起来再翻这一篇的你:
1. 任何时候都要有“标准答案”
定位初始化问题的所有努力,都建立在“知道正确结果是什么”之上。如果连标准答案都没有,你在 Unidbg 里得到任何结果都没法判断对错。
永远先在 Frida 里验证,再在 Unidbg 里复现。
2. 用 r0tracer 或函数 Trace 找规律
如果你不知道哪个函数是 init,可以用 r0tracer 把 SO 所有导出函数的调用都打出来,然后看真机启动 App 时的调用顺序 —— 排在前面的几乎必然是 init。
这比手动猜要快得多。
3. 初始化问题不解决,后面所有努力都白费
我见过有人补全了 200+ 个 JNI 字段,试图“绕过”一个明显是初始化问题的 null 返回。那些工作都是浪费。
发现是初始化问题的瞬间,就停下手头的一切补全工作,先把 init 跑通。
总结
| 痛点 | 应对 |
|---|---|
| 函数返回 null,没有报错 | 怀疑初始化问题,先做四步定位 |
| 不知道 init 是哪个 | 用 Frida 逐个 Call 导出函数 |
| init 之后还是不对 | 静态分析 init 内部依赖,补上 |
| JNI_OnLoad 死锁 | Hook 阻塞函数,patch 掉后台线程 |
| 初始化链过长 | 用 Frida 录制调用顺序,在 Unidbg 中回放 |
| 没有明确 init | 把“被初始化消费的环境”也补上 |
| 初始化藏在类继承链 | 见上文"形式 5:类继承链预解析",在 loadLibrary 之前就要做 |