0%

JNI函数的Hook与快速定位

JNI函数的Hook与快速定位

示例应用的so文件加载时机是在一些按钮被点击后才加载,注意

第一步也是要先得到JNI函数的地址,有两种方式

  1. 枚举libart的符号表,得到对应的JNI函数地址后Hook
  2. 通过计算地址的方式来Hook,比较麻烦:先得到JNEnv结构体的地址,再通过偏移得到对应JNI函数指针的地址,最后通过指针得到JNI函数的地址。需要注意的是**32位(4字节)64位(8字节)**的指针长度不同会导致偏移地址不同

如果用C/C++开发so文件,JNIEnv*指针变量最终都指向JNINativePoniter结构体。

要获取JNIEnv*指针变量的内存地址,Frida中提供了相应的方法来获取,部分源码如下

1
2
3
4
5
6
7
8
9
declare namespace Java {
...
const vm: VM;
interface VM {
...
getEnv():Env;
tryGetEnv():Env | null;
}
}

getEnv和tryGetEnv都可以返回Frida包装后的JNIEnv对象,如果没有找到,前者会抛出异常错误,后者会返回一个NULL

1
2
console.log("env:",JSON.stringify(Java.vm.tryGetEnv()));
//env: {"handle":"0x76c5e1af30","vm":{"handle":"0x76d5e07c10"}}

handle属性记录的是原始JNIEnv* 指针变量的内存地址,所存放的就是JNIEnv结构体的地址。利用readPointer可以将改内存的数据转化为地址

所以说,获得JNIEnv结构体的地址的最直接的方式

1
Java.vm.tryGetEnv().handle.readPointer()

1

1
2
3
4
struct JNINativeInterface{
...
jstring (*NewStringUTF)(JNIEnv* ,const char*);
}

env.handle的类型是NativePointer,但env的类型不是,所以不能直接env.NativePointer。但可以通过Memory.readPointer(env)或者ptr(env).NativePointer()来达到目的,推荐使用后者

枚举libart符号表来Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hook_jni() {
var _symbols = Process.getModuleByName("libart.so").enumerateSymbols();
var newStringUtf = null;
for (let i = 0; i < _symbols.length; i++) {
var _symbol = _symbols[i];
if(_symbol.name.indexOf("CheckJNI") == -1 && _symbol.name.indexOf("NewStringUTF") != -1){
newStringUtf = _symbol.address;
}
}
Interceptor.attach(newStringUtf, {
onEnter: function (args) {
console.log("newStringUtf args: ", args[1].readCString());
}, onLeave: function (retval) {
console.log("newStringUtf retval: ", retval);
}
});
}
hook_jni();

这些api在so层hook都讲过的,都能看懂,只是这过滤了一下包含CheckJNI的符号

再来介绍一下Process.pointerSize这个方法,返回当前进程的指针大小,这样就不用可以去区别32位和64位指针大小的区别了,也解决了因指针大小不一样而导致偏移量不一样的问题。

1
2
3
4
5
6
var envAddr = Java.vm.tryGetEnv().handle.readPointer();
var NewStringUTF = envAddr.add(167 * Process.pointerSize);
var NewStringUTFAddr = envAddr.add(167 * Process.pointerSize).readPointer();
console.log(hexdump(NewStringUTF));//NewStringUTF函数指针的内存地址
console.log(hexdump(NewStringUTFAddr));//NewStringUTFAddr就是NewStringUTF函数的地址(不是指针的)
console.log(Instruction.parse(NewStringUTFAddr).toString());

当得到了函数地址后,可以通过Frida的API,Instruction.parse(NewStringUTFAddr).toString()方法,可以将指令转化为汇编代码。

Hook代码

1
2
3
4
5
6
7
8
9
10
11
12
function hook_jni2() {
var envAddr = Java.vm.tryGetEnv().handle.readPointer();
var NewStringUTFAddr = envAddr.add(167 * Process.pointerSize).readPointer();
Interceptor.attach(NewStringUTFAddr, {
onEnter: function (args) {
console.log("FindClass args: ", args[1].readCString());
}, onLeave: function (retval) {
console.log("FindClass retval: ", retval);
}
});
}
hook_jni2();

