白乐天

道阻且长,行则将至。

快速上手unidbg

介绍

Unidbg是一个轻量级模拟器,支持对Android Native函数的模拟执行。

Unidbg是一个基于Maven构建的 JAVA 项目,可在 Github 下载源代码,然后在 IDEA/VScode 等 IDE 里打开,其依赖下载完成后,测试运行。

初始化环境

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Emulator;
import com.github.unidbg.Module;
import com.github.unidbg.arm.backend.Unicorn2Factory;
import com.github.unidbg.file.FileResult;
import com.github.unidbg.file.IOResolver;
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.VM;
import com.github.unidbg.memory.Memory;

import java.io.File;

public class MainActivity extends AbstractJni implements IOResolver {
private final AndroidEmulator emulator;
private String process = "";
private final Memory memory;
private final VM vm;
private DalvikModule dm;
private Module module;


public MainActivity() {
emulator = AndroidEmulatorBuilder
.for64Bit() // for32Bit()
.setProcessName(process)
.setRootDir(new File("target/rootfs"))
.addBackendFactory(new Unicorn2Factory(false))
.build();
// 设置执行多少条指令切换一次线程
// emulator.getBackend().registerEmuCountHook(10000);
// 启用系统调用的详细日志输出。
// emulator.getSyscallHandler().setVerbose(true);
// 开启线程调度器
// emulator.getSyscallHandler().setEnableThreadDispatcher(true);
memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
vm = emulator.createDalvikVM(new File(""));
vm.setJni(this);
vm.setVerbose(true);
dm = vm.loadLibrary("",true);
dm.callJNI_OnLoad(emulator);
module = dm.getModule();

//
emulator.getSyscallHandler().addIOResolver(this);
}

public static void main(String[] args) {

}

@Override
public FileResult resolve(Emulator emulator, String pathname, int oflags) {
System.out.println("pathname:" + pathname);
return null;
}
}

加载动态链接库

1
2
3
4
5
DalvikModule loadLibrary(String libname, boolean forceCallInit);

// libname:共享库的名字,不包含路径和扩展名
// forceCallInit:是否强制调用库的初始化函数
// 返回 DalvikModule 对象,它表示已加载的库,并且可以通过该对象调用库中的函数。
1
2
3
4
5
DalvikModule loadLibrary(File elfFile, boolean forceCallInit);

// elfFile:File 类型的对象,指向共享库的路径
// forceCallInit:是否强制调用库的初始化函数
// 返回 DalvikModule 对象,它表示已加载的库,并且可以通过该对象调用库中的函数。

获取类和对象

resolveClass()

方法签名

1
DvmClass resolveClass(String className, DvmClass... interfaceClasses);

示例

1
DvmClass myClass = vm.resolveClass("com/example/MyClass");

对象

DvmClass.newObject()

通过类获取对象

1
DvmObject myobject = myClass.newObject(null);

ProxyDvmObject.createObject()

方法签名

1
DvmObject<?> createObject(VM vm, Object value)

示例

1
DvmObject myobject = ProxyDvmObject.createObject(vm,value)

调用方法

调用静态方法与实例方法的区别

  • 静态方法

    通过类直接调用,不需要实例对象。

  • 实例方法

    需要通过类的实例对象调用。

调用静态方法

根据返回值使用合适的调用方法。

调用实例方法

根据返回值使用合适的调用方法。

通过module调用so中方法

通过符号名调用

1
Number callFunction(Emulator<?> emulator, String symbolName, Object... args); 

通过地址调用

1
Number callFunction(Emulator<?> emulator, long offset, Object... args);

参数传递

基本类型的参数传递

Java 中的基本类型(如bytecharshortintlongfloatdoubleboolean 等)可以直接作为参数传递。

String类型也可以直接作为参数传递(不建议,有时候可能导致错误)。

基本对象的参数传递

这里的基本对象指的是基本类型的包装类、字符串、基本类型数组,对象类型数组等

字符串

可以用如下方式处理StringObject

1
2
3
StringObject stringObj = vm.resolveClass("java/lang/String").newObject("Hello World"); 	// 这样写太冗长了,不建议
StringObject stringObj = (StringObject) ProxyDvmObject.createObject(vm, "Hello World");
StringObject stringObj = new StringObject(vm,"Hello World"); // 建议这样写,简单又直观

字节数组

1
2
ByteArray byteArray = new ByteArray(vm, new byte[]{0x64, (byte) 0xBC, 0x0C, 0x65, (byte) 0xAA, 0x1E});
ByteArray byteArray = (ByteArray) ProxyDvmObject.createObject(vm, new byte[]{0x64, (byte) 0xBC, 0x0C, 0x65, (byte) 0xAA, 0x1E});

字符串数组

