Fanxs's Blog

JNI调用和动态注册探索

字数统计: 3.9k阅读时长: 17 min
2019/08/19 Share

前言:
之前在测试一个APP时,需要自己去写一个APP调用so库来进行通信的加解密。由于so文件使用了动态注册,所以没能找到对应的Native加解密方法来进行分析。所以这一篇就自己探索一下JNI接口了调用so库的方法,以及如何发现和定位动态注册的JNI接口。

0x00. JNI接口

在Java中声明并调用了Native方法时,在运行时必须要让JVM找到对应的函数。Java层会通过JNI(Java Native Interface)来访问到Native层:

1
Java <---> JNI <---> Native

Java代码调用Native函数,需要在类中加载so文件,并声明要调用的Native方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.test.jni;
public class HelloWorldJNI {
//加载so文件
static {
System.loadLibrary("native");
}
// 声明Native方法sayHello,返回值为空
private native void sayHello();

public static void main(String[] args) {
//通过HelloWorldJNI对象调用
new HelloWorldJNI().sayHello();
}
}

这里在类HelloWorldJNI中调用了Native方法sayHello。运行时,需要JNI定位到目标函数。有两种方法能映射so文件中的目标函数:

  • 静态注册
  • 动态注册

0x01. 静态注册

静态注册时,C/C++文件代码中,需要以固定的命名规则来命名导出的JNI函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 
- 命名规则:
Java_{package_and_classname}_{function_name}(JNI_arguments)`,用下划线代替"."

- 参数:
JNIEnv*: JNI环境的引用
jobject: "this" Java对象的引用
*/

#include <jni.h>
#include <stdio.h>
JNIEXPORT void JNICALL Java_com_testing_jni_HelloWorldJNI_sayHello(JNIEnv* env, jobject thisObject) {
printf("Hello World!\n");
}

JNIEnv类提供了JNI环境的许多信息和方法,具体结构可看:
http://xdprof.sourceforge.net/doxygen/structJNIEnv__.html

除了固定的命名规则以外,还需要用JNIEXPORT和JNICALL来标识JNI方法。在/Android/SDK/ndk-bundle/……/include/jni.h中,可看到JNIEXPORT和JNICALL的宏定义:

1
2
3
#define JNIIMPORT
#define JNIEXPORT __attribute__ ((visibility ("default")))
#define JNICALL


JNIEXPORT 确保标识的函数被正常导出。C++中提供了elf文件的导出表隐藏功能,而为了JNI能找到Native方法,需要设置Visibility属性为”default“, 即确保标识函数会出现到so文件的导出表中,因此JNI接口可以找到目标方法。JNICALL 则确保函数的命名规范正确。

Native方法静态注册后,JNI环境可以很轻松地在so的导出表中,根据调用者的包名、类名和方法名,找到对应的方法。

0x02. 动态注册

除了使用静态注册这种传统方法实现JNI外,也可以使用RegisterNatives动态实现JNI。

JNIEnv类中提供了动态注册Native函数的方法RegisterNatives,这个方法可动态地将so库中的方法名,与JVM中某个类调用的Native方法名做绑定和映射,这样JNI就不需要通过函数命名规则来搜索目标函数,可直接在JNIEnv中定位目标函数。动态注册的过程,一般发生在JNI_Onload执行期间。

JVM在System.loadLibrary加载so文件时,会第一时间调用JNI_Onload函数,它有两个重要的作用:

  • 指定JNI版本:告诉VM该组件使用那一个JNI版本(若未提供JNI_OnLoad()函数,VM会默认该使用最老的JNI 1.1版),如果要使用新版本的JNI,例如JNI 1.4版,则必须由JNI_OnLoad()函数返回常量JNI_VERSION_1_4来告知VM。
  • 初始化设定,进行各种资源的初始化操作。

由于JNI_Onload执行了初始化资源的操作,动态注册也一般在初始化时进行。重写JNI_Onload函数:

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
38
39
40
41
42
43
44
45
46
47
#include <jni.h> 
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

JNIEXPORT void JNICALL sayHello(JNIEnv* env, jobject thisObject) {
printf("Hello World!\n");
}

//Java和JNI函数的绑定表
static JNINativeMethod gMethods[] = {
{"sayHello", "()V", (void *)sayHello},
};

//注册native方法到java中
static int registerNativeMethods(JNIEnv* env, const char* className,JNINativeMethod* gMethods, int numMethods){
jclass clazz;
clazz = (*env)->FindClass(env, className);
if (clazz == NULL) {
return JNI_FALSE;
}
if ((*env)->RegisterNatives(env, clazz, gMethods,numMethods) < 0){
return JNI_FALSE;
}
return JNI_TRUE;
}

int register_ndk_load(JNIEnv *env){
return registerNativeMethods(env, "com/testing/jni/HelloWorldJNI",gMethods,sizeof(gMethods) / sizeof(gMethods[0]));
}

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
{
JNIEnv* env = NULL;
jint result = -1;
if ((*vm)->GetEnv(vm, (void**) &env, JNI_VERSION_1_4) != JNI_OK){
return result;
}
// 动态注册
register_ndk_load(env);

// 返回jni的版本
return JNI_VERSION_1_4;
}

在C++和Java中创建关联的是JNINativeMethod,保存了Java中调用的函数名和C++代码中函数名的映射关系,它在jni.h中定义:

1
2
3
4
5
6
/*used in RegisterNatives to describe native method name, signature, and function pointer.*/
typedef struct {
char *name;
char *signature;
void *fnPtr;
} JNINativeMethod;

name是java中定义的native函数的名字,fnPtr是函数指针,也就是C++中java native函数的实现。signature是java native函数的签名,可以认为是参数和返回值。比如(LMyJavaClass;)V,表示函数的参数是LMyJavaClass(Java类MyJavaClass),返回值是void。对于基本类型,对应关系如下:

1
2
3
4
5
6
7
8
9
10
字符     Java类型     C/C++类型
V void void
Z jboolean boolean
I jint int
J jlong long
D jdouble double
F jfloat float
B jbyte byte
C jchar char
S jshort short

  • 数组则以”[“开始,用两个字符表示,比如int数组表示为[I,以此类推。
  • 如果参数是Java类,则以”L”开头,以”;”结尾,中间是用”/“隔开包及类名,例如Ljava/lang/String;,而其对应的C++函数的参数为jobject,一个例外是String类,它对应C++类型jstring。
  • (DD)I 表示2个double的参数,返回值为void

举几个例子:

1
2
3
4
5
6
7
8
9
10
static JNINativeMethod gMethods[] = {
{"processDirectory", "(Ljava/lang/String;Ljava/lang/String;Landroid/media/MediaScannerClient;)V", (void *)android_media_MediaScanner_processDirectory},
{"processFile", "(Ljava/lang/String;Ljava/lang/String;Landroid/media/MediaScannerClient;)V",
(void *)android_media_MediaScanner_processFile},
{"setLocale", "(Ljava/lang/String;)V", (void *)android_media_MediaScanner_setLocale},
{"extractAlbumArt", "(Ljava/io/FileDescriptor;)[B", (void *)android_media_MediaScanner_extractAlbumArt},
{"native_init", "()V", (void *)android_media_MediaScanner_native_init},
{"native_setup", "(DD)V", (void *)android_media_MediaScanner_native_setup},
{"native_finalize", "(BDI)V", (void *)android_media_MediaScanner_native_finalize},
};

可见动态注册时,不仅仅会将C/C++函数与Java调用的函数名做映射,也会与具体的调用类class进行绑定。因此,调用第三方so文件时,Java中定义的类名和package名都要严格对应

动态注册的好处是:

  1. C/C++中函数命名自由,不必拘泥特定的命名方式;
  2. 效率高。传统方式下,Java类call本地函数时,通常是依靠VM去动态寻找.so中的本地函数(因此它们才需要特定规则的命名格式),而使用RegisterNatives将本地函数向VM进行登记,可以让其更有效率的找到函数;
  3. 运行时动态调整本地函数与Java函数值之间的映射关系,只需要多次call RegisterNatives()方法,并传入不同的映射表参数即可。

0x03. 搜查静态注册函数

现在要解决上一篇文章留下的问题:若如果so文件加壳或严重混淆了,仅仅知道Java VM调用的Native方法是getNativeValue,怎么在so文件中找到相应的函数呢?或有没有更方便的方法定位so库里的方法。

若调用第三方so时,调用者的package和class和so文件中不对应,就会报Runtime java.lang.UnsatisfiedLinkError错误。package或class名不正确时:

所以要调用第三方so库时,只需要在APP中定义正确的package/class和调用正确的method名,JVM就能找到对应的Native方法。如果我们只拿到一个so库,或者APP加固了无法看到调用这so时的package和class名,有多种方法可以找到这个package/class。

若so库只用了静态注册,所有JNI方法都可以在.dynamic导出函数表里找到。在IDA反编译:

或者简单使用objdump:

在方法名中就可以明显地看到package/class。

如果用frida,就是:

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
import frida
import sys

def on_message(message, data):
print(message["payload"])

jscode = '''
var moduleName = "/data/app/com.application-1/lib/arm/libnative.so"
Module.load(moduleName)
Module.enumerateExports(moduleName, {
onMatch: function(exports){
send(exports.name);
},
onComplete: function(){
send("Stop");
}
});

'''

device = frida.get_device_manager().enumerate_devices()[-1]
pid = device.spawn(["com.arbitary.application"]) # 任意APP
session = device.attach(pid)
device.resume(pid)
script = session.create_script(jscode)
script.on('message', on_message)
script.load()

这里有个坑就是路径必须是/data/app/…/的so,不能是/data/data/package/../,也不能是/sdcard啥的。不过这里也只能导出静态注册的导出函数。

0x04. IDA静态搜查so

对于动态注册的Native方法,可使用IDA来进行静态探索。动态注册函数一般都在JNI_Onload中进行,所以可以使用IDA搜查JNI_Onload函数。

打开了上篇文章中的so文件,点开JNI_Onload F5后,是这样的:

这里可看到参数为 int a1 ,IDA未能识别到参数的类型。根据jni_internal 中加载so时的源码,可见JNI_Onload的原型为typedef int (JNI_OnLoadFn)(JavaVM, void),参数为JavaVM\。根据这个,在IDA中将Jni_Onload的参数修改类型,Ctrl+Y修改为JavaVM*。同时,看到汇编中通过v3+偏移量进行函数调用,猜测v3可能为JNIEnv*类型,所以同时将v3的类型Ctrl+Y改为JNIEnv。这样能帮助识别到一些函数:

可见这里先使用了JNIEnv
->FindClass,再调用了JNIEnv*->RegisterNatives。registerNativeMethods的原型如下:

1
RegisterNatives(env, "com/testing/jni/HelloWorldJNI",gMethods, sizeof(gMethods) / sizeof(gMethods[0]));

那么说明一定能在Jni_Onload这个函数中,找到说明映射关系的gMethods数组。直接看Jni_Onload的汇编,在一处发现了package/class字符串:

com/../的package字符串在R1寄存器,为registerNativeMethods的第1个参数,那直接找R2,就能找到第2个参数gMethods。

根据NativeMethods结构体:

1
2
3
4
5
6
/*used in RegisterNatives to describe native method name, signature, and function pointer.*/
typedef struct {
char *name;
char *signature;
void *fnPtr;
} JNINativeMethod;

可分析出IDA中数据区的数据就是我们要的gMethods数组。在off_D7004处右键unDefine,再右键Structure,IDA自动识别了类型,选择JNI就重组为更方便看的样子,可看到第一个结构即指向了我们之前要的getNativeValue函数。

0x05. Frida动态搜查so

除了静态的方法,也可以用Frida来找到所有的动态注册方法。Frida可以在Java层和Native层对调用函数进行插桩hook,那么就可以用Frida对registerNatives方法加钩,在系统调用registerNatives方法动态注册函数时,将参数输出。

在实现这个方法时,我先参考了Jim West 的代码:

这里hook了libart.so中的registerNatives函数,是非常可靠的思路。但是用这个代码调了一周多后,修改了正确的RegisterNative的函数名了,也未能成功地输出参数,onEnter事件根本没有触发。之后,参考了另一段代码:Reveal native methods。在这段代码的基础上进行了修改,成功输出了我们想要的动态注册参数:

但这里有一个问题,由于ASLR,此处输出的Native函数地址是动态变化的,根据这个地址无法追溯到具体的函数。

所以需要再修改一下代码,以下代码在hook里registerNatives的函数后,会所属的module和address存起来,之后再使用Process.enumerateModules和module.enumerateExports来读取相关so库的输出表,找到相应地址的函数名。根据这个函数名,一定程度上可在IDA中判断出调用的Native方法:

Python代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import frida
import sys

def on_message(message, data):
if message["type"] == "send":
print(message["payload"])
else:
print(message)

jscode = open("script.js","r").read()
device = frida.get_device_manager().enumerate_devices()[-1]
pid = device.spawn(["com.testing.package"])
session = device.attach(pid)
device.resume(pid)
script = session.create_script(jscode)
script.on('message', on_message)
script.load()
sys.stdin.read()

Js代码:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
var ModuleScanning = function(args) {
Process.enumerateModules({
onMatch: function(exp){
if(exp.name in args){
Module.enumerateExports(exp.name, {
onMatch: function(exports){
if(args[exp.name].indexOf(String(exports.address)) > -1){
args[exports.address] = exports.name;
}
},
onComplete: function(){}
});
}
},
onComplete: function(){}
});
}
var RevealNativeMethods = function() {
var pSize = Process.pointerSize;
var env = Java.vm.getEnv();
var RegisterNatives = 215, FindClassIndex = 6; // search "215" @ https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html
var jclassAddress2NameMap = {};
var JNIResult = [];
var JNIAddress = {};
function getNativeAddress(idx) {
return env.handle.readPointer().add(idx * pSize).readPointer();
}
// intercepting FindClass to populate Map<address, jclass>
Interceptor.attach(getNativeAddress(FindClassIndex), {
onEnter: function(args) {
jclassAddress2NameMap[args[0]] = args[1].readCString();
},
onLeave: function(args){}
});
// RegisterNative(jClass*, .., JNINativeMethod *methods[nMethods], uint nMethods) // https://android.googlesource.com/platform/libnativehelper/+/master/include_jni/jni.h#977
Interceptor.attach(getNativeAddress(RegisterNatives), {
onEnter: function(args) {
for (var i = 0, nMethods = parseInt(args[3]); i < nMethods; i++) {
/*
https://android.googlesource.com/platform/libnativehelper/+/master/include_jni/jni.h#129
typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;
*/
var structSize = pSize * 3; // = sizeof(JNINativeMethod)
var methodsPtr = ptr(args[2]);
var methodName = methodsPtr.add(i * structSize).readPointer();
var signature = methodsPtr.add(i * structSize + pSize).readPointer();
var fnPtr = methodsPtr.add(i * structSize + (pSize * 2)).readPointer(); // void* fnPtr
var jClass = jclassAddress2NameMap[args[0]].split('/');
var moduleName = DebugSymbol.fromAddress(fnPtr)['moduleName'] // https://www.frida.re/docs/javascript-api/#debugsymbol
JNIResult.push(JSON.stringify({
module: moduleName,
package: jClass.slice(0, -1).join('.'),
class: jClass[jClass.length - 1],
method: methodName.readCString(), // char* name
signature: signature.readCString(), // char* signature TODO Java bytecode signature parser { Z: 'boolean', B: 'byte', C: 'char', S: 'short', I: 'int', J: 'long', F: 'float', D: 'double', L: 'fully-qualified-class;', '[': 'array' } https://github.com/skylot/jadx/blob/master/jadx-core/src/main/java/jadx/core/dex/nodes/parser/SignatureParser.java
address: String(fnPtr),
}));
if(moduleName in JNIAddress){
JNIAddress[moduleName].push(String(fnPtr));
}
else{
JNIAddress[moduleName] = [];
JNIAddress[moduleName].push(String(fnPtr));
}
}
},
onLeave: function(args){
ModuleScanning(JNIAddress);
for (var i = 0; i < JNIResult.length; i++) {
var message = JNIResult[i];
var address = JSON.parse(message)['address'];
var length = message.length;
console.log(message.slice(0,length-1)+", \"Native\":\""+JNIAddress[address]+"}\"");
}
}
});
}
Java.perform(RevealNativeMethods);

这里有个小细节需要注意,在分析动态加载的so库时,需要在程序已经完成System.loadLibrary时,frida才能找到这个module,因此不能在一开始就使用Process.enumerateModules和Module.enumerateExports。在这里,我在动态注册结束后,onLeave时再去找so库的输出函数表,此时so库一定已经加载好了。


如果想更精确地进一步确认调用的Native函数,以便在IDA中迅速定位进行分析,也可以用另一种方法。在这段代码中,输出了registerNatives方法的参数,以及相关module的base address,以此可在IDA中进行定位调用的相关函数:

以getNativeValue为例,函数地址0xde2b0b85,so文件基地址0xde27d000,得到偏移:

offset =0xde2b0b85 - 0xde27d000 = 0x00033B85

此时打开IDA静态so文件,因为IDA加载文件时,基址为0,因此直接跳转到偏移值0x00033B85,即可找到对应的函数:

python代码和上面一样。JS代码:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
var ModuleScanning = function(args) {
Process.enumerateModules({
onMatch: function(exp){
if(exp.name in args){
send("[*] Module:"+exp.name+",Address:"+exp.base);
}
},
onComplete: function(){}
});
}
var RevealNativeMethods = function() {
var pSize = Process.pointerSize;
var env = Java.vm.getEnv();
var RegisterNatives = 215, FindClassIndex = 6; // search "215" @ https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html
var jclassAddress2NameMap = {};
var moduleDict = {};
function getNativeAddress(idx) {
return env.handle.readPointer().add(idx * pSize).readPointer();
}
// intercepting FindClass to populate Map<address, jclass>
Interceptor.attach(getNativeAddress(FindClassIndex), {
onEnter: function(args) {
jclassAddress2NameMap[args[0]] = args[1].readCString();
},
onLeave: function(args){
}
});
// RegisterNative(jClass*, .., JNINativeMethod *methods[nMethods], uint nMethods) // https://android.googlesource.com/platform/libnativehelper/+/master/include_jni/jni.h#977
Interceptor.attach(getNativeAddress(RegisterNatives), {
onEnter: function(args) {
for (var i = 0, nMethods = parseInt(args[3]); i < nMethods; i++) {
/*
https://android.googlesource.com/platform/libnativehelper/+/master/include_jni/jni.h#129
typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;
*/
var structSize = pSize * 3; // = sizeof(JNINativeMethod)
var methodsPtr = ptr(args[2]);
var methodName = methodsPtr.add(i * structSize).readPointer();
var signature = methodsPtr.add(i * structSize + pSize).readPointer();
var fnPtr = methodsPtr.add(i * structSize + (pSize * 2)).readPointer(); // void* fnPtr
var jClass = jclassAddress2NameMap[args[0]].split('/');
var moduleName = DebugSymbol.fromAddress(fnPtr)['moduleName'];
console.log(JSON.stringify({
module: moduleName, // https://www.frida.re/docs/javascript-api/#debugsymbol
package: jClass.slice(0, -1).join('.'),
class: jClass[jClass.length - 1],
method: methodName.readCString(), // char* name
signature: signature.readCString(), // char* signature TODO Java bytecode signature parser { Z: 'boolean', B: 'byte', C: 'char', S: 'short', I: 'int', J: 'long', F: 'float', D: 'double', L: 'fully-qualified-class;', '[': 'array' } https://github.com/skylot/jadx/blob/master/jadx-core/src/main/java/jadx/core/dex/nodes/parser/SignatureParser.java
address: fnPtr
}));
moduleDict[moduleName] = "1";
}
},
onLeave: function(args){
ModuleScanning(moduleDict);
}
});
}
Java.perform(RevealNativeMethods);

0x06. 参考

https://github.com/longlinht/android_source
https://www.cnblogs.com/wainiwann/p/3835904.html
http://xdprof.sourceforge.net/doxygen/structJNIEnv__-members.html
https://www.cnblogs.com/aliflycoris/p/5507236.html
https://www3.ntu.edu.sg/home/ehchua/programming/java/JavaNativeInterface.html#zz-2.4
https://stackoverflow.com/questions/51811348/find-manually-registered-obfuscated-native-function-address

CATALOG
  1. 1. 0x00. JNI接口
  2. 2. 0x01. 静态注册
  3. 3. 0x02. 动态注册
  4. 4. 0x03. 搜查静态注册函数
  5. 5. 0x04. IDA静态搜查so
  6. 6. 0x05. Frida动态搜查so
  7. 7. 0x06. 参考