利用Frida API主动调用JNI函数

前文提到过,通过Java.vm.tryGetEnv()就可以得到Frida包装过的JNIEnv对象,然后就可以接着通过Frida封装的API来调用JNI函数。

这里需要注意,JNI的函数命名采用大驼峰命名法,Frida封装的采用小驼峰命名法,并且UTF只有首字母大写。

举两个例子:

  1. JNI-NewStringUTF;Frida-newStringUtf;
  2. JNI-GetStringUTFChars;Frida-getStringUtfChars

其余的到下面的链接看

https://github.com/frida/frida-java-bridge

另外两者的参数也不同,JNI的参数第一个都是JNIEnv*,frida不需要传这个参数

so层文件打印函数栈

在Frida中可以通过Thread.backtrace来获取函数栈,看看源码

1
2
3
4
5
6
7
delcare namespace Thread{

function backtrace(context?:CpuContext,backtracer?:Backtracer):NativePointer[];

function sleep(delay:number):void;

}

backtrace用于获取函数栈,掺入两个可省略的参数,返回NativePointer类型的地址数组,但两个参数不推荐省略。

sleep接受一个数值用于挂起当前线程,并在多少秒后恢复

backtrace参数需要传入BackTracer类型

1
2
3
4
declare class BackTracer{
static ACCURATE: Baktracer;//这个获取到的信息比较准确,但不是所有情况都适用
static FUZZY: Backtracer;//这个可能不是很准确,但对任何二进制文件都有效
}

实际情况可以两个都试一下。

DebugSymbol.fromAddress。该方法用于获取对应地址的调试信息

其源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
declare class DebugSymbol{
address: NativePointer; //符号所在地址
name: string | null; //符号名
moduleName: string | null; //符号所在模块名
fileName: string | null; //符号所在的文件名
lineNumber: number | null; //符号在文件里的行号
static fromAddress(address: NativePointerValue): DebugSymbol; //通过地址获取符号
static fromName(name: string):DebugSymbol; //通过名字获取符号
//传入函数名,返回该函数所在地址;如果有多个同名函数,则返回第一个,没找到则报错。
static getFunctionByName(name: string): NativePointer;
//传入函数名,以数组形式返回函数地址。
static findFunctionsNamed(name: string): NativePointer[];
static findFunctionsMatching(glod:string): NativePointer[];
static load(path:string):void; //加载指定模块中的符号
toString():string;
}

来个简单测试

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
var debsym = DebugSymbol.fromName("strcat");
console.log("address: ", debsym.address);
console.log("name: ", debsym.name);
console.log("moduleName: ", debsym.moduleName);
console.log("fileName: ", debsym.fileName);
console.log("lineNumber: ", debsym.lineNumber);
console.log("toString: ", debsym.toString());

console.log("getFunctionByName: ", DebugSymbol.getFunctionByName("strcat"));
console.log("findFunctionsNamed: ", DebugSymbol.findFunctionsNamed("JNI_OnLoad"));
console.log("findFunctionsMatching: ", DebugSymbol.findFunctionsMatching("JNI_OnLoad"));

/*

address: 0x7896f75230
name: strcat
moduleName: libc.so
fileName:
lineNumber: 0
toString: 0x7896f75230 libc.so!strcat
getFunctionByName: 0x7896f75230
findFunctionsNamed: 0x75fd9e1910,0x75fd9a2ee8,0x75fd957fb0,0x75fc0abd90,0x75f0be3070,0x75ec6ad2a0,0x75ecb22020,0x75ec6380b0,0x75ec5c8020,0x75e8b69208,0x75f4620c48
findFunctionsMatching: 0x75e8b69208,0x75ec5c8020,0x75ec6380b0,0x75ec6ad2a0,0x75ecb22020,0x75f0be3070,0x75f4620c48,0x75fc0abd90,0x75fd957fb0,0x75fd9a2ee8,0x75fd9e1910

*/

so层主动调用任意函数

