本文是第十一篇《初始化问题》的案例补遗。原篇讲了“初始化函数没执行 → 目标函数返回空”的三种设计意图与四步定位法。本文用抖音 libmetasec_ml.so 的 Aweme.java,展示另一种更隐蔽的变体:初始化不在 SO 里,而在 Java 层的类加载顺序。
这个样本特殊在哪
多数 SO 的初始化依赖藏在 JNI_OnLoad 或某个导出函数里。Aweme 的 libmetasec_ml 不一样——它的初始化依赖藏在 Java 类之间的继承关系。
Aweme.java:54-56 里有三行看似不起眼的代码,写错一点点整个 SO 就不工作:
vm.resolveClass("com/bytedance/mobsec/metasec/ml/MS", vm.resolveClass("ms/bd/c/a0", vm.resolveClass("ms/bd/c/k")));
三层嵌套的 resolveClass。原篇四步定位法里没有直接讲这种场景——它既不是“SO 里的 init 函数”,也不是“JNI_OnLoad 死锁”。它是真机上 DEX 加载时就已经建立好的 Java 类继承关系,SO 启动时读取这些关系来决定后续分支。
这三行做了什么
读起来要从里向外:
第 3 层:resolveClass("ms/bd/c/k") → 告诉 DalvikVM:"ms.bd.c.k 这个类存在,没有父类(或者 Object)"第 2 层:resolveClass("ms/bd/c/a0", ↑) → "ms.bd.c.a0 存在,它的父类是 ms.bd.c.k"第 1 层:resolveClass("com/.../MS", ↑) → "MS 这个类存在,它的父类是 ms.bd.c.a0"
三行代码在 Unidbg 的类表里建立了 MS ← a0 ← k 的继承链。这不是“初始化函数”,这是“告诉 SO Java 世界里有什么类、谁继承谁”。
不做这件事会怎样
去掉这三行,loadLibrary 还会成功,JNI_OnLoad 也会成功。但你跑任何一个业务调用时会在某个奇怪的地方炸掉——通常是 SO 内部做了一次 FindClass("com/.../MS") 失败,或者 IsInstanceOf 返回了错的结果。
这就是原篇说的“初级到中级的分水岭”的另一种表现形式:不是“不会补”,而是“你永远想不到要补这个”。
和原篇三种设计意图的对照
原篇列了三种初始化函数的设计意图:安全门卫 / 缓存预热 / 数据准备。Aweme 这个案例属于第二种(缓存预热)的 Java 层变体:
| | |
|---|
| | |
| | |
| | 在 loadLibrary 之前手动 resolveClass |
| | |
关键差异:Aweme 的“初始化”不发生在 SO 执行期,而是在 SO 执行前。你没法通过“Hook JNI_OnLoad 之后逐个 Call 导出函数”来发现它,因为它根本不是导出函数。
怎么发现需要补这三行
Aweme.java 的作者是怎么知道要补这条链的?倒推过程大概是这样:
- 跑一遍
GetSign() → SO 在某处抛出 java/lang/NoClassDefFoundError: ms/bd/c/a0 - 到 JADX 里找
ms/bd/c/a0 → 发现它 extends ms/bd/c/k - 到 JADX 里找
com/.../MS → 发现它 extends ms/bd/c/a0
这是一种“报错驱动的反向建模”——SO 问 Unidbg 要一个类,Unidbg 说没有,你就去 JADX 里把这个类的身世填进来。
原篇四步定位法是为“不知道哪个 init 函数”设计的;这个场景的定位法更简单:报错里给了类名,去 JADX 找父类,从叶子节点往上 resolveClass。
一个顺序约束:resolveClass 必须在 loadLibrary 之前
回头看开头那三行代码 —— 为什么它们必须写在 vm.loadLibrary(...)之前?换到后面就不行吗?
换到后面确实不行,但不会报错,而是在某个遥远的业务分支里悄悄走错。原因在 BaseVM.resolveClass 的源码里:
publicfinal DvmClass resolveClass(String className, DvmClass... interfaceClasses){ ... DvmClass dvmClass = classMap.get(hash); DvmClass superClass = null;if (interfaceClasses != null && interfaceClasses.length > 0) { superClass = interfaceClasses[0]; ... }if (dvmClass == null) { // ← 关键: 只在第一次登记时生效 dvmClass = this.createClass(this, className, superClass, interfaceClasses); classMap.put(hash, dvmClass); }return dvmClass;}
resolveClass 是 first-write-wins 的:classMap 里一旦登记了某个类,之后再调 resolveClass 补 superclass / interfaces,第二次的参数会被静默丢弃,没有任何日志。
这和 loadLibrary 的交互就变成了一个隐蔽的竞态:
loadLibrary 会触发 JNI_OnLoad 执行- JNI_OnLoad 内部但凡做过一次
env->FindClass("com/.../MS")(抖音这个 SO 大概率做)—— unidbg 内部会自动触发 resolveClass("com/.../MS"),不带 superclass - 你后来在外面写
resolveClass("MS", a0) 想补继承链 —— MS 已经在 classMap 里了,if (dvmClass == null) 判掉,superClass 参数被扔掉 - SO 后续走
IsInstanceOf(obj, a0) / IsAssignableFrom 时,从 MS 往上找不到 a0,走进错误分支
结论:三行 resolveClass 必须出现在 loadLibrary 之前。调换顺序不会编译出错也不会运行报错,但继承链不会生效,业务函数会在某个意想不到的地方返回错值 —— 这是原篇反复强调的"初始化问题的典型症状"在 Java 层的镜像。
顺便澄清一个常见误解:unidbg 上没有一个"重量级的 loadClass 会执行 <clinit>"与 resolveClass 对立的 API。unidbg 没有 DEX 字节码解释器,classMap 里的 DvmClass 只是登记项,<clinit> 里如果有 native 调用,那是你自己 callJniMethod 的事 —— 没有任何 API 替你执行它。所以 "resolveClass 够用"不是因为"我们选了轻量的那个",而是因为能用的就只有它。
同文件里的第二个初始化暗桩
看下面这段,和原篇“数据准备型”高度对应:
// Aweme.java:89-99case"com/bytedance/mobsec/metasec/ml/MS->b(IIJLjava/lang/String;Ljava/lang/Object;)Ljava/lang/Object;": {int a = vaList.getIntArg(0);if (a == 65539) returnnew StringObject(vm,"/data/user/0/.../files/;o@Y0f");elseif (a == 33554433) return DvmBoolean.valueOf(vm, Boolean.TRUE);elseif (a == 33554434) return DvmBoolean.valueOf(vm, Boolean.TRUE);elseif (a == 16777233) returnnew StringObject(vm, "23.3.0");}
这个 MS.b 是 SO 向 Java 层“查询配置”的统一入口。魔数 65539、33554433、16777233 是不同的配置 id,真机上由 Java 层的某个配置服务响应。
这些数字是 Aweme 在主业务调用(GetSign)前会挨个问一遍的——属于原篇说的“数据准备型”:SO 问你要一堆常量,你答不上来,它就走降级分支或者直接返回空。
这是另一种“非函数式的初始化依赖”。你不再是“补一个 init”,你是在扮演一张配置表。
对照回原篇
| |
|---|
| 这里根本没有 init 函数,依赖藏在类继承链 + 配置魔数 |
| 这里 Java 类继承=缓存预热的变体;MS.b 魔数=数据准备 |
| 对这种场景降级为“看报错类名 → JADX 查父类 → 逆向填表” |
| 这就是其中一种——“初始化在 Java 层而非 SO 层” |
如果你只记一件事
“初始化”不一定是一次函数调用,也可能只是一张表、一条继承链、一组魔数。当你补完所有 JNI 回调还是拿不到正确结果,翻一眼错误栈顶的 ClassNotFound 或某个奇怪的 if a == 65539——那就是 SO 在问你要一张“开场前就该摆好的表”。