这一节,我们来学习unidbg提供的两种模拟调用方式:符号调用和地址调用。
首先重新编译一下APK,使它生成全架构的so文件,如图2—11所示。
将Android Studio左侧项目结构图切换为Project视图,然后找到项目中编译出来的app-debug.apk,如图2—12所示。
图2—11 编译APK操作
图2—12 app-debug.apk目录
接着使用终端进入相应目录,使用以下命令解压APK文件。
unzip app-debug.apk
使用IDEA打开之前的unidbg项目,在unidbg-android/src/test/java下依照引用so文件的全类名来编写相同的类。然后将上面解压的APK中的lib/arm64-v8a中的so及APK文件都复制到该目录下,最终效果如图2—13所示。
图2—13 代码编写准备界面
首先编写MainActivity构造方法来模拟so执行所需的环境。
由代码可知,前4行将常用的变量存储为成员变量,以便在不同的成员函数间使用。
初始化环境的代码与1.3.3节的代码大致相同。只是在创建vm虚拟机时,传入了原本的APK文件,使unidbg可以依据APK为模拟环境做一些初始化工作。同时通过加载so文件得到的dalvikModule对象的getModule()方法,得到操作so的对象并将其保存到成员变量中备用。
unidbg支持两种调用so中函数的方式:符号调用和地址调用。
符号调用方式的代码示例如下:
public void callMd5(){
DvmObject obj=ProxyDvmObject.createObject(vm,this);
String data="dta";
DvmObject dvmObject=obj.callJniMethodObject(emulator,"md5(Ljava/lang/
String;)Ljava/lang/String;",data);
String result=(String)dvmObject.getValue();
System.out.println("[symble]Call the so md5 function result is==>"+result);
}
相关代码与1.3.3节的示例代码区别不大。先根据传入的this参数生成一个调用so的代理对象obj,该处传入的this参数即com.dta.lesson2.MainActivity对象,如果全类名无法与原APK中调用so的java类对应,则unidbg将无法补全相应的函数名。
由于md5()方法的返回值为String类型,所以应当使用callJniMethodObject()方法来调用md5()方法,同时使用DvmObject对象来接收该返回值。除了基本类型之外,其余的类型都要返回Object对象,相关的API如图2—14所示。
图2—14 unidbg支持的callJniMethod相关的API
对于得到的md5()方法的结果DvmObject对象,我们可以通过调用它的getValue()方法来获得相应的值。由于我们已知函数的返回值为String类型,因此可以将其强制转换为String类型,之后将获得的值输出。
unidbg中另一种常用的函数调用方式是地址调用。一般情况下,我们所需的函数可能没有导出,所以无法进行符号调用。
地址调用方式的代码示例如下:
在使用符号调用方式时,unidbg帮助我们完成了很多操作,例如拼接函数名、填充参数等。但在使用地址调用方式时,这些操作需要我们自己来完成。
根据函数的原型md5(_JNIEnv * env,jobject thiz,jstring str),我们需要手动构造参数列表,补全相应参数,才能对相关函数进行调用。
首先通过getJNIEnv()方法获取JNIEnv,并使用指针类型来存储。然后使用与符号调用相同的方式创建一个调用so函数的代理对象,并将其作为所需的第二个参数jobject。对于第三个参数需要注意的是,在unidbg中,当传入参数为非指针和Number类型时,都需要先将其定义为DvmObject对象并添加到VM中,才能够使用该参数。而对于常用的String类,unidbg对其做了相应的封装,这里新建了一个StringObject对象来作为第三个参数。
之后定义一个List<Object>参数列表,并将构造的三个参数添加到其中。然后使用module.callFunction()方法来对函数进行调用,该方法需要三个参数,分别为模拟器对象、函数的相对偏移地址和参数数组。
对于函数偏移的获取,我们需要使用IDA载入该so函数以找到相应的获取函数,如图2—15所示。
可以看到md5()方法的偏移为0x8E80,但由于该汇编指令的格式为thumb,所以需要对函数地址进行加1操作,最终此处填入的参数的值为0x8E81。对于arm指令与thumb指令,可以通过指令长度来区分:thumb的指令长度为2字节,而arm指令长度为4字节。此外,arm指令中是没有PUSH指令的,这也可以作为区分的依据。
调用函数后获得的返回值为Number类型,这与函数的实际返回值String类型是不相符的。不过不用着急,我们可以用两种方案来处理:对于基本数值类型或者布尔类型,可以直接通过Number获取相应的值;而对于Object对象来说,Number中实际存储的是Object对象的hash值,因此需要先通过vm.getObject()方法来获取DvmObject对象(传入的参数为number.intValue(),即Object对象的hash值),之后即可通过getValue()方法来获取对象中存储的值,并将其强制转换为String类型后输出。
图2—15 使用IDA获取函数偏移
main()方法就比较简单了,仅仅是实例化MainActivity对象后使用了符号调用和地址调用这两个方式来完成so中函数的调用。
public static void main(String[]args){
long start=System.currentTimeMillis();
MainActivity mainActivity=new MainActivity();
System.out.println("load the vm"+(System.currentTimeMillis()-start)+"ms");
mainActivity.callMd5();
mainActivity.call_address();
}
输出如下,可以发现函数的输出是正确计算出来的。
Picked up_JAVA_OPTIONS:-Dawt.useSystemAAFontSettings=on-Dswing.aatext=true
load the vm 16020ms
[symble]Call the so md5 function result is==>36072180305f072a2e2c7ea96eedf034
[addr]Call the so md5 function result is==>36072180305f072a2e2c7ea96eedf034
Process finished with exit code 0
在上述代码中,我们接触到几个常用的接口,如AndroidEmulator、Memory、VM等。
AndroidEmulator是一个抽象的Android模拟器接口。它作为一个枢纽,协调unidbg中大多数模块的工作,至关重要。我们的大多数操作需要借助它完成。
而AndroidEmulatorBuilder可以帮助我们快速创建AndroidEmulator实例。例如:
AndroidEmulator emulator=AndroidEmulatorBuilder
//指定32位处理器
.for32Bit()
//指定64位处理器
//.for64bit()
//添加后端工厂
.addBackendFactory(new DynarmicFactory(true))
//指定进程名,推荐以Android包名作为进程名
.setProcessName("com.github.unidbg")
//设置根路径,此路径相当于Android中的根目录
.setRootDir(new File("target/rootfs/default"))
//生成AndroidEmulator实例
.build();
我们也可以通过AndroidEmulator实例来获取内存操作接口Memory实例:
Memory memory=emulator.getMemory();
创建DalvikVM虚拟机:
//创建虚拟机
VM dalvikVM=emulator.createDalvikVM();
//创建虚拟机并指定APK文件
VM dalvikVM=emulator.createDalvikVM(new File("apk file path"));
Memory内存接口主要提供内存管理和ELF文件加载两个功能。
对于ELF文件的加载,我们一般用加载APK的形式实现,因为unidbg会帮助我们进行一些解析处理,可以节省很多时间。
Module module=emulator.loadLibrary(new File("file path"),true);
而VM对象的主要作用就是代理一套JNI,帮助用户借助JNI来操作Java层。
VM的实际功能是调用JNI_OnLoad函数。某些JNI方法在JNI_OnLoad函数进行动态绑定时需调用此函数才能够调用JNI方法,所以这里推荐在加载模块后再执行此方法。
//参数一:模拟器实例
//参数二:要执行JNI_OnLoad函数的模块
dalvikVM.callJNI_OnLoad(emulator,module);
或者控制JNI交互的详细日志输出:
//设置是否输出JNI运行日志
vm.setVerbose(true);
对于CallMethod系列的方法,第二个参数是方法签名,我们只需传入部分名称即可,这是因为unidbg在底层逻辑中进行了相应的处理。
对于上面的项目,按住Ctrl后单击callJniMethodObject()方法,可以跟进相应的实现,之后跟进其内部的callJniMethod()方法,再跟进findNativeFunction()方法,可以查看unidbg找到相应函数的过程,如图2—16所示。
图2—16 unidbg findNativeFunction()实现
可以看到,在第239行unidbg根据对传入对象的全类名与函数名进行拼接来得到最终的函数名。
而使用callFunction()方法时需要传入参数,除了基本数据类型之外,其余数据类型都要封装为DvmObject并传入VM虚拟机后才能够使用,具体后文会有详细的案例。