一、引言 目标 APK:lvzhou.apk
目标方法声明所处的 Dex:target.dex(见参考链接)。样本在 JAVA 层做了加壳。
目标方法:com.weibo.xvideo.NativeApi.s
目标方法实现:liboasiscore.so
二、任务描述 使用 JADX 反编译 target.dex,找到 NativeApi 类,其中的s
方法就是本篇的目标。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 package com.weibo.xvideo;import org.jetbrains.annotations.NotNull;public final class NativeApi { public NativeApi () { System.loadLibrary("oasiscore" ); } ... @NotNull public final native String s (@NotNull byte [] bArr, boolean z) ; }
首先使用 Frida 进行动态分析,动调目标进程获得入参和返回值。
1 2 3 4 5 6 7 8 9 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; }; })
接下来主动调用s
方法,它是一个实例方法,可供参考的 Frida 代码如下。
1 2 3 4 5 6 7 8 9 10 11 12 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=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" ; let arg2 = false ; let ret = NativeApi .$new().s (stringToBytes (arg1), arg2); console .log ("ret:" +ret); })
输出为3882b522d0c62171d51094914032d5ea
,结果无误。接下来我们用unidbg来调用该函数。
三、初始化 我们的代码写在 unidbg-android/src/test/java 路径下。
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 package com.bilibili;import com.github.unidbg.AndroidEmulator;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.AbstractJni;import com.github.unidbg.linux.android.dvm.DalvikModule;import com.github.unidbg.linux.android.dvm.DvmClass;import com.github.unidbg.linux.android.dvm.VM;import com.github.unidbg.memory.Memory;import java.io.File;public class xvideo extends AbstractJni { private final AndroidEmulator emulator; private final DvmClass NativeApi; private final VM vm; public xvideo () { emulator = AndroidEmulatorBuilder .for64Bit() .addBackendFactory(new Unicorn2Factory (true )) .setProcessName("com.sina.oasis" ) .build(); Memory memory = emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver (23 )); vm = emulator.createDalvikVM(new File ("unidbg-android/src/test/resources/bilibili/lvzhou.apk" )); vm.setJni(this ); vm.setVerbose(true ); DalvikModule dm = vm.loadLibrary("oasiscore" , true ); NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi" ); dm.callJNI_OnLoad(emulator); } public static void main (String[] args) { xvideo xv = new xvideo (); } }
看起来有些复杂,包含初始化模拟器、初始化内存、设置依赖库路径、创建虚拟机处理器、加载目标 SO、执行其 JNI_OnLoad 函数这一系列操作。
本篇讨论初始化模拟器这一个部分,就是下面这部分,其余代码逻辑放在后面文章里缓缓展开。
1 2 3 4 5 emulator = AndroidEmulatorBuilder .for64Bit() .addBackendFactory(new Unicorn2Factory (true )) .setProcessName("com.sina.oasis" ) .build();
接下来讨论建造过程中涉及的参数。
3.1 位数与架构 依据处理的是32位还是64位的SO,模拟器选择for32Bit
或者for64Bit
。Unidbg 只支持ARM架构。因此for64Bit
对应ARM64,for32Bit
同理对应ARM32。对于 ARM 架构,armeabi、armeabi-v7a 对应于 32 位,arm64-v8a 对应于 64 位。
3.2 进程名 setProcessName
用于设置进程名,最好按照样本的真实进程名做设置,否则会带来隐患。
目标程序可以通过getprogname
库函数获取进程名,如果不加以设置,在 Unidbg 环境里会返回unidbg
作为进程名返回,对应源代码逻辑如下。
1 this.processName = processName == null ? "unidbg" : processName;
3.3 后端 addBackendFactory
用于设置指令执行引擎。
先回顾一下处理器、操作系统、应用程序的基本关系,大体上是下面这样。
处理器的基本任务是执行汇编指令,操作系统的基本任务是管理、调度资源并提供服务,应用程序依赖于 CPU 执行指令,基于操作系统提供的 API 实现功能。
而在 Unidbg 里,为了让程序感觉自己在操作系统里,做了如下设计。
Unidbg 扮演操作系统,提供微型、有限的操作系统服务,它实现了许多系统调用、代理和模拟了大量的 JNI 调用。在 SO 的感知里,就好似 在真实 Android 系统环境里运行。
Backend 即后端,它扮演的角色是处理器,负责执行机器指令,它最常见的方案是虚拟化和指令模拟器,我们这里主要讨论指令模拟器。Unicorn 是近年来最亮眼的指令模拟器,它也是 Unidbg 早先唯一的后端。但后来,Unidbg 支持了数个新的后端,目前共五个 Backend,分别是 Unicorn、Unicorn2、Dynarmic、Hypervisor、KVM。如果不添加 BackendFactory,默认使用 Unicorn Backend,代码逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 public static Backend createBackend (Emulator<?> emulator, boolean is64Bit, Collection<BackendFactory> backendFactories) { if (backendFactories != null ) { for (BackendFactory factory : backendFactories) { Backend backend = factory.newBackend(emulator, is64Bit); if (backend != null ) { return backend; } } } return new UnicornBackend (emulator, is64Bit); }
各种 BackendFactory 的构造方法都要传入fallbackUnicorn
,这是一个布尔型参数,它表示如果这个后端创建失败时如何处理——报错还是回退到 Unicorn Backend。
相比较 Unicorn,Unidbg 基于 Unicorn2 设计和实现了多线程的相关逻辑,因此在 Unicorn 和 Unicorn2 之间,应该选择 Unicorn2。
1 addBackendFactory(new Unicorn2Factory (true ))
3.4 根目录 setRootDir
用于设置虚拟文件系统的根目录,在语义上它对应于 Andorid 的根目录。
1 2 3 4 5 6 emulator = AndroidEmulatorBuilder.for32Bit() .setProcessName(executable.getName()) .setRootDir(new File ("target/rootfs" )) .addBackendFactory(new DynarmicFactory (true )) .build();
当我们认为目标 SO 可能会做文件访问与读写操作时,就应该设置根目录 ,程序对文件的读写会落在这个目录里。
如果不加以设置,Unidbg 会默认在本机临时目录下创建根目录,这会在将项目迁移到其他电脑上时带来不便。所以我们一般会主动设置根目录,并设置为target/rootfs
这个相对路径,使得潜在的文件依赖位于在当前 Unidbg 项目里,方便打包处理和迁移。
3.5 多线程 当 Unidbg 飘红报错Out of Memory
时,你需要打开 Unidbg 的多线程模式。
Unidbg 在 Unicorn2 后端上实现了相对完善的多线程处理逻辑,如果读者希望开启多线程,除了要将 Backend 设置为 Unicorn2 外,还需要在模拟器初始化后通过setEnableThreadDispatcher
开启多线程调度,以及设置线程切换条件registerEmuCountHook
。
1 2 3 4 5 6 7 8 emulator = AndroidEmulatorBuilder.for32Bit() .addBackendFactory(new Unicorn2Factory (false )) .setProcessName("test" ) .build(); emulator.getBackend().registerEmuCountHook(10000 ); emulator.getSyscallHandler().setEnableThreadDispatcher(true );
由于 Unidbg 所实现的多线程逻辑不完备,并不是所有场景里都适用,因此是默认不开启多线程逻辑。建议在模拟执行相对复杂的样本时,就打开多线程;如果样本难度一般,就不必打开。
四、发起调用 接下来发起对s
的调用,它是一个实例方法,读者可以将calls
和 Frida Call 的代码逻辑做对照。
1 2 3 4 5 6 7 8 9 10 11 12 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; }public static void main (String[] args) { xvideo xv = new xvideo (); String result = xv.calls(); System.out.println("call s result:" +result); }
运行后得出我们的预期结果
1 call s result:3882b522d0c62171d51094914032d5ea
4.1 创建类和实例化 回顾 DEX 的反编译结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package com.weibo.xvideo;import org.jetbrains.annotations.NotNull;public final class NativeApi { public NativeApi () { System.loadLibrary("oasiscore" ); } @NotNull public final native String d (@NotNull String str) ; @NotNull public final native String dg (@NotNull String str, boolean z) ; @NotNull public final native String e (@NotNull String str) ; @NotNull public final native String s (@NotNull byte [] bArr, boolean z) ; }
s
方法是NativeApi
类里的实例方法,因此在调用它时,需要先获取NativeApi
类,再创建它的实例,最后才是发起调用,比如 Frida Call 代码就遵循这样的原则。Java.use
获取类对象,$new()
实例化,然后调用s
方法。
1 2 3 4 5 6 7 8 9 10 11 12 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=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" ; let arg2 = false ; let ret = NativeApi .$new().s (stringToBytes (arg1), arg2); console .log ("ret:" +ret); })
Unidbg 作为 SO 模拟器,它没有加载 DEX,更没有运行 DEX,自然没法获取其中对应的类信息,那么它如何处理NativeApi
以及更多的用户自定义的类库访问需求?这是 Unidbg 的核心问题之一。
首先,它构建了一套描述类库,或者说映射类库的机制。在 JAVA 层,一个类是Class
,在 JNI 层,一个类是Jclass
,而在 Unidbg 的 JNI 层,一个类是DvmClass
。
在 Unidbg 中,通过vm.resolveClass(className)
可以声明一个类名是className
的DvmClass
。
1 DvmClass NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi" );
在类名的表述上,必须是全类名 ,旧版 Unidbg 上必须用斜杠/
作为分隔符,而 Unidbg 0.9.6 及以上的当前版本中,用斜杠/
或点号.
分割都可行,因为resolveClass
会替我们做这种替换。
1 2 3 4 5 @Override public final DvmClass resolveClass (String className, DvmClass... interfaceClasses) { className = className.replace('.' , '/' ); }
resolveClass
是一个可变参数方法,它的第二个参数是可变参数,用于进一步描述我们所构造的这个DvmClass
的父类和接口。
1 2 3 4 DvmClass resolveClass (String className, DvmClass... interfaceClasses) ;
举个例子,Android 的 Application 类。它继承自 ContextWrapper,ContextWrapper 继承自 Context,具体如下图所示:
如果想用DvmClass
表明 Application 的父类情况,可以像下面这样嵌套表示。
1 2 3 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)
即可完成实例化,得到一个形式上的对象。
1 2 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)
创建实例后,接下来就是发起函数调用。
1 2 3 4 5 6 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; }
4.2 发起调用 我们想对s
发起调用,因为它的返回值是字符串,是对象,所以用callJniMethodObject
,如果返回值是 int 就用callJniMethodInt
,其他返回值类型的调用方法同理类似。
如果s
是静态方法,那么自然不需要 newObject,调用则通过 callStaticJNIMethodXXX 系列函数。
接下来看看方法的具体参数,参数 1 是模拟器实例,参数 2 是方法签名,接着是可变参数,用于传入调用方法的参数。
1 2 3 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();
如果目标函数是动态加载的,则参数 2 必须是方法签名;如果目标函数是静态加载的,那么直接用传入函数名即可,不需要写方法签名。
4.3 参数传递 回到调用代码,在 Unidbg 中如何传递函数的入参?
1 2 3 4 5 6 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 是布尔值,直接传递即可,不需要做其他处理,两大类参数都可以直接传递。
除此之外还有许多种可能的参数,比如字符串数组、二维数组、Android Context/Application、HashMap 等等,在大体上遵循两类处理办法。
如果是 JDK 中包含的类库和方法,比如二维数组、字符串数组、HashMap 等等,直接构造然后使用ProxyDvmObject.createObject(vm, obj);
构造出对象。除此之外比如 Okhttp3 之类的第三方类库,导入到本地环境里,也可以使用这个办法。
如果是 JDK 中无法包含的类库,比如 Android FrameWork 以及样本自定义的类库,通过resolveClass(className).newObject
处理,就像本节的NativeApi
那样处理。
五、参考 [Unidbg 的基本使用(一)](https://www.yuque.com/lilac-2hqvv/xdwlsg/gmbn45b5n6g59kks?# 《Unidbg 的基本使用(一)》)