12、Native层逆向(一):JNI基础(RegisterNatives、JNI函数表)、SO文件加载流程。
好,咱们终于要开始啃 Native 层这块硬骨头了。
说实话,很多做逆向的朋友,一看到 .so 文件就头大。其实没那么可怕。你想想看,Java 层和 Native 层之间,总得有个“翻译官”吧?这个翻译官就是 JNI(Java Native Interface)。
今天这一讲,咱们就把 JNI 的底裤扒干净。我会从最基础的 JNI 函数表讲起,再到 RegisterNatives 这种动态注册的黑科技,最后带你走一遍 SO 文件从加载到执行的完整流程。
12.1 JNI 函数表:Java 和 C 的“通讯录”
JNI 函数表,说白了就是一张“通讯录”。
Java 层想调用 Native 层的某个函数,不能直接喊话,得通过这张表来查。这张表里存的是什么呢?就是 JNI 提供的所有接口函数的指针。
我刚开始学的时候,总以为 JNI 函数是直接写在代码里的。后来才发现,其实每个线程都有一个 JNIEnv 指针,这个指针指向的就是那张函数表。
核心概念:JNIEnv 是一个指向线程局部数据的指针,该数据中包含一个指向 JNI 函数表的指针。函数表里排列着 200 多个 JNI 函数。
举个例子,你在 Java 层声明了一个 native 方法:
public class NativeBridge {
public static native String getSecretKey();
}
在 C 层,对应的实现函数签名长这样:
JNIEXPORT jstring JNICALL
Java_com_example_NativeBridge_getSecretKey(JNIEnv *env, jclass clazz) {
// 你的逻辑
return (*env)->NewStringUTF(env, "my_secret_key");
}
注意看,第一个参数就是 JNIEnv *env。通过这个 env,你才能调用 NewStringUTF 这种 JNI 函数。没有它,你连个字符串都创建不了。
我的小技巧:逆向分析时,如果你在 .so 里看到大量对 JNI 函数表的调用(比如通过寄存器间接调用),基本可以确定这是 JNI 代码。我曾经靠这个特征,在一堆混淆代码里快速定位到了核心加密逻辑。
12.2 静态注册 vs 动态注册:两种“上户口”的方式
Java 的 native 方法怎么找到 C 函数的?有两种方式。
12.2.1 静态注册(按名字匹配)
这是最传统的方式。C 函数的名字必须严格按照 Java_包名_类名_方法名 的格式来写。
比如上面那个例子,函数名就是 Java_com_example_NativeBridge_getSecretKey。
这种方式的好处是简单,JVM 启动时自动就能找到。但缺点也很明显:
- 函数名太长,容易写错
- 容易被逆向分析者直接定位
- 每次新增方法都要改函数名,维护麻烦
12.2.2 动态注册(用 RegisterNatives 手动绑定)
这才是高手用的方式。你可以在 JNI_OnLoad 函数里,手动调用 RegisterNatives 来建立 Java 方法和 C 函数的映射关系。
static JNINativeMethod methods[] = {
{"getSecretKey", "()Ljava/lang/String;", (void*)native_getSecretKey},
{"setData", "([B)Z", (void*)native_setData}
};
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env;
if ((*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
jclass clazz = (*env)->FindClass(env, "com/example/NativeBridge");
if ((*env)->RegisterNatives(env, clazz, methods, sizeof(methods)/sizeof(methods[0])) < 0) {
return JNI_ERR;
}
return JNI_VERSION_1_6;
}
看到没?这里的关键是 JNINativeMethod 结构体,它包含三个字段:Java 方法名、方法签名(就是那个奇怪的字符串)、C 函数指针。
注意:方法签名里的括号和分号,一个都不能错。比如 ()Ljava/lang/String; 表示无参数,返回 String。我曾经因为少写了一个分号,排查了整整一个下午。
动态注册的好处很明显:
- 函数名可以随便起,不用遵守命名规范
- 可以在运行时动态决定绑定哪个函数
- 逆向分析时,不能直接通过函数名定位,增加了分析难度
我遇到过很多加固方案,就是用动态注册 + 函数名混淆,让 IDA 直接懵逼。你搜不到 JNI 函数,只能靠动态调试去断点。
12.3 SO 文件加载流程:从 dlopen 到 JNI_OnLoad
好了,现在咱们来走一遍 SO 文件从加载到执行的完整流程。这个流程,你逆向分析时几乎天天都要面对。
核心流程:
- Java 层调用 System.loadLibrary("native-lib")
- 底层调用 dlopen 打开 .so 文件
- 系统执行 .so 的初始化函数(.init / .init_array)
- 系统查找并调用 JNI_OnLoad 函数
- JNI_OnLoad 中完成动态注册(如果有)
- Java 层可以正常调用 native 方法了
我画了一张图,帮你理解这个过程:
这里有几个关键点,我特别想强调一下:
12.3.1 .init 和 .init_array 段
这两个段在 dlopen 之后、JNI_OnLoad 之前执行。很多恶意代码会在这里做反调试、环境检测。我见过一个样本,在 .init_array 里开了个线程专门检测调试器,一旦发现就自杀。
12.3.2 JNI_OnLoad 的返回值
这个函数必须返回 JNI 版本号,比如 JNI_VERSION_1_6。如果返回错误,整个加载会失败。我调试时经常在这里下断点,因为它是 Native 层第一个可控的入口。
12.3.3 动态注册的时机
动态注册必须在 JNI_OnLoad 里完成,否则 Java 层调用 native 方法时会找不到对应的 C 函数,直接崩溃。
避坑指南:我曾经遇到一个案例,SO 文件加载后一直崩溃,但代码逻辑看起来没问题。后来发现是 JNI_OnLoad 里调用了 FindClass,但类名写的是 com/example/NativeBridge,而实际包名是 com.example.app。这种低级错误,排查起来真要命。
12.4 逆向分析时的实战要点
好了,理论讲完了,咱们聊聊实战中怎么用。
当你拿到一个 .so 文件,想分析它的 JNI 逻辑时,我建议按这个顺序来:
- 先看 JNI_OnLoad:在 IDA 里直接搜这个函数名。如果找不到,说明可能被混淆或 strip 了。那就搜字符串 "JNI_OnLoad" 或者直接看 .init_array。
- 找 RegisterNatives 调用:如果用了动态注册,JNI_OnLoad 里一定有 RegisterNatives 的调用。找到它,就能拿到 JNINativeMethod 数组,里面就是 Java 方法和 C 函数的映射关系。
- 分析函数签名:拿到映射后,看每个方法的签名。签名能告诉你参数类型和返回类型,这对理解函数逻辑至关重要。
- 静态注册的情况:如果没找到动态注册,那就按静态注册的命名规则去搜函数名。比如搜
Java_开头的函数。
一个实用技巧:在 IDA 里,按 Ctrl + F 搜索 JNINativeMethod 结构体。这个结构体在内存中是连续存放的,找到它就能一次性拿到所有注册信息。我经常用这个技巧快速定位核心函数。
嗯,今天的内容就到这。JNI 基础是 Native 层逆向的基石,搞不懂它,后面分析加密算法、反调试、加固方案都会很吃力。建议你找个简单的 .so 文件,用 IDA 打开,对照着今天讲的内容,一步步走一遍加载流程。
记住,纸上得来终觉浅。动手调试一次,比看十遍文章都管用。