从实践中学习unidbg使用(一)
一、引言
目标APK:lvzhou.apk
目标方法声明处的Dex:target.dex。样本APK在Java层做了加壳(360加固),所以建议直接使用附件中的target.dex,或使用其他工具进行脱壳(frida-dump即可)
目标方法实现:liboasiscore.so
二、任务描述
首先先反编译target.dex,但使用jadx反编译时报错,于是就使用了GDA进行反编译,找到NativeApi类,其中s方法就是本篇的目标。
package com.weibo.xvideo.NativeApi;
import java.lang.Object;
import java.lang.String;
import java.lang.System;
public final class NativeApi // class@0011ea from target.dex
{
public void NativeApi(){
super();
System.loadLibrary("oasiscore");
}
public native final String d(String p0);
public native final String dg(String p0,boolean p1);
public native final String e(String p0);
public native final String s(byte[] p0,boolean p1);
}
然后写个Frida脚本hook一下,就先针对样本APK在运行时该方法传入的值和返回值进行分析。
setTimeout(function() {
Java.perform(function() {
let NativeApi = Java.use("com.weibo.xvideo.NativeApi");
NativeApi["s"].implementation = function (bArr, z) {
console.log('s is called' + ', ' + 'bArr: ' + bArr + ", " + "z: " + z);
let ret = this.s(bArr, z);
console.log('s ret value is ' + ret);
return ret;
};
});
}, 5000);
这里需要注意的一个点就是因为这个APK加壳了,所以导致如果在指令中直接写
frida -U -f "com.weibo.xvideo" -l hook.js
会报错显示找不到进程,用以下指令
frida -U -f "com.sina.oasis" -l hook.js
就会接收到以下信息
把bArr转成ASCII码就是
aid=01A1H7Fp6UDqucgEerCNxpT41YVcU_j_LzwYFOs_L2l3_OoL8.&cfrom=28B5295010&cuid=0&noncestr=d101Ed45892MF20mM2LJ54i62EhwO3&phone=17816087093&platform=ANDROID×tamp=1748421079454&ua=Google-Pixel3_oasis_3.5.8_Android__Android12&version=3.5.8&vid=2020786998479&wm=20004_90024
s ret value is 08e2a22b03840858f5e78858e4575db2
就是程序发出的一个URL请求参数字符串,通常用于身份验证、用户追踪、设备标识、版本控制等。然后我们拿到传入的参数,接下来主动调用s方法
setTimeout(function () {
Java.perform(function () {
function stringToBytes(str) {
var javaString = Java.use("java.lang.String");
return javaString.$new(str).getBytes();
}
let NativeApi = Java.use("com.weibo.xvideo.NativeApi");
let arg1 = "aid=01A1H7Fp6UDqucgEerCNxpT41YVcU_j_LzwYFOs_L2l3_OoL8.&cfrom=28B5295010&cuid=0&noncestr=d101Ed45892MF20mM2LJ54i62EhwO3&phone=17816087093&platform=ANDROID×tamp=1748421079454&ua=Google-Pixel3_oasis_3.5.8_Android__Android12&version=3.5.8&vid=2020786998479&wm=20004_90024";
let arg2 = false;
let ret = NativeApi.$new().s(stringToBytes(arg1), arg2);
console.log("ret:" + ret);
});
}, 5000);
得到返回值是
ret:1e3780f50056a3fea76b35592bfc3efc
接下来我们开始使用unidbg,本篇的目的就是使用unidbg复现对s方法的调用,并得到Frida主动调用一致的结果。
三、初始化
在test/java路径下新建包和对应的类,包名,类名根据自己喜欢来就好
package com.test0;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.LibraryResolver;
import com.github.unidbg.arm.backend.DynarmicFactory;
import com.github.unidbg.arm.backend.Unicorn2Factory;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.jni.ProxyDvmObject;
import com.github.unidbg.memory.Memory;
import java.io.File;
import java.nio.charset.StandardCharsets;
//继承AbstractJni类之后,TEST0类就可以接收和响应Dalvik VM里本地方法调用,同时可以自定义JNI行为
public class TEST0 extends AbstractJni {
private final AndroidEmulator emulator;
private final DvmClass NativeApi;
private final VM vm;
public TEST0() {
//模拟器初始化
emulator = AndroidEmulatorBuilder
//设置架构,执行速度ARM64相比ARM32会快一些,10%上下;而Unidbg对ARM32的支持和完善程度高于ARM64
.for64Bit()
//告诉unidbg使用Unicorn模拟器来执行指令
.addBackendFactory(new Unicorn2Factory(true))
//这个参数是用来设置进程名的,这个很重要,并且填写的是样本APK真实进程名,否则会带来隐患。若为空,一般Unidbg
//会将"unidbg"作为进程名返回。
.setProcessName("com.sina.oasis")
.build();
// //是否开启多线程
// //设置执行多少条指令切换一次线程
// emulator.getBackend().registerEmuCountHook(10000);
// //开启线程调度器
// emulator.getSyscallHandler().setEnableThreadDispatcher(true);
//获取模拟器的内存实例,也就是模拟器的内存操作接口
Memory memory = emulator.getMemory();
//创建一个库解析器,并设置Android版本为23(Android 6.0)
memory.setLibraryResolver(new AndroidResolver(23));
//创建虚拟机并指定apk文件
vm = emulator.createDalvikVM(new File("D:\\Compilation_Enviroment\\unidbg-master\\unidbg-android\\src\\test\\java\\com\\test0\\lvzhou.apk"));
//设置当前类为虚拟机的JNI接口实现,即告诉虚拟机,如果 native 层调用 Java 方法、访问 Java 字段,使用我(这个类)来处理。
vm.setJni(this);
//打开 unidbg 的详细日志输出(verbose mode)
vm.setVerbose(true);
//加载so模块
DalvikModule dm = vm.loadLibrary("oasiscore", true);
//加载目标类
NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi");
dm.callJNI_OnLoad(emulator);
}
public String calls() {
String arg1 = "aid=01A1H7Fp6UDqucgEerCNxpT41YVcU_j_LzwYFOs_L2l3_OoL8.&cfrom=28B5295010&cuid=0&" +
"noncestr=d101Ed45892MF20mM2LJ54i62EhwO3&phone=17816087093&platform=ANDROID&" +
"timestamp=1748421079454&ua=Google-Pixel3_oasis_3.5.8_Android__Android12&version=3.5.8&" +
"vid=2020786998479&wm=20004_90024";
Boolean arg2 = false;
String ret = NativeApi.newObject(null).callJniMethodObject(emulator, "s([BZ)Ljava/lang/String;", arg1.getBytes(StandardCharsets.UTF_8), arg2).getValue().toString();
return ret;
}
public static void main(String[] args) {
TEST0 test0 = new TEST0();
String result = test0.calls();
System.out.println("Call s result:\n" + result);
}
}
每行代码的含义都已注释好。
总的来说,就是进行了初始化模拟器、初始化内存、设置依赖库路径、创建虚拟机处理器、加载目标so、执行其JNI_Onload函数这一系列操作。
运行后得到我们的预期结果
Call s result:
1e3780f50056a3fea76b35592bfc3efc
四、解释
回顾DEX的反编译结果
package com.weibo.xvideo.NativeApi;
import java.lang.Object;
import java.lang.String;
import java.lang.System;
public final class NativeApi // class@0011ea from target.dex
{
public void NativeApi(){
super();
System.loadLibrary("oasiscore");
}
public native final String d(String p0);
public native final String dg(String p0,boolean p1);
public native final String e(String p0);
public native final String s(byte[] p0,boolean p1);
}
s方法是NativeApi类里的实例方法,因此在调用它时,需要先获取NativeApi类,再创建它的实例,最后才是发起调用,比如 Frida Call 代码就遵循这样的原则。Java.use获取类对象,$new()实例化,然后调用s方法。
Unidbg 作为 SO 模拟器,它没有加载 DEX,更没有运行 DEX,自然没法获取其中对应的类信息,那么它如何处理NativeApi以及更多的用户自定义的类库访问需求?这是 Unidbg 的核心问题之一。
首先,它构建了一套描述类库,或者说映射类库的机制。在 JAVA 层,一个类是Class,在 JNI 层,一个类是Jclass,而在 Unidbg 的 JNI 层,一个类是DvmClass。
在 Unidbg 中,通过vm.resolveClass(className)可以声明一个类名是className的DvmClass。
DvmClass NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi");
在类名的表述上,必须是全类名,旧版 Unidbg 上必须用斜杠/作为分隔符,而 Unidbg 0.9.6 及以上的当前版本中,用斜杠/或点号.分割都可行,因为resolveClass会替我们做这种替换。
resolveClass是一个可变参数方法,它的第二个参数是可变参数,用于进一步描述我们所构造的这个DvmClass的父类和接口。
举个例子,Android 的 Application 类
它继承自 ContextWrapper,ContextWrapper 继承自 Context,下面的图看起来更清晰。
如果想用DvmClass表明 Application 的父类情况,可以像下面这样嵌套表示。
DvmClass Context = vm.resolveClass("android/content/Context");
DvmClass ContextWrapper = vm.resolveClass("android/content/ContextWrapper", Context);
DvmClass Application = vm.resolveClass("android/app/Application", ContextWrapper);
需要注意,这并不意味着我们必须非要这样才能表示 Application 类,类是数据和方法的集合,DvmClass并不是要完美模拟对应的类,更多时候只是简单的“占位”,使之符合形式上的要求。
接下来讨论类的实例化,对类调用newObject(Object value)即可完成实例化,得到一个形式上的对象。
DvmClass NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi");
DvmObject<?> nativeApi = NativeApi.newObject(null);
在 Java 中它是 Object,JNI 中叫做 Jobject,Unidbg 里叫 DvmObject。
newObject 方法的参数和真实对象的构造函数的参数不是一回事,它只是一种“形式上”的对象,不需要和真实的对象保持一致。
resolveClass以及newObject只是对真实类和对象的描述、占位、投影,然后根据程序逻辑上对这些类、对象的使用情况,再逐步完善这样的一种描述、占位、投影,使之能完成基本需求。
这么做是有好处的,我们常说“吃多少,拿多少”,Unidbg 所设计的这种逻辑,就是程序用多少,就补多少。一个对象可能有一百个方法,两百个成员属性,但程序中如果只使用到了其中的一个方法,两个属性,那么只需要处理它们,而不用管其他的部分。
通过NativeApi.newObject(null)创建实例后,接下来就是发起函数调用。
接下里是关于发起调用的一些解释
我们想对s
发起调用,因为它的返回值是字符串,是对象,所以用callJniMethodObject,如果返回值是 int 就用callJniMethodInt,其他可选方法如下。
如果s是静态方法,那么自然不需要 newObject,调用则通过 callStaticJNIMethodXXX 系列函数。
接下来看看方法的具体参数,参数 1 是模拟器实例,参数 2 是方法签名,接着是可变参数,用于传入调用方法的参数。
String arg1 = "aid=aid=01A1H7Fp6UDqucgEerCNxpT41YVcU_j_LzwYFOs_L2l3_OoL8.&cfrom=28B5295010&cuid=0&noncestr=d101Ed45892MF20mM2LJ54i62EhwO3&phone=17816087093&platform=ANDROID×tamp=1748421079454&ua=Google-Pixel3__oasis__3.5.8__Android__Android12&version=3.5.8&vid=2020786998479&wm=20004_90024";
Boolean arg2 = false;
String ret = NativeApi.newObject(null).callJniMethodObject(emulator, "s([BZ)Ljava/lang/String;", arg1.getBytes(StandardCharsets.UTF_8), arg2).getValue().toString();
这里方法签名填入了s([BZ)Ljava/lang/String;,直接填入s会报错,提示无法找到方法。
函数名 + 参数类型 + 返回值类型
public final native String s(@NotNull byte[] bArr, boolean z);
我们可以手动转写,大家在学习 Smali 以及 JNI 时应该都和这种表示方法打过交道。
Java类型 | 签名 |
---|---|
boolean | Z |
byte | B |
char | C |
short | S |
int | I |
long | J |
float | F |
double | D |
void | V |
class | Lclass; |
[ type | type[] |
对于基础类型,除了 boolean 都通用首字母大写表示,boolean 和 byte 的首字母重复,所以由 byte 用 B 首字母,boolean 用 Z 形成区分。
对于对象类型,则是 L 开头,分号结尾,中间跟着方法全类名,以斜杠/分割。
参数签名就是将参数和返回值转换成这个格式,然后直接拼接起来。
- byte[] bArr即 [B
- boolean z即 Z
- 返回值String,即Ljava/lang/String;。
拼接在一起就是s([BZ)Ljava/lang/String;,手写多少有点麻烦,而且容易出错,在更多时候我们会通过其他方式处理。
如果目标函数采用动态绑定,那么在 Undibg 的 JNI_OnLoad 执行流中找到RegisterNative日志即可看到对应的方法签名。
RegisterNative(com/weibo/xvideo/NativeApi, s([BZ)Ljava/lang/String;, RX@0x400116cc[liboasiscore.so]0x116cc)
RegisterNative(com/weibo/xvideo/NativeApi, e(Ljava/lang/String;)Ljava/lang/String;, RX@0x40011af4[liboasiscore.so]0x11af4)
RegisterNative(com/weibo/xvideo/NativeApi, d(Ljava/lang/String;)Ljava/lang/String;, RX@0x40011cd8[liboasiscore.so]0x11cd8)
RegisterNative(com/weibo/xvideo/NativeApi, dg(Ljava/lang/String;Z)Ljava/lang/String;, RX@0x4001220c[liboasiscore.so]0x1220c)
如果目标函数采用静态绑定,那么直接用传入s
即可,不需要写s([BZ)Ljava/lang/String;。
除了这个办法外,也可以将 JADX 中切换到 Smali 展示模式,找到对应方法即可看到方法签名。
然后是参数的传递
回到调用代码,在 Unidbg 中如何传递函数的入参?
public String calls(){
String arg1 = "aid=01A-khBWIm48A079Pz_DMW6PyZR8uyTumcCNm4e8awxyC2ANU.&cfrom=28B5295010&cuid=5999578300&noncestr=46274W9279Hr1X49A5X058z7ZVz024&platform=ANDROID×tamp=1621437643609&ua=Xiaomi-MIX2S__oasis__3.5.8__Android__Android10&version=3.5.8&vid=1019013594003&wm=20004_90024";
Boolean arg2 = false;
String ret = NativeApi.newObject(null).callJniMethodObject(emulator, "s([BZ)Ljava/lang/String;", arg1.getBytes(StandardCharsets.UTF_8), arg2).getValue().toString();
return ret;
}
参数 1 是字符串,参数 2 是布尔值,直接传递即可,不需要做其他处理,两大类参数都可以直接传递。
基本类型直接传递,int、long、boolean、double 等。
下面几种对象类型也可以直接传递
String
byte 数组
short 数组
int 数组
float 数组
double 数组
Enum 枚举类型
除此之外还有许多种可能的参数,比如字符串数组、二维数组、Android Context/Application、HashMap 等等,在大体上遵循两类处理办法。
- 如果是 JDK 中包含的类库和方法,比如二维数组、字符串数组、HashMap 等等,直接构造后使用ProxyDvmObject.createObject(vm, obj);构造出对象。除此之外比如 Okhttp3 之类的第三方类库,导入到本地环境里,也可以使用这个办法。
- 如果是 JDK 中无法包含的类库,比如 Android FrameWork 以及样本自定义的类库,通过resolveClass(className).newObject处理,就像本节的NativeApi那样处理。
参考
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1621925986@qq.com