1
2
ArrayObject arrayObject = ArrayObject.newStringArray(vm, "Hello", "World");
ArrayObject arrayObject = (ArrayObject) ProxyDvmObject.createObject(vm,new String[]{"Hello", "World"});

List

1
2
3
4
5
6
int length = 10;
List<DvmObject<?>> myList = new ArrayList<>();
for (int i = 0; i < length; i++) {
myList.add(i);
}
return new ArrayListObject(vm, myList);

对象参数

对象类型的参数都可以用ProxyDvmObject.createObject()来处理

HashMap 对象

1
ProxyDvmObject.createObject(vm, new HashMap<>());

Android里的类和对象

对于Android里的类和对象,用vm.resolveClass()来处理比较好。

获取模块的基地址

1
2
Module module = emulator.getMemory().getModule("target.so");
System.out.println("Base address: 0x" + Long.toHexString(module.base));

补环境

系统调用

系统调用是由svc软中断发起的,依赖与svc后面的数字

1
2
svc imm
bx lr

在系统调用的调用约定里,imm无实际意义,而且默认使用0svc 0,Unidbg可以根据imm是否为0来确认模拟的是JNI函数调用还是系统调用。

JNI调用

Unidbg构造了JNIEnvJavaVM这两个JNI中的关键结构,凭借系统调用的处理模块来实现对JNI的代理,在svc immbx lr这部分,有意义的是lr,它是svc的返回地址,对应样本发起JNI调用的位置。

补环境的原因

对于对样本的Java层数据做访问的一系列JNI函数,由于Unidbg未运行Dex以及Apk,就需要自己去填充外部信息。

为了减少补环境的负担,Unidbg预处理了常见的JNI函数调用和字段访问,逻辑位于src/main/java/com/github/unidbg/linux/android/dvm/AbstractJni.java类里。

日志打印

Unidbg 中使用 Apache 的开源项目 log4jcommons-logging 处理日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.apache.log4j.Level;
import org.apache.log4j.Logger;

{
Logger.getLogger(ARM32SyscallHandler.class).setLevel(Level.DEBUG);
Logger.getLogger(AndroidSyscallHandler.class).setLevel(Level.DEBUG);

// ARM64 JNI 详细日志
Logger.getLogger(DalvikVM64.class).setLevel(Level.DEBUG);

// ARM32 JNI 详细日志
Logger.getLogger(DalvikVM.class).setLevel(Level.DEBUG);
}

如果希望输出所有模块的日志,可以修改unidbg-android/src/test/resources/log4j.properties文件,将 unidbg 的日志等级从 INFO 改为 DEBUG。

1
2
3
4
# 修改前
log4j.logger.com.github.unidbg=INFO
# 修改后
log4j.logger.com.github.unidbg=DEBUG

虚拟机日志

除了常规日志,Unidbg 还有另一套日志输出,主要打印 JNI 、Syscall 调用相关的内容。它和常规日志的输出有重叠,但内容更详细一些。

通过 vm.setVerbose 开启或关闭它。

1
2
// 设置是否打印以 JNI 为主的虚拟机调用细节
vm.setVerbose(false);

断点调试

1
2
3
4
5
6
7
8
BreakPoint addBreakPoint(long address);
// 在指定的内存地址处添加一个断点。当程序执行到这个地址时,断点会被触发。
// 返回一个 BreakPoint 对象,用于后续管理(例如:禁用或移除断点)。
// Unidbg 内部会监控执行流程,当指令指针(PC)到达该地址时,自动触发断点。
BreakPoint addBreakPoint(long address, BreakPointCallback callback);
// 在指定地址处添加一个断点,并绑定一个自定义回调。每当执行到该地址时,不仅触发断点,还会调用你提供的回调函数。
// 同样返回一个 BreakPoint 对象,用于管理该断点。
// 当程序执行到设定的地址时,除了触发断点外,Unidbg 会调用实现了 BreakPointCallback 接口的回调方法。

示例

1
2
3
4
5
6
7
8
9
// 在地址 0x1000 处设置断点,并绑定一个自定义回调
BreakPoint bp = emulator.addBreakPoint(0x1000, new BreakPointCallback() {
@Override
public void onHit(Emulator<?> emulator, long address) {
// 当断点命中时,打印相关信息
System.out.println("断点命中,地址:" + Long.toHexString(address));
// 此处可以添加更多调试操作,比如查看寄存器或内存状态
}
});