首先介绍一个Frida的API

1
new NativeFunction(address,returnType,argTypes[,abi])

该API需要传入函数地址,返回值类型,参数类型数组和可省略的abi数组。

returnTypes和argTypes支持很多种类型: void,pointer,int,uint,(u)long,(u)char,float,double,int8,uint8,(u)int16,(u)int32,(u)int64,bool,size_t,ssize_t

其实传给new NativeFunction的返回值和参数类型不用非常准确也是可以调用函数的

1
2
3
4
5
6
7
8
9
10
11
12
Java.perform(function () {
var soAddr = Module.findBaseAddress("libxiaojianbang.so");
var funAddr = soAddr.add(0x16BC);
var jstr2cstr = new NativeFunction(funAddr, 'pointer', ['pointer','pointer']);
var env = Java.vm.tryGetEnv();
//主动调用jni函数newStringUtf,将JavaScript的字符串转为Java字符串
var jstring = env.newStringUtf("xiaojianbang");
var retval = jstr2cstr(env.handle, jstring);
//var retval = jstr2cstr(env, jstring);
console.log(retval.readCString());
});
//xiaojianbang

[,abi]的个数为除了JNI特有的参数外(如JNIEnv*,jclass/jobject),函数本身有的参数加一个返回值

通过NativeFunction主动调用JNI函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var symbols = Process.getModuleByName("libart.so").enumerateSymbols();
var NewStringUTFAddr = null;
var GetStringUTFCharsAddr = null;
for (var i = 0; i < symbols.length; i++) {
var symbol = symbols[i];
if(symbol.name.indexOf("CheckJNI") == -1 && symbol.name.indexOf("NewStringUTF") != -1){
NewStringUTFAddr = symbol.address;
}else if (symbol.name.indexOf("CheckJNI") == -1 && symbol.name.indexOf("GetStringUTFChars") != -1){
GetStringUTFCharsAddr = symbol.address;
}
}
var NewStringUTF = new NativeFunction(NewStringUTFAddr, 'pointer', ['pointer', 'pointer']);
var GetStringUTFChars = new NativeFunction(GetStringUTFCharsAddr, 'pointer', ['pointer', 'pointer', 'pointer']);

var jstring = NewStringUTF(Java.vm.tryGetEnv().handle, Memory.allocUtf8String("xiaojianbang"));
console.log(jstring);

var cstr = GetStringUTFChars(Java.vm.tryGetEnv(), jstring, ptr(0));
console.log(cstr.readCString());
/*
0x1
xiaojianbang
*/

查看上面两个函数NewStringUTF;GetStringUTFChars在jni中的声明

1
2
3
4
5
struct JNINativeInterface{
...
jstring (*NewStringUTF)(JNIEnv*,const char*);
const char* (*getStringUTFChars)(JNIEnv*, jstring,jboolean*);
};

在C语言中,jboolean可以用一个字节的数值代替,故相关代码也可改为以下形式

1
2
3
var cstr = GetStringUTFChars(Java.vm.tryGetEnv(),  jstring,  Memory.alloc(1).writeS8(1));
console.log(cstr.readCString());
//xiaojianbang

而且在使用new NativeFunction声明函数指针时,某些参数与jni.h中不一致也是可以调用的,如GetStringUTFChars,可以只传两个参数

1
2
3
4
5
//......
var GetStringUTFChars = new NativeFunction(GetStringUTFCharsAddr, 'pointer', ['pointer', 'pointer']);
var cstr = GetStringUTFChars(Java.vm.tryGetEnv(), jstring);
console.log(cstr.readCString());
//xiaojianbang

JNI函数注册的快速定位

  1. JNI静态注册可以Hook dlsym 静态注册的方式在一开始并没有绑定so层的函数,当Java的native函数首次被调用,系统会按规则构建出对应的函数名,通过dlsym去每一个so文件中寻找符号,找到后进行绑定。
  2. JNI函数动态注册可以Hook RegisterNatives,这个函数就是用来动态注册的,不必多说
  3. Hook其他关键的JNI函数,如NewStringUTF,再打印函数栈。当然也可以试试jnitrace

