抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

跟着[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
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 is called, bArr: [object Object], z: false
s ret value is 519858da87be0ff512648eb5e565e22e

接下来主动调用s方法,它是一个实例方法,可供参考的 Frida 代码如下。参数就是先前 Frida Call 得到的入参。

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&timestamp=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 复现对 s 方法的调用,并得到和 Frida Call 一致的结果。

初始化

我们的代码写在 unidbg-android/src/test/java 路径下

新建com.bilibili包和对应的xvideo

继承AbstractJni类,并创建构造函数

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
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 函数这一系列操作。

在设计模式上,这是一个典型的建造者模式。以 AndroidEmulatorBuilder 为起点,使用链式调用,一步步把符合使用者需求的 Unidbg 模拟器实例构造出来

1
2
3
4
5
emulator = AndroidEmulatorBuilder
.for64Bit()
.addBackendFactory(new Unicorn2Factory(true))
.setProcessName("com.sina.oasis")
.build();

位数与架构

Unidbg支持的架构
Unidbg是一个专门为ARM架构设计的模拟器,并不支持X86或其他架构。因此,Unidbg中的for64Bitfor32Bit仅仅用于选择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 目前支持以下五种后端:

  1. Unicorn:最早的后端,基于 Unicorn 模拟器实现。
  2. Unicorn2:Unicorn 的改进版本,支持更多功能和更好的性能。
  3. Dynarmic:一个 ARM 的 JIT 模拟器,优化了模拟器的速度。
  4. Hypervisor:用于虚拟化环境中,支持更高级的硬件虚拟化。
  5. KVM:利用 Linux 的 KVM 虚拟化技术进行模拟。

默认后端:

如果没有显式指定后端,Unidbg 默认使用 Unicorn Backend,即 Unicorn 后端来执行指令模拟。

尽管 Unicorn 是一个功能非常强大的后端,但在最新的 Unidbg 中,推荐使用 Unicorn2,尤其是因为 Unicorn2 增强了多线程支持,这对于现代应用程序的运行和性能至关重要。

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;
}
}
}
// 默认使用 Unicorn后端
return new UnicornBackend(emulator, is64Bit);
}

BackendFactory 的构造函数通常需要一个 fallbackUnicorn 参数。这个布尔值参数控制在后端创建失败时,是否回退到 Unicorn 后端。如果设为 true,则在当前后端无法成功创建时,Unidbg 会使用 Unicorn 后端作为回退;如果设为 false,则会抛出异常。

根目录

etRootDir用于设置虚拟文件系统的根目录,在语义上它对应于 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 项目里,方便打包处理和迁移。

多线程

当 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、qiling 等一众项目,都是可选开启而不是默认开启多线程逻辑,因为这些模拟器所实现的多线程逻辑都不完备,并不是所有场景里都适用。

在模拟执行相对复杂的样本时,就打开多线程;如果样本难度一般,就不必打开

发起调用

接下来发起对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&timestamp=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);
}

创建类和实例化

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)可以声明一个类名是classNameDvmClass

    1
    DvmClass NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi");

    在类名的表述上,用斜杠/或点号.分割都可行,因为resolveClass会替我们做这种替换。(所以主要还是用/分割。)

类的继承与接口

  • resolveClass 的第二个参数用于声明父类和接口。
  • Unidbg 并不需要严格模拟整个类的继承结构,很多时候只需要提供形式上的占位即可满足需求。
1
2
3
4
/**
* @param interfaceClasses 如果不为空的话,第一个为superClass,其它的为interfaces
*/
DvmClass resolveClass(String className, DvmClass... interfaceClasses);

类实例化的机制

在 Unidbg 中,通过 DvmClass.newObject 方法可以创建一个 DvmObject 对象。这种对象只是形式上的表示,并不包含真实的实例逻辑。

使用 newObject 创建实例

  • 方法原型

    1
    DvmObject<?> newObject(Object value);
    • 创建一个形式上的对象,并返回 DvmObject
    • 参数 value 只是一个占位值,与真实的构造函数参数无关。
    1
    2
    DvmClass NativeApi = vm.resolveClass("com/weibo/xvideo/NativeApi");
    DvmObject<?> nativeApi = NativeApi.newObject(null);

创建实例的特点

  • 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&timestamp=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;
}

调用

Unidbg 提供了以下调用方法系列,根据方法类型和返回值类型进行选择:

  1. 实例方法调用
    • callJniMethodObject:返回值为对象。
    • callJniMethodInt:返回值为 int
    • callJniMethodBoolean:返回值为布尔值。
    • 其他返回值类型(如 floatdouble 等)对应相应方法。
  2. 静态方法调用
    • callStaticJniMethodObject:返回值为对象。
    • callStaticJniMethodInt:返回值为 int
    • 其他类型同上。

我们想对s发起调用,因为它的返回值是字符串,是对象,所以用callJniMethodObject

接下来看看方法的具体参数,参数 1 是模拟器实例,参数 2 是方法签名,接着是可变参数,用于传入调用方法的参数。

1
2
3
String arg1 = "aid=01A-khBWIm48A079Pz_DMW6PyZR8uyTumcCNm4e8awxyC2ANU.&cfrom=28B5295010&cuid=5999578300&noncestr=46274W9279Hr1X49A5X058z7ZVz024&platform=ANDROID&timestamp=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();

这里方法签名填入了s([BZ)Ljava/lang/String;,直接填入s会报错,提示无法找到方法。

如果目标函数是动态加载的,则参数 2 必须是方法签名;如果目标函数是静态加载的,那么直接用传入函数名即可,不需要写方法签名。

如果目标函数采用动态绑定,那么在 Undibg 的 JNI_OnLoad 执行流中找到RegisterNative日志即可看到对应的方法签名。

也可以将 JADX 中切换到 Smali 展示模式,找到对应方法即可看到方法签名。

参数传递

1
2
3
4
5
6
public String calls(){
String arg1 = "aid=01A-khBWIm48A079Pz_DMW6PyZR8uyTumcCNm4e8awxyC2ANU.&cfrom=28B5295010&cuid=5999578300&noncestr=46274W9279Hr1X49A5X058z7ZVz024&platform=ANDROID&timestamp=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 等等,在大体上遵循两类处理办法。

  1. 如果是 JDK 中包含的类库和方法,比如二维数组、字符串数组、HashMap 等等,直接构造然后使用ProxyDvmObject.createObject(vm, obj);构造出对象。除此之外比如 Okhttp3 之类的第三方类库,导入到本地环境里,也可以使用这个办法。
  2. 如果是 JDK 中无法包含的类库,比如 Android FrameWork 以及样本自定义的类库,通过resolveClass(className).newObject处理,就像本节的NativeApi那样处理。

评论