命令

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
c: continue
// 可以让程序继续运行,直到遇到下一个断点或程序执行结束。
n: step over
// 单步执行当前指令,但不会进入函数内部。
bt: back trace
// 打印当前的调用栈信息,也就是从当前执行点一直追溯到程序入口的函数调用链。
nb: break at next block
// 在下一个基本块(Basic Block) 开始执行时自动暂停。
s|si: step into
// 单步执行,如果下一条指令是函数调用,它会进入该函数内部执行。
s[decimal]: execute specified amount instruction
// 允许一次性执行多条指令,然后暂停,而不是一条一条手动执行。
s(bl): execute util BL mnemonic, low performance
// 执行直到遇到 BL 指令
m(op) [size]: show memory, default size is 0x70, size may hex or decimal
// 显示从某个起始地址开始的内存内容。
// 默认显示的内存大小为 0x70 字节(约112字节)。
// [size] 参数可选,可以以十六进制(例如 0x50)或十进制(例如 80)的形式提供
mx0-mx28, mfp, mip, msp [size]: show memory of specified register
// 显示某个寄存器所存储地址处的内存数据。
m(address) [size]: show memory of specified address, address must start with 0x
// 查看该地址处的数据
b(address): add temporarily breakpoint, address must start with 0x, can be module offset
// 在指定地址处添加一个临时断点
b: add breakpoint of register PC
// 在当前程序计数器(PC)指向的地址设置断点。
r: remove breakpoint of register PC
// 命令 r 用于移除之前通过 b 命令设置的 PC 断点。
blr: add temporarily breakpoint of register LR
// 在链接寄存器(LR)所指向的地址上添加一个临时断点。

Trace

traceCode()

1
2
3
4
5
6
7
8
TraceHook traceCode();
// 无参的 traceCode() 方法返回一个 TraceHook 对象,该对象默认对整个代码模块的执行进行追踪,不对地址范围作任何限制。

TraceHook traceCode(long begin, long end);
// 这个重载版本接收两个 long 类型的参数,分别代表代码追踪的起始地址和结束地址。返回的 TraceHook 对象只会对指定地址区间内的指令进行追踪。

TraceHook traceCode(long begin, long end, TraceCodeListener listener);
// 除了指定起始与结束地址外,此方法还允许传入一个自定义的 TraceCodeListener。该监听器在每次执行到追踪范围内的指令时都会被调用,从而让你能够在运行时动态处理或记录这些指令信息。

用法示例

1
emulator.traceCode(module.base, module.base + module.size);

trace的时机

  • 如果想 trace 从某个函数开始的执行流,那就让 traceCode 早于它执行即可。

  • 想trace 从 JNI_OnLoad 开始的目标 SO 执行流,在dm.callJNI_OnLoad(emulator);前添加 trace。

    如下

    1
    2
    emulator.traceCode(module.base, module.base + module.size);
    dm.callJNI_OnLoad(emulator);
  • 如果想到更早的时机开启追踪,即追踪 init_proc、init_array 这些初始化函数的执行情况,那就需要将 traceCode 放到 loadLibrary 之前调用,代码直接提前到前面是不行的,因为此时还没有取到module对象,自然也没有什么module.basemodule.size,这时候最正确的处理办法是使用模块监听器,在模块加载的第一时间开始 trace 。

    如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    memory.addModuleListener(new ModuleListener() {
    @Override
    public void onLoaded(Emulator<?> emulator, Module module) {
    if(module.name.equals("lib.so")){
    emulator.traceCode(module.base, module.base+module.size);
    }
    }
    });
    DalvikModule dm = vm.loadLibrary("xxx", true);
    dm.callJNI_OnLoad(emulator);

AndroidEmulator

1
2
3
4
AndroidEmulator emulator;

emulator.getSyscallHandler().addIOResolver(this);
// 注册一个自定义的IO解析器,用于处理模拟执行期间的文件系统操作。

打包及Python调用

路径问题

这里我在unidbg的根目录下创建了一个文件夹apks,在这里面放apk文件,在java文件里写apk路径的时候可以直接apks/.../example.apk,就可以了。

打包

点击菜单中的File->Project Structure,然后在Project Settings里点击Artifacts,依次点击如下内容

然后会弹出如下的窗口,在Main Class里选择要打包的类,勾选下图中的选项,并指定META-INF/MANIFEST.MF目录

然后点击OK->Apply->OK

Build

菜单栏点击Build->Build Artifacts->unidbg:jar->Build

完成之后会在unidbg根目录下生成一个out文件夹,打包的文件就在unidbg_jar里,如下。

但是这个文件夹里没有apk文件,还需要手动把apk文件及其目录一起移动到这里面

直接把apks目录赋值到unidbg_jar里,如下

终端运行

进入到unidbg_jar目录下,打开终端,执行命令,就可以了

1
java -jar unidbg.jar

Python调用

使用subprocess调用

1
2
3
4
5
import subprocess

cmd = "java -jar unidbg.jar"
signature = subprocess.check_output(cmd, shell=True,cwd="unidbg_jar").decode("utf-8")
print(signature)