JNI 101

本章参考: 《深入理解Android 卷Ⅰ》

在逆向so文件时,我们经常遇到/发现:

  1. 找不到Java层native函数对应的JNI函数;
  2. 要把JNI函数的第一个参数改为JNIEnv *env以便识别;
  3. 加载so时,频繁调用NewStringUTFReleaseStringUTFChars等字符串函数。

在深入理解JNI (Java Native Interface) 之后,我们可以解释上述现象。

JNI函数注册,即将Java层的native函数与其对应的JNI函数关联起来,以实现在Java层调用JNI函数。有两种注册方式,静态注册与动态注册。

首先,我们来看较为简单的静态注册。创建一个类Static,其中加载了名为jni的库,并调用了JNI函数output

1
2
3
4
5
6
7
8
9
package com.example.jni;

public class Static {
static {
System.loadLibrary("jni");
}

public static native String output();
}

使用javac编译,并用javah生成对应的JNI头文件com_example_jni_Static.h

在该文件中,我们可以看到它声明了native函数output。无参函数output,此时拥有了两个参数JNIEnvjclass。第一个参数JNIEnv,代表着当前的JNI环境;第二个参数代表当前类,若native函数为static,参数为jclass,其他为jobject

接着,在com_example_jni_Static.c中实现该函数:

1
2
3
4
5
6
#include <jni.h>
#include <string.h>

jstring Java_com_example_jni_Static_output(JNIEnv *env,jclass type){
return (*env)->NewStringUTF(env, "You called the static-register method.");
}

使用ndk-build生成libjni.so

此时,使用IDA分析libjni.so,我们可以轻而易举地定位output函数:

其构成为:Java_包名_函数名。需要注意,包名中的.,都被转换为_,另外,如果函数名中含_,将被转换为l_

于是,在静态注册中,当Java层调用output函数时,会在对应的JNI库中寻找Java_com_example_jni_Static_output来建立关联关系。而实际上,这种方式会影响运行效率,同时被逆向的风险较大。

那么我们来看动态注册。首先介绍一下JNINativeMethod

1
2
3
4
5
typedef struct { 
char *name; // Java层native函数名
char *signature; // 函数签名
void *fnPtr; // JNI层函数指针
} JNINativeMethod;

动态注册,便是使用这样一个结构体来存储Java层函数与JNI指针的对应关系,以克服效率上的弊端。

创建一个类Dynamic

1
2
3
4
5
6
7
8
9
package com.example.jni2;

public class Dynamic {
static {
System.loadLibrary("jni");
}

public static native String output();
}

编写com_example_jni2_Dynamic.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <jni.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

JNIEXPORT jstring JNICALL riddle(JNIEnv *env, jclass type) {
return (*env)->NewStringUTF(env, "You called the dynamic-register method.");
}


static JNINativeMethod g_methods[] = {
{
"output",
"()Ljava/lang/String",
(void *) riddle
}
};

JNIEXPORT int JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env;
if ((*vm)->GetEnv(vm, (void **)&env,JNI_VERSION_1_6) != JNI_OK) {
return JNI_FALSE;
}

const char *class_path = "com/example/jni2/Dynamic";
jclass javaClass = (*env)->FindClass(env,class_path);
if (javaClass == NULL) {
return JNI_FALSE;
}

int method_count = sizeof(g_methods) / sizeof(g_methods[0]);
if ((*env)->RegisterNatives(env,javaClass, g_methods, method_count) < 0) {
return JNI_FALSE;
}

return JNI_VERSION_1_6;
}

代码的前半部分,包括riddle函数的实现和元素为JNINativeMethod的动态注册表g_methods。先来看JNINativeMethod,函数名为output,函数签名为()Ljava/lang/String,JNI层指针为riddle。其中,函数签名的格式为(参数1类型;参数2类型...;)返回值类型,我们可以使用javap工具快捷生成函数签名。

可知,output函数对应的JNI函数为riddle。正如IDA中看到的一样:

非开发者逆向时,自然难以得知outputriddle相关联,可见动态注册变相增加了逆向的难度。

那在对抗动态注册时,如何定位native函数的实现呢?我们结合上文JNI_OnLoad的源码来进行分析。在Java层通过System.loadLibrary加载完so库后,会调用JNI_OnLoad函数,这是唯一有机会进行动态注册的地方。

IDA中的JNI_OnLoad函数初始如下:

修改JNIEnv参数:

可以看到动态注册函数RegisterNatives得到还原。对照源码可知,其第三个参数为动态注册表g_methods。来到off_26B0处:

发现动态注册表,成功定位output函数。

最后,我们再来解释一下开头提出的现象3。我们在Java层调用output函数:

1
2
3
4
5
6
7
8
9
10
public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
String test = Static.output();
Toast.makeText(getApplicationContext(), test, Toast.LENGTH_SHORT).show();
}
}

使App在创建时弹出output返回的字符串,效果如下:

使用JNITrace跟踪libjni.so的加载情况:

可见,JNIEnv对我们输入的字符串调用了NewStringUTF生成了jstring对象。JNI层中,正式借助此类函数对jstring对象,即Java层的String对象,进行操作。在实际开发时,进行此类操作后,需要调用ReleaseStringUTFChars释放资源,以防JVM内存泄漏。

Author

yekc1m

Posted on

2022-03-02

Updated on

2022-03-03

Licensed under