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 文件从加载到执行的完整流程。这个流程,你逆向分析时几乎天天都要面对。

核心流程:

  1. Java 层调用 System.loadLibrary("native-lib")
  2. 底层调用 dlopen 打开 .so 文件
  3. 系统执行 .so 的初始化函数(.init / .init_array)
  4. 系统查找并调用 JNI_OnLoad 函数
  5. JNI_OnLoad 中完成动态注册(如果有)
  6. Java 层可以正常调用 native 方法了

我画了一张图,帮你理解这个过程:

SO 文件加载与 JNI 初始化流程 System.loadLibrary() dlopen 加载 .so 执行 .init 段 调用 JNI_OnLoad 动态注册函数 Java 调用 native 方法 Java 层触发 系统底层 初始化 注册映射 正常调用

这里有几个关键点,我特别想强调一下:

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 逻辑时,我建议按这个顺序来:

  1. 先看 JNI_OnLoad:在 IDA 里直接搜这个函数名。如果找不到,说明可能被混淆或 strip 了。那就搜字符串 "JNI_OnLoad" 或者直接看 .init_array。
  2. 找 RegisterNatives 调用:如果用了动态注册,JNI_OnLoad 里一定有 RegisterNatives 的调用。找到它,就能拿到 JNINativeMethod 数组,里面就是 Java 方法和 C 函数的映射关系。
  3. 分析函数签名:拿到映射后,看每个方法的签名。签名能告诉你参数类型和返回类型,这对理解函数逻辑至关重要。
  4. 静态注册的情况:如果没找到动态注册,那就按静态注册的命名规则去搜函数名。比如搜 Java_ 开头的函数。

一个实用技巧:在 IDA 里,按 Ctrl + F 搜索 JNINativeMethod 结构体。这个结构体在内存中是连续存放的,找到它就能一次性拿到所有注册信息。我经常用这个技巧快速定位核心函数。

嗯,今天的内容就到这。JNI 基础是 Native 层逆向的基石,搞不懂它,后面分析加密算法、反调试、加固方案都会很吃力。建议你找个简单的 .so 文件,用 IDA 打开,对照着今天讲的内容,一步步走一遍加载流程。

记住,纸上得来终觉浅。动手调试一次,比看十遍文章都管用。


公众号:蓝海资料掘金营,微信deep3321