Hook dlsym

dlsym的声明

1
2
void * dlsym(void * handle,const char* symbol);
//第一个参数是dlopen函数之后返回的句柄,第二个是要求获取的函数的名称,返回值是void *,指向函数的地址。

handle时使用dlopen函数之后返回的句柄,symbol是要求获取函数的名称,返回值是void* 指向函数的地址。具体看

dlsym()

1
2
3
4
5
6
7
8
9
10
11
12
13
var dlsymAddr = Module.findExportByName("libdl.so", "dlsym");
Interceptor.attach(dlsymAddr, {
onEnter: function (args) {
this.args1 = args[1];
}, onLeave: function (retval) {
var module = Process.findModuleByAddress(retval);
if(module == null) return;
console.log(this.args1.readCString(), module.name, retval, retval.sub(module.base));
}
});
//Java_com_xiaojianbang_ndk_NativeHelper_add 0x75072acacc单击cadd
//Java_com_xiaojianbang_ndk_NativeHelper_md5 0x75072acf2c单机cmd5

经过测试发现,上面那两条信息只能输出一次,就是在那两个按钮在被初次点击的时候。因为静态注册的native函数首次被调用才会经过dlsym函数,之后就不再触发。

而在jni动态注册中,在系统加载完so文件后,才去获取JNI_Onload函数地址,使用的也是dlsym

Hook RegisterNatives获取函数地址

RegisterNatives定义在libart.so中,可以通过枚举符号来获取地址。

1
2
3
4
5
6
7
8
9
var RegisterNativesAddr = null;
var _symbols = Process.findModuleByName("libart.so").enumerateSymbols();
for (var i = 0; i < _symbols.length; i++) {
var _symbol = _symbols[i];
if (_symbol.name.indexOf("CheckJNI") == -1 && _symbol.name.indexOf("RegisterNatives") != -1) {
RegisterNativesAddr = _symbols[i].address;
}
}
console.log(RegisterNativesAddr);

RegisterNatives在jni.h,我觉得已经不用再看了,就是一个结构体,其成员:第0个是在函数java层的名字,第二个是签名,第三个是函数在cpp文件中的指针一般为(void )开头,参数的话有四个JNIEnv * ,jcalss,JNINativeMethod ,jint

Hook代码如下

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
var RegisterNativesAddr = null;
var _symbols = Process.findModuleByName("libart.so").enumerateSymbols();
for (var i = 0; i < _symbols.length; i++) {
var _symbol = _symbols[i];
if (_symbol.name.indexOf("CheckJNI") == -1 && _symbol.name.indexOf("RegisterNatives") != -1) {
RegisterNativesAddr = _symbols[i].address;
}
}

Interceptor.attach(RegisterNativesAddr, {
onEnter: function (args) {
var env = Java.vm.tryGetEnv();
var className = env.getClassName(args[1]);
var methodCount = args[3].toInt32();

for (let i = 0; i < methodCount; i++) {
var methodName = args[2].add(Process.pointerSize * 3 * i)
.readPointer().readCString();
var signature = args[2].add(Process.pointerSize * 3 * i)
.add(Process.pointerSize).readPointer().readCString();
var fnPtr = args[2].add(Process.pointerSize * 3 * i)
.add(Process.pointerSize * 2).readPointer();
var module = Process.findModuleByAddress(fnPtr);
console.log(className, methodName, signature, fnPtr, module.name, fnPtr.sub(module.base));
}
}, onLeave: function (retval) {
}
});

这里JNINativeMethod是一个结构体数组,每个成员占3个指针长度,在不知道有多少个成员的情况下,使用循环进行遍历,没循环一次,再偏移3个指针长度。

jnitrace使用

1
jnitrace -m attach - xxxx.so com.xxxxx.xxxxx //包名以frida-ps -U显示的为优先

-l xxx.so用于指定要跟踪的库,此参数可多次使用,或用*来跟踪所有库

e.g:-l libnativ-lib.so -l libanother-lib.so 或-l *

貌似遇到有壳的就不弹信息了。