跟着[Unidbg 的基本使用(一)](https://www.yuque.com/lilac-2hqvv/xdwlsg/gmbn45b5n6g59kks?# 《Unidbg 的基本使用(一)》)学unidbg
引言
目标 APK:lvzhou.apk
目标方法声明所处的 Dex:target.dex。样本在 JAVA 层做了加壳,直接使用提供的 target.dex
目标方法实现:liboasiscore.so
任务描述
使用 JADX 反编译 target.dex,找到 NativeApi 类,其中的s
方法就是本篇的目标。
此处在s
上右键,选择“复制为 frida 片段”即可生成对s
方法的 Frida hook 代码。再用Java.perform
包裹。动调目标进程获得入参和返回值。
1 | Java.perform(function() { |
s is called, bArr: [object Object], z: false
s ret value is 519858da87be0ff512648eb5e565e22e
接下来主动调用s
方法,它是一个实例方法,可供参考的 Frida 代码如下。参数就是先前 Frida Call 得到的入参。
1 | Java.perform(function() { |
输出为3882b522d0c62171d51094914032d5ea
我们的目标是使用 Unidbg 复现对 s 方法的调用,并得到和 Frida Call 一致的结果。
初始化
我们的代码写在 unidbg-android/src/test/java 路径下
新建com.bilibili
包和对应的xvideo
类
继承AbstractJni
类,并创建构造函数
1 | package com.bilibili; |
看起来有些复杂,包含初始化模拟器、初始化内存、设置依赖库路径、创建虚拟机处理器、加载目标 SO、执行其 JNI_OnLoad 函数这一系列操作。
在设计模式上,这是一个典型的建造者模式。以 AndroidEmulatorBuilder 为起点,使用链式调用,一步步把符合使用者需求的 Unidbg 模拟器实例构造出来
1 | emulator = AndroidEmulatorBuilder |
位数与架构
Unidbg支持的架构
Unidbg是一个专门为ARM架构设计的模拟器,并不支持X86或其他架构。因此,Unidbg中的for64Bit
和for32Bit
仅仅用于选择ARM架构的32位或64位模式:
for32Bit
表示模拟32位的ARM架构(ARM32)。for64Bit
表示模拟64位的ARM架构(ARM64)。
如果应用只提供32位SO,64位设备可以向下兼容32位,因此所有设备都能运行。如果应用只提供64位SO,则32位设备无法运行。
核心限制:
- 当选择32位模式时,传入的动态库必须是ARM32架构的(
ElfFile.ARCH_ARM
)。 - 当选择64位模式时,传入的动态库必须是ARM64架构的(
ElfFile.ARCH_AARCH64
)。
apk lib 里只有armeabi-v7a
,那就只能选择 32 位,apk lib 里只有arm64-v8a
,就选择 64 位。
进程名
setProcessName
是 Unidbg 中用来设置进程名的方法。
如果不设置,Unidbg 默认将进程名设置为 "unidbg"
,这可能会导致程序中的 getprogname
返回 "unidbg"
,进而引发不可预期的行为。
对应源代码逻辑如下。
1 | this.processName = processName == null ? "unidbg" : processName; |
后端
addBackendFactory
用于设置指令执行引擎(推荐使用 Unicorn2)
处理器的基本任务是执行汇编指令,操作系统的基本任务是管理、调度资源并提供服务,应用程序依赖于 CPU 执行指令,基于操作系统提供的 API 实现功能。
而在 Unidbg 里,为了让程序感觉自己在操作系统里,做了如下设计。
在 Unidbg 中,后端(Backend) 负责模拟处理器的指令执行。后端就像是指令执行引擎,处理所有与 CPU 相关的任务。在一个完整的计算机系统中,处理器 执行指令,操作系统 提供资源管理和服务,而应用程序则依赖处理器和操作系统来执行代码和实现功能。
具体到 Unidbg,它模拟了一个有限的操作系统环境来运行动态库(SO 文件),并通过后端负责执行指令。Unidbg 的设计目的是让运行在模拟环境中的程序,感知到自己正在真实的 Android 系统上运行,即使它实际上是在一个模拟的环境中。
Unidbg 支持多种后端(Backend)引擎,每个后端都是一个指令模拟器,负责模拟处理器的行为。具体来说,Unidbg 目前支持以下五种后端:
- Unicorn:最早的后端,基于 Unicorn 模拟器实现。
- Unicorn2:Unicorn 的改进版本,支持更多功能和更好的性能。
- Dynarmic:一个 ARM 的 JIT 模拟器,优化了模拟器的速度。
- Hypervisor:用于虚拟化环境中,支持更高级的硬件虚拟化。
- KVM:利用 Linux 的 KVM 虚拟化技术进行模拟。
默认后端:
如果没有显式指定后端,Unidbg 默认使用 Unicorn Backend,即 Unicorn 后端来执行指令模拟。
尽管 Unicorn 是一个功能非常强大的后端,但在最新的 Unidbg 中,推荐使用 Unicorn2,尤其是因为 Unicorn2 增强了多线程支持,这对于现代应用程序的运行和性能至关重要。
1 | public static Backend createBackend(Emulator<?> emulator, boolean is64Bit, Collection<BackendFactory> backendFactories) { |
BackendFactory
的构造函数通常需要一个 fallbackUnicorn
参数。这个布尔值参数控制在后端创建失败时,是否回退到 Unicorn 后端。如果设为 true
,则在当前后端无法成功创建时,Unidbg 会使用 Unicorn 后端作为回退;如果设为 false
,则会抛出异常。
根目录
etRootDir
用于设置虚拟文件系统的根目录,在语义上它对应于 Andorid 的根目录。
1 | emulator = AndroidEmulatorBuilder.for32Bit() |
当读者认为目标 SO 可能会做文件访问与读写操作时,就应该设置根目录,程序对文件的读写会落在这个目录里。
如果不加以设置,Unidbg 会默认在本机临时目录下创建根目录,这会在将项目迁移到其他电脑上时带来不便。所以我们一般会主动设置根目录,并设置为target/rootfs
这个相对路径,使得潜在的文件依赖位于在当前 Unidbg 项目里,方便打包处理和迁移。
多线程
当 Unidbg 飘红报错Out of Memory
时,你需要打开 Unidbg 的多线程模式。
Unidbg 在 Unicorn2 后端上实现了相对完善的多线程处理逻辑,如果读者希望开启多线程,除了要将 Backend 设置为 Unicorn2 外,还需要在模拟器初始化后通过setEnableThreadDispatcher
开启多线程调度,以及设置线程切换条件registerEmuCountHook
。
1 | emulator = AndroidEmulatorBuilder.for32Bit() |
多线程是现代操作系统的基本特性之一,程序常常会依赖多线程,那么似乎在理论上,我们总是应该开启多线程逻辑。但事实并非如此,包括 Unidbg、qiling 等一众项目,都是可选开启而不是默认开启多线程逻辑,因为这些模拟器所实现的多线程逻辑都不完备,并不是所有场景里都适用。
在模拟执行相对复杂的样本时,就打开多线程;如果样本难度一般,就不必打开
发起调用
接下来发起对s
的调用,它是一个实例方法,可以将calls
和 Frida Call 的代码逻辑做对照。
1 | public String calls(){ |
创建类和实例化
s
方法是NativeApi
类里的实例方法,因此在调用它时,需要先获取NativeApi
类,再创建它的实例,最后才是发起调用,比如 Frida Call 代码就遵循这样的原则。Java.use
获取类对象,$new()
实例化,然后调用s
方法。
在 Unidbg 中,尽管它本身并不加载或运行 DEX 文件,但通过一套描述和映射机制,能够支持对 Java 类和实例的模拟操作。这种机制允许 Unidbg 在模拟 Android SO 的过程中,处理涉及 Java 类与对象的方法调用需求。
类创建的机制
在 Unidbg 中,Java 类通过 DvmClass
进行模拟。这是一种抽象表示,用于描述 Android 平台上的类结构。Unidbg 提供了方法 vm.resolveClass
来创建 DvmClass
对象。
使用 resolveClass
声明类
在 Unidbg 中,通过
vm.resolveClass(className)
可以声明一个类名是className
的DvmClass
。1
DvmClass NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi");
在类名的表述上,用斜杠
/
或点号.
分割都可行,因为resolveClass
会替我们做这种替换。(所以主要还是用/
分割。)
类的继承与接口
resolveClass
的第二个参数用于声明父类和接口。- Unidbg 并不需要严格模拟整个类的继承结构,很多时候只需要提供形式上的占位即可满足需求。
1 | /** |
类实例化的机制
在 Unidbg 中,通过 DvmClass.newObject
方法可以创建一个 DvmObject
对象。这种对象只是形式上的表示,并不包含真实的实例逻辑。
使用 newObject
创建实例
方法原型:
1
DvmObject<?> newObject(Object value);
- 创建一个形式上的对象,并返回
DvmObject
。 - 参数
value
只是一个占位值,与真实的构造函数参数无关。
1
2DvmClass NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi");
DvmObject<?> nativeApi = NativeApi.newObject(null);- 创建一个形式上的对象,并返回
创建实例的特点
- Unidbg 不需要为对象提供完整的实现逻辑,只需描述程序实际使用的部分即可。这种“按需补充”机制简化了模拟的复杂度:
- 如果程序调用对象的某个方法,只需模拟该方法。不用管其他的部分。
- 如果程序访问某个属性,只需描述该属性。不用管其他的部分。
通过NativeApi.newObject(null)
创建实例后,接下来就是发起函数调用。
1 | public String calls(){ |
调用
Unidbg 提供了以下调用方法系列,根据方法类型和返回值类型进行选择:
- 实例方法调用:
callJniMethodObject
:返回值为对象。callJniMethodInt
:返回值为int
。callJniMethodBoolean
:返回值为布尔值。- 其他返回值类型(如
float
、double
等)对应相应方法。
- 静态方法调用:
callStaticJniMethodObject
:返回值为对象。callStaticJniMethodInt
:返回值为int
。- 其他类型同上。
我们想对s
发起调用,因为它的返回值是字符串,是对象,所以用callJniMethodObject
接下来看看方法的具体参数,参数 1 是模拟器实例,参数 2 是方法签名,接着是可变参数,用于传入调用方法的参数。
1 | 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"; |
这里方法签名填入了s([BZ)Ljava/lang/String;
,直接填入s
会报错,提示无法找到方法。
如果目标函数是动态加载的,则参数 2 必须是方法签名;如果目标函数是静态加载的,那么直接用传入函数名即可,不需要写方法签名。
如果目标函数采用动态绑定,那么在 Undibg 的 JNI_OnLoad 执行流中找到RegisterNative
日志即可看到对应的方法签名。
也可以将 JADX 中切换到 Smali 展示模式,找到对应方法即可看到方法签名。
参数传递
1 | public String calls(){ |
参数 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
那样处理。