Unidbg操作说明
zsk Lv4

补环境的三条准则

  1. 常规object,比如jstring,jarray使用Unidbg封装的API
  2. 除此之外的jobject使用vm.resolveClass(className).newObject(object),jclass使用vm.resolveClass(className)
  3. 如果object不可得,传空/方法签名/标识

目录

基本框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private final AndroidEmulator emulator;
private final VM vm;
private final Module module;

xxxx() {
// 创建模拟器实例,进程名建议依照实际进程名填写,可以规避针对进程名的校验
emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("xxxxx").build();
// 获取模拟器的内存操作接口
final Memory memory = emulator.getMemory();
// 设置系统类库解析
memory.setLibraryResolver(new AndroidResolver(23));
// 创建Android虚拟机,传入APK,Unidbg可以替我们做部分签名校验的工作
vm = emulator.createDalvikVM(new File("apk路径"));
// 加载目标SO
DalvikModule dm = vm.loadLibrary(new File("so路径"), true); // 加载so到虚拟内存
//获取本SO模块的句柄,后续需要用它
module = dm.getModule();
vm.setJni(this); // 设置JNI
vm.setVerbose(true); // 打印日志

dm.callJNI_OnLoad(emulator); // 调用JNI OnLoad
};

AndroidEmulator

AndroidEmulator介绍

AndroidEmulator是一个抽象的Android模拟器接口。它作为一个枢纽,协调unidbg中大多数模块相互工作,至关重要。我们需要的大多数操作也都需要借助于它。

创建AndroidEmulator实例

1
2
3
4
5
6
7
8
9
10
11
AndroidEmulator build = AndroidEmulatorBuilder
// 指定32位CPU
.for32Bit()
// 添加后端,推荐使用Dynarmic,运行速度快,但并不支持某些新特性
.addBackendFactory(new DynarmicFactory(true))
// 指定进程名,推荐以安卓包名做为进程名
.setProcessName("com.github.unidbg")
// 设置根路径
.setRootDir(new File("target/rootfs/default"))
// 生成AndroidEmulator实例
.build();

AndroidEmulator操作接口

getMemory

1
2
// 获取内存操作接口
Memory memory = emulator.getMemory();

获取内存操作接口,关于Memory接口介绍查看Memory

getPid

1
2
// 获取进程pid
int pid = emulator.getPid();

createDalvikVM

1
2
3
4
// 创建虚拟机
VM vm = emulator.createDalvikVM();
// 创建虚拟机并指定APK文件
VM vm = emulator.createDalvikVM(new File("apk file path"));

创建虚拟机,关于VM的接口查看VM介绍

loadLibrary

1
2
3
4
5
//参数一: So或可执行ELF文件
//参数二: 是否强制执行Init初始化系列函数
Module module = emulator.loadLibrary(new File("Elf文件路径"), true);
//省略参数二,默认为false
Module module = emulator.loadLibrary(new File("Elf文件路径"));

getDalvikVM

1
2
// 获取已创建的虚拟机
VM vm = emulator.getDalvikVM();

获取已创建的虚拟机。

showRegs

1
2
3
4
//打印所有寄存器信息
emulator.showRegs();
//打印指定寄存器信息
emulator.showRegs(ArmConst.UC_ARM_REG_R0, ArmConst.UC_ARM_REG_R1);

打印当前寄存器信息。

getBackend

1
2
// 获取后端CPU
Backend backend = emulator.getBackend();

获取后端,关于后端请查看Backend介绍

getProcessName

1
2
// 获取进程名
String processName = emulator.getProcessName();

获取进程名。

getContext

1
2
// 获取寄存器
RegisterContext context = emulator.getContext();

获取寄存器上下文,关于寄存器上下文介绍请查看RegisterContext介绍

isRunning

1
2
// 是否正在运行
boolean running = emulator.isRunning();

是否正在运行。

Trace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//Trace汇编指令
emulator.traceCode();
//指定起始结束范围Trace汇编指令。
emulator.traceCode(1, 0);
//带回调的Trace,每条指令都会回调onInstruction方法
emulator.traceCode(1, 0, new TraceCodeListener() {
@Override
public void onInstruction(Emulator<?> emulator, long address, Instruction insn) {
String mnemonic = insn.getMnemonic();
if (mnemonic.equals("svc") || mnemonic.equals("swi")){
System.out.println("0x"+Long.toHexString(address)+"执行了svc指令");
}
}
});

//Trace读内存
emulator.traceRead();
// emulator.traceRead(起始地址,结束地址); // 哪里做了读取
//Trace写内存
emulator.traceWrite();
// emulator.traceWrite(起始地址,结束地址); // 哪里做了赋值

注意:Trace功能仅支持Unicorn后端引擎
如使用带范围的Trace,起始位置大于结束位置将全部进行Trace。与无参方法作用相同。每种Trace都包含了三种重载,参照traceCode()即可。

Set/Get

1
2
emulator.set("Test",this);
DocTest test = emulator.<DocTest>get("Test");

emulator内部维护了一个Map,如有全局的对象需要存储,可借助这两个API来操作,无需额外维护Map对象

SyscallHandler

1
SyscallHandler<AndroidFileIO> syscallHandler = emulator.getSyscallHandler();

获取系统调用处理器,其API请查看SyscallHandler介绍

Debugger

1
2
3
4
5
6
7
8
9
10
11
12
//无参默认CONSOLE调试器
Debugger attach = emulator.attach();
//指定调试器
Debugger attach = emulator.attach(DebuggerType.CONSOLE);
public enum DebuggerType {
//console debugger
CONSOLE,
//gdb server
GDB_SERVER,
//ida android server v7.x
ANDROID_SERVER_V7
}

附加调试器。关于调试器请查看Debugger介绍

emulateSignal

1
2
//参数为SIG_NUM Linux执行 kill -l 查看信号列表
boolean success = emulator.emulateSignal(2);

模拟向进程发送一个信号。

disassemble

1
2
3
4
5
6
// 参数一: 地址
// 参数二: 机器码
// 参数三: 是否为Thumb指令
// 参数四: 最多处理多少条指令
byte[] code = new byte[]{0x00, 0x20};
Instruction[] disassemble = emulator.disassemble(0, code, true, 1);

如需灵活转换,请使用Capstone,此处只是Capstone的简单封装。disassemble方法的另一重载不推荐大家使用,为Trace而封装的,了解即可。

close

1
emulator.close();

调用此方法来释放Android虚拟机使用的资源。

getUnwinder

1
emulator.getUnwinder().unwind();

此方法用来打印调用栈

Memory

Memory介绍

Memory内存接口主要提供了两个功能

  • 内存管理
  • ELF文件的加载

下面我们来介绍下Memory操作的主要接口。

Memory操作

获取Memory实例

1
2
//emulator为模拟器实例
Memory memory = emulator.getMemory();

setLibraryResolver

1
2
//指定Android SDK 版本,目前支持19和23两个版本
memory.setLibraryResolver(new AndroidResolver(23));

此接口属于必调用接口。设置SDK版本,管理了Android提供的常用基本so库。如不调用此方法,在处理Elf文件的依赖时,需要自行处理依赖问题。只有So中无任何依赖的情况下可以不调用此方法。

findModule

1
2
3
4
5
6
//根据名称获取已加载模块
Module module = memory.findModule("libc.so");
//根据模块在内存中的地址查找已加载模块
Module module = memory.findModuleByAddress(address);
//获取所有已经加载过的模块
Collection<Module> loadedModules = memory.getLoadedModules();

上面两个方法都为获取已加载的模块,需在自行加载模块之后调用。

allocateStack

1
2
3
4
5
6
//参数为申请栈空间的大小
UnidbgPointer pointer = memory.allocateStack(0x10);
//在栈空间写入数据并返回指针
UnidbgPointer pointer = memory.writeStackBytes(new byte[]{0x01, 0x02});
//在栈空间写入字符串并返回指针
UnidbgPointer pointer = memory.writeStackString("doc");

不要在程序运行期间修改栈,如果你不清楚在做什么,最好不要自行开辟栈空间,unidbg会自行维护。

malloc

1
2
3
4
5
6
7
//参数一: 分配内存的大小
//参数二: runtime标志
MemoryBlock malloc = memory.malloc(0x10, true);
//获取已分配内存的指针来操作该内存
UnidbgPointer pointer = malloc.getPointer();
//释放已分配的内存
malloc.free();

参数二的runtime标志:true表示使用mmap按页大小分配,相应的调用MemoryBlock.free方法则使用munmap释放。false表示使用libc.malloc分配,相应的调用MemoryBlock.free方法则使用libc.free释放。

setErrno

1
memory.setErrno(UnixEmulator.EPERM); 

如需在模拟执行某函数前需要指定errno,可使用此方法设置。

getStackPoint

1
long stackPoint = memory.getStackPoint(); 

返回当前SP寄存器指向的地址。

disableCallInitFunction

1
2
3
memory.disableCallInitFunction();
//参数一: 指定是否调用初始化
memory.setCallInitFunction(false);

禁用Elf加载时调用初始化函数。需配合加载模块的forceCallInit参数使用。如不进行上述设置,默认为true。

getMemoryMap

1
2
3
4
5
Collection<MemoryMap> memoryMap = memory.getMemoryMap();
//打印当前内存映射情况
for (MemoryMap map : memoryMap){
System.out.println(map);
}

获取内存映射信息,所有正在使用的内存块都维护在此Map中。

pointer

1
2
UnidbgPointer pointer = memory.pointer(0x40000000);
UnidbgPointer pointer2 = UnidbgPointer.pointer(emulator, 0x40000000);

在unidbg中,可以借助unidbgPointer封装的API来读写内存,此API供已知地址的情况下,获得一个指针指向已知地址,继而进行操作。可以理解为指哪打哪。具体内存读写操作请查看UnidbgPointer

addModuleListener

1
2
3
4
5
6
memory.addModuleListener(new ModuleListener() {
@Override
public void onLoaded(Emulator<?> emulator, Module module) {
System.out.println(module.name + "已被加载");
}
});

添加一个模块加载监听器,当一个新模块被加载后,会回调ModuleListener的onLoaded方法,用来感知某模块加载。一般用于想在某个模块加载后接着进行hook的操作。需在模块加载前设置,否则无效。

load

1
Module module = memory.load(new File("文件地址")); 

其余几个重载就不介绍了,emulator接口提供的模块加载API和VM提供的加载API底层都基于此,仅是对此API的一层封装。

Memory总结

Memory模块封装了关于内存管理的各个接口,其余未介绍的方法不推荐大家使用,多为unidbg内部封装的功能。

VM

VM介绍

如果要加载的模块中存在JNI交互的场景,需要创建VM。VM主要的作用就是代理了一套JNI,在模拟执行的过程中,如果需要借助JNI来操作Java层,就少不了VM的帮助。

VM创建

AndroidEmulator一节我们介绍过,主要有两种创建VM的方式

1
2
3
4
//创建虚拟机
VM dalvikVM = emulator.createDalvikVM();
//创建虚拟机并指定APK文件
VM dalvikVM = emulator.createDalvikVM(new File("apk file path"));

推荐使用指定APK文件进行创建,某些API需要指定APK文件才可使用。

VM接口

日志控制

1
2
//设置是否输出JNI运行日志 
vm.setVerbose(true);

此开关只控制VM的日志,主要表现为JNI交互的详细日志。

设置JNI

1
2
//设置JNI接口 
dalvikVM.setJni(this);

如程序中存在使用JNI的接口,必须设置JNI。推荐自实现的类继承AbstractJni类,此类封装了绝大部份常用的方法,免去部分繁杂的内容。如签名校验(需指定APK文件创建VM)、常用JDK操作等。 关于JNI部分请查看JNI介绍

调用JNI_OnLoad函数

1
2
3
// 参数一: 模拟器实例 
// 参数二: 要执行JNI_OnLoad函数的模块
dalvikVM.callJNI_OnLoad(emulator,module);

对JNI_OnLoad函数进行模拟执行。如某些JNI方法在JNI_OnLoad函数进行动态绑定需调用此函数后才能够调用JNI方法,推荐加载模块后紧接执行此方法。

获取模拟器实例

1
Emulator<?> emulator = dalvikVM.getEmulator(); 

获取APK相关信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//指定APK文件以后,可调用该方法获取资源文件
byte[] bytes = dalvikVM.openAsset("fileName");

//指定APK文件以后,可调用该方法获取APK包名
String packageName = dalvikVM.getPackageName();

//指定APK文件以后,可调用该方法获取APK版本名称
String versionName = dalvikVM.getVersionName();

//指定APK文件以后,可调用该方法获取APK版本号
long versionCode = dalvikVM.getVersionCode();

//指定APK文件以后,可调用该方法获取Manifest清单信息
String manifestXml = dalvikVM.getManifestXml();

加载模块

1
2
3
4
5
6
7
8
//参数一: 模块名称
//参数二: 是否强制执行Init初始化
DalvikModule docModule = dalvikVM.loadLibrary("doc", true);

//参数一: 模块名称
//参数二: 要加载模块的字节数据
//参数三: 是否强制执行Init初始化
DalvikModule docModule = dalvikVM.loadLibrary("doc", soFile.getBytes(), true);

VM同样能够加载Elf模块,具体的差异就是如果指定了APK文件,可以使用上述第一个重载方法指定模块名称,unidbg会自行从APK文件中提取该So文件进行加载。第二个重载方法使用较少,指定byte[]数据进行加载,名称由参数一指定。
返回一个DalvikModule实例,可调用JNI_OnLoad函数:

1
docModule.callJNI_OnLoad(emulator); 

该实例还维护了一个Module实例,可使用下面方法获取。

1
Module module = docModule.getModule(); 

有关Module实例请查看Module

VM总结

上述的几个接口主要来控制VM或获取APK文件信息,VM其中的方法不止于这些,我们留到JNI部分来介绍,因为其他的几个方法都是有关于JNI交互场景的,也就是unidbg中的环境问题。
unidbg补环境、JNI交互请查看JNI介绍

CallMethod

执行JNI函数

  • 我们首先来写一段小案例来说明如何在unidbg中执行JNI函数

Java层:

1
2
3
4
5
6
7
8
package com.github.unidbg;

public class Test {
static {
System.loadLibrary("native-doc");
}
public native String jnitest(String arg);
}

So层:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <jni.h>
#include <string>

using namespace std;

extern "C"
JNIEXPORT jstring JNICALL
Java_com_github_unidbg_Test_jnitest(JNIEnv *env, jobject thiz, jstring arg) {
const char *c_arg = env->GetStringUTFChars(arg, nullptr);
string hello = "unidbg:";
hello.append(c_arg);
return env->NewStringUTF(hello.c_str());
}

非常简单的一段小例子,我们就以该例子来说明如何调用libnative-doc.so中的jnitest函数。

  • 第一步: 创建一个DvmObject,此对象相当于在Java层去调用native函数的类的实例对象

    1
    2
    //创建一个类的实例对象
    DvmObject<?> obj = vm.resolveClass("com/github/unidbg/Test").newObject(null);

    关于实例对象的介绍请查看JNI对象/实例

  • 第二步: 使用该对象调用JNI方法

    1
    2
    3
    4
    //参数一: Emulator实例
    //参数二: 方法的签名
    //参数三: 可变参数,传需要向该方法传递的参数
    boolean result = obj.callJniMethodObject(emulator, "jnitest(Ljava/lang/String;)Ljava/lang/String;", "yyds!");

    我们拥有了一个实例对象,就可以像在Java中那样来调用native方法了,针对Java层中不同返回值调用的API不同,请查看JNI方法封装
    参数二是方法的签名,此参数如何填写请查看JNI方法签名

执行任意函数

当我们想执行的函数并非导出函数,也可使用地址进行调用,请自行参考unidbg对JNI函数调用的封装,自行实现此部分。

执行main函数

如果加载的文件是一个可执行文件,需要持有该文件被加载后返回的Module实例来调用callEntry方法,请查看Module

JNI方法封装

void

1
2
3
4
5
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
//调用void返回值的函数
testClass.callStaticJniMethod(emulator, "signature", args);
obj.callJniMethod(emulator,"signature", args);

boolean

1
2
3
4
5
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
//调用boolean返回值的函数
boolean ret = testClass.callStaticJniMethodBoolean(emulator, "signature", args);
boolean ret = obj.callJniMethodBoolean(emulator, "signature", args);

int

1
2
3
4
5
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
//调用int返回值的函数
int ret = testClass.callStaticJniMethodInt(emulator, "signature", args);
int ret = obj.callJniMethodInt(emulator, "signature", args);

long

1
2
3
4
5
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
//调用long返回值的函数
long ret = testClass.callStaticJniMethodLong(emulator, "signature", args);
long ret = obj.callJniMethodLong(emulator, "signature", args);

float

1
2
3
4
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
int ret = testClass.callStaticJniMethodInt(emulator, "signature", args);
int ret = obj.callJniMethodInt(emulator, "signature", args);

先拿到4字节的int数据,然后将其转换

1
2
3
4
ByteBuffer allocate = ByteBuffer.allocate(4);
allocate.putInt(ret);
allocate.flip();
float f_ret = allocate.getFloat();

double

1
2
3
4
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
long ret = testClass.callStaticJniMethodLong(emulator, "signature", args);
long ret = obj.callJniMethodLong(emulator, "signature", args);

先拿到8字节的long数据,然后将其转换

1
2
3
4
ByteBuffer allocate = ByteBuffer.allocate(8);
allocate.putLong(ret);
allocate.flip();
double d_ret = allocate.getDouble();

对象类型

1
2
3
4
5
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
//调用对象类型返回值的函数
DvmObject<?> ret = testClass.callStaticJniMethodObject(emulator, "signature", args);
DvmObject<?> ret = obj.callJniMethodObject(emulator, "signature", args);

基本类型外的所有类型都为对象类型,包括基本类型的数组类型

JNI方法签名

通过类名与方法签名的配合,才能定位到唯一的函数。所以一定要将方法签名填写正确,unidbg才可以找到对应的函数,所有的类型都按JNI本身定义的签名一致。

Java类型 类型签名
boolean Z
byte B
char C
long J
float F
double D
short S
int I
L全限定类名;
数组 [元素类型签名

unidbg方法签名如下格式:
方法名(参数列表)返回值
举个例子:

1
public native String jnitest(String arg);

对应的方法签名为:

1
jnitest(Ljava/lang/String;)Ljava/lang/String;

Hook

Hook介绍

Hook模块为unidbg引入第三方Hook框架而封装的一个模块,使用它可以快速对目标So进行hook操作。而API基本与第三方Hook框架保持一致。如果是32位程序,要注意Hook目标地址是否为Thumb模式,手动处理+1操作。

Hook框架分类

HookZz

HookZz为Dobby的前身,32位模式下推荐使用HookZz。支持inline hook。

获取HookZz实例
1
HookZz hook = HookZz.getInstance(emulator);

在使用任何Hook框架之前都需要拿到unidbg对该Hook框架的封装实例。

HookZz API
wrap

wrap的作用相当于在目标地址进行一层包装,回调到我们的代码,可以对其打印当前参数或寄存器的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1.获取HookZz实例
HookZz hookZz = HookZz.getInstance(emulator);
// 2.创建hook触发回调
WrapCallback<HookZzArm32RegisterContextImpl> wrapCallback = new WrapCallback<HookZzArm32RegisterContextImpl>() {
@Override
public void preCall(Emulator<?> emulator, HookZzArm32RegisterContextImpl ctx, HookEntryInfo info) {
//hook函数进入前回调
System.out.println("arg0:" + ctx.getIntArg(0));
}

@Override
public void postCall(Emulator<?> emulator, HookZzArm32RegisterContextImpl ctx, HookEntryInfo info) {
//hook函数返回后回调
System.out.println("ret:" + ctx.getIntArg(0));
}
};
// 3.wrap
long functionAddress = module.base + 0xC09D/*偏移*/;
hookZz.wrap(functionAddress, wrapCallback);

// 3.wrap重载, hook导出符号
Symbol symbol = module.findSymbolByName("symbolName");
hookZz.wrap(symbol, wrapCallback);
replace

replace可以进行替换目标函数。

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
//获取HookZz实例
HookZz hookZz = HookZz.getInstance(emulator);
//创建replace触发回调
ReplaceCallback replaceCallback = new ReplaceCallback() {
@Override
public HookStatus onCall(Emulator<?> emulator, HookContext context, long originFunction) {
//函数调用前回调。上层会根据HookStatus来决定程序流程
//先来介绍几个参数
//emulator: 模拟器实例
//context: Hook上下文,可根据此参数获取各种参数。也可存储临时数据。
//originFunction: 原函数地址

//获取参数一并打印
int arg0 = context.getIntArg(0);
System.out.println("arg0:"+arg0);

//获取指针参数二并存入context
context.push(context.getPointerArg(1));

//修改参数三的值为0
EditableArm32RegisterContext ctx = (EditableArm32RegisterContext) context;
ctx.setR2(0);

//可以在此改变函数流程
long newFuncAddr = module.base + 0xDC0/*偏移*/;
return HookStatus.RET(emulator, newFuncAddr);

/*
//也可以直接返回一个值而不调用函数
long retValue = 0L;
return HookStatus.LR(emulator, retValue);
*/
}

@Override
public void postCall(Emulator<?> emulator, HookContext context) {
//此回调会根据hookZz.replace方法的第三个参数enablePostCall决定
//可以获取context存储的数据
UnidbgPointer arg2 = context.pop();
//可在此修改函数返回值
super.postCall(emulator, context);
}
};

long functionAddress = module.base + 0xC09D/*偏移*/;
//对目标地址进行inline hook, 并传入回调实例
hookZz.replace(functionAddress, replaceCallback);
//replace重载,第三个参数控制是否可以修改返回值
hookZz.replace(functionAddress, replaceCallback, true);

Symbol symbol = module.findSymbolByName("symbolName");
//使用符号进行hook
hookZz.replace(symbol, replaceCallback);
hookZz.replace(symbol, replaceCallback, true);
instrument

instrument提供了一种对单行汇编进行Hook的能力,使用场景较少。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//获取HookZz实例
HookZz hookZz = HookZz.getInstance(emulator);
//创建InstrumentCallback实例
InstrumentCallback<RegisterContext> instrumentCallback = new InstrumentCallback<RegisterContext>() {
@Override
public void dbiCall(Emulator<?> emulator, RegisterContext ctx, HookEntryInfo info) {
System.out.println(ctx.getIntArg(0));
}
};

long functionAddress = module.base + 0xC09D/*偏移*/;
//对单行地址进行hook
hookZz.instrument(functionAddress, instrumentCallback);

Symbol symbol = module.findSymbolByName("symbolName");
hookZz.instrument(symbol, instrumentCallback);

Dobby

64位模式下推荐使用Dobby进行inline hook。

获取Dobby实例
1
Dobby dobby = Dobby.getInstance(emulator);
Dobby API
replace
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Dobby dobby = Dobby.getInstance(emulator);
long functionAddress = module.base + 0xC09D/*偏移*/;
dobby.replace(functionAddress, new ReplaceCallback() {
@Override
public HookStatus onCall(Emulator<?> emulator, HookContext context, long originFunction) {
Pointer result = context.getPointerArg(0);
System.out.println("input:" + result.getString(0));
return super.onCall(emulator, context, originFunction);
}

@Override
public void postCall(Emulator<?> emulator, HookContext context) {
super.postCall(emulator, context);
}
},true);

Dobby.replace的用法同HookZz,详细使用请查看HookZz-replace

instrument

同HookZz中的instrument

xHook

xHook框架实现原理本身就存在局限性,只能够hook符号表函数。有好有坏,它的优点就是稳定。

获取xHook实例
1
IxHook hook = XHookImpl.getInstance(emulator);
xHook API
register
1
2
IxHook hook = XHookImpl.getInstance(emulator);

xHook的register方法的第一个参数为pathname_regex_str, 其支持POSIX BRE (Basic Regular Expression) 定义的正则表达式语法。用来检索符合条件的So。第二个参数为符号名。详情请看xhook介绍

refresh
1
hook.refresh(); 

在使用xHook进行hook时,务必调用此方法进行刷新才能生效。

Whale

Whale Hook框架为跨平台的Hook框架,在Android中的实现为inline hook。

获取Whale实例

IWhale whale = Whale.getInstance(emulator);

Whale API

TODO

原生hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
emulator.attach().addBreakPoint(module.findSymbolByName("memcmp").getAddress(), new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
System.out.println("callMemcmp作比较");
RegisterContext registerContext = emulator.getContext();
UnidbgPointer arg1 = registerContext.getPointerArg(0);
UnidbgPointer arg2 = registerContext.getPointerArg(1);
int length = registerContext.getIntArg(2);
Inspector.inspect(arg1.getByteArray(0, length), "arg1");
Inspector.inspect(arg2.getByteArray(0, length), "arg2");
if (arg1.getString(0).equals("Context")){
emulator.attach().debug();
// mr1
}
return true;
}
});

patch

第一种,使用arm转机器码
转换网址 https://armconverter.com/

1
2
3
4
5
public void patch(){
UnidbgPointer pointer = UnidbgPointer.pointer(emulator,module.base + 0x3E8);
byte[] code = new byte[]{(byte) 0xd0, 0x1a};
pointer.write(code);
}

第二种,使用keystone

1
2
3
4
5
6
7
8
public void patch2(){
UnidbgPointer pointer = UnidbgPointer.pointer(emulator,module.base + 0x3E8);
Keystone keystone = new Keystone(KeystoneArchitecture.Arm, KeystoneMode.ArmThumb);
String s = "subs r0, r2, r3";
byte[] machineCode = keystone.assemble(s).getMachineCode();
//byte[] code = ;
pointer.write(machineCode);
}

JNI

JNI介绍

当一个So在模拟执行的过程中,它要与Java层进行交互,比如获取Java层某个字段的值、调用Java层的某个方法获取返回值等等,如果你不告诉unidbg如何做,那程序就无法执行下去。你告诉undibg碰到访问Java层的某些东西的时候该怎么做的这个过程,从而让程序正确的执行下去,就叫做补环境。
我们在JNI部分来介绍unidbg如何来补环境,首先我们要来看unidbg中的几个概念

类/类型

Java中可以抽象出一个类,一个类可以创建对象,可以调用该类上的静态方法。一个类的基本属性有很多,unidbg模拟了父类和接口。在unidbg中对应的为DvmClass。

1
2
3
4
//参数一: 类名
DvmClass Test = vm.resolveClass("com/github/unidbg/Test");
//重载方法,可以指定父类型。也就是表示了TestUnidbg继承了Test类。
DvmClass dvmClass = vm.resolveClass("com/github/unidbg/TestUnidbg", Test);

VM的resolveClass方法会创建出一个类型,可以理解为Java中的一个类,参数为全限定类名,将.换为/。这样就创建出来一种类型了。我们可以基于此类型创建对象。

对象/实例

Java层肯定会有对象的,或者叫做实例,unidbg中对应的叫做DvmObject。unidbg中所有的Java层的对象都直接或间接的继承自DvmObject。模拟创建对象:

  • 第一种方法

    1
    2
    3
    DvmClass dvmClass = vm.resolveClass("com/github/unidbg/Test");
    //参数一: 值
    DvmObject<?> obj = dvmClass.newObject(null);

    在Java中,有了类,就可以new对象了对吧。unidbg的newObject操作就类似这样。它可以有了一种类型后进行创建对象,其中的参数你可以存储任意类型的数据,此数据就会与该DvmObject绑定。

  • 第二种方法

    1
    2
    3
    4
    //根据数据来创建DvmObject
    //参数一: VM实例
    //参数二: value
    DvmObject<?> obj = ProxyDvmObject.createObject(vm, this);

    unidbg会根据此value值的类型来创建DvmObject。如果value为基本数据类型则会被封装为该基本类型的包装类型。如果value为一个对象,则会获取该对象所对应的Class,根据Class名称来自动创建该类型,然后创建一个对象,相当于对第一种方法的一个封装。如果为数组类型,则会被封装为由unidbg创建的包装对象。

包装对象

理论上所有的Java层的对象在unidbg中都可以按照上面所述的方法进行创建。但unidbg内部有部分封装,所以在使用的时候我们要使用unidbg的包装对象

String

String类型被封装为StringObject

1
new StringObject(vm,"unidbg"); 

Array

数组类型在使用的时候按如下使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//byte[]
ByteArray byteArray = new ByteArray(vm, new byte[]{1, 2, 3});

//int[]
IntArray intArray = new IntArray(vm, new int[]{1, 2, 3});

//short[]
ShortArray shortArray = new ShortArray(vm, new short[]{1, 2, 3});

//float[]
FloatArray floatArray = new FloatArray(vm, new float[]{1.0f, 2.0f, 3.0f});

//double[]
DoubleArray doubleArray = new DoubleArray(vm, new double[]{1.0d, 2.0d, 3.0d});

//Object[]
new ArrayObject(byteArray, intArray);

List

List类型的数据被封装为ArrayListObject

1
2
3
4
DvmClass Test = vm.resolveClass("com/github/unidbg/Test");
List<DvmObject<?>> list = new ArrayList<>();
list.add(Test.newObject(null));
new ArrayListObject(vm,list);

Module

Module介绍

在undibg中,经过Memory加载到内存中的Elf文件,都被抽象为一个Module(LinuxModule)对象。无论是Emulator还是VM加载的,底层都是对Memory的load进行了封装,返回值就是一个Module实例。

Module常用的属性

base

1
2
Module module = emulator.loadLibrary(file, true);
System.out.println("加载的基址: "+module.base);

base属性维护了该Elf文件被加载到内存中的基址。

name

1
2
Module module = emulator.loadLibrary(file, true);
System.out.println("模块的名称: "+module.name);

name属性维护了该模块的名称,通常为文件名。

callEntry

1
2
Module module = emulator.loadLibrary(file, true);
int ret = module.callEntry(emulator);

当被加载的文件是可执行文件时,需要通过该方法来调用main函数。

findSymbolByName

1
2
Module module = emulator.loadLibrary(file, true);
Symbol symbol = module.findSymbolByName("symbolName");

通过名字查找当前模块中的符号。

Backend

Backend介绍

unidbg内置了五种Backend。Backend的作用是用来模拟执行机器指令。它作为一个模块被unidbg引入,提供了模拟CPU的能力,且被封装为统一的Backend接口。

Backend列表

unidbg默认使用Unicorn后端。即在创建AndroidEmulator时无额外添加BackendFactory即为Unicorn后端。

Unicorn/Unicorn2

Unicorn官网

使用Unicorn2后端:

emulator = AndroidEmulatorBuilder .for32Bit() .addBackendFactory(new Unicorn2Factory(true)) .build();
Unicorn2Factory构造方法的参数表示在Unicorn2创建失败时,是否退回使用Unicorn后端。如设置false,在Unicorn2后端创建失败后就退出,而不选择使用Unicorn后端。

Unicorn后端特点
  • 执行较其他后端慢
  • 支持原生Hook
  • 支持Debugger功能
  • 支持原生Trace

对调试、Trace功能支持较好,缺点是执行较慢。推荐在实验环境中使用。

Dynarmic

Dynarmic官网

使用Dynarmic后端
1
2
3
4
emulator = AndroidEmulatorBuilder
.for32Bit()
.addBackendFactory(new DynarmicFactory(true))
.build();
Dynarmic后端特点
  • 执行速度快
  • 无原生Hook功能

生产环境推荐使用,速度比Unicorn快很多。

Hypervisor

Hypervisor官网

使用Hypervisor后端
1
2
3
4
emulator = AndroidEmulatorBuilder
.for32Bit()
.addBackendFactory(new HypervisorFactory(true))
.build()
Hypervisor后端特点
  • 支持Apple M1
  • 最快的ARM64后端

Kvm

Kvm官网

使用Kvm后端
1
2
3
4
emulator = AndroidEmulatorBuilder
.for64Bit()
.addBackendFactory(new KvmFactory(false))
.build();
Kvm后端特点
  • 支持Raspberry Pi B4

Backend接口

Backend为CPU的抽象接口,通过它可以直接读写CPU的寄存器的值、内存读写。unidbg对此类接口都有对应的封装,不建议直接使用此类接口。

Deggbuer

Debugger介绍

重要提醒: 只有unicorn后端支持调试功能!!! 重要提醒: 只有unicorn后端支持调试功能!!! 重要提醒: 只有unicorn后端支持调试功能!!!
调试是在逆向工程中不可缺少的一个功能,可以帮助我们快速定位问题。在unidbg也是支持的,而且这部分功能基于原生Unicorn后端,支持非常好。
主要功能:

  • 断点调试
  • 内存读写
  • 寄存器读写
  • 单步调试
  • 堆栈搜索
  • 调用栈打印
  • 反汇编
  • patch

ConsoleDebugger

使用步骤

第零步

再提示一下,先将后端切换回Unicorn。如何切换请查看: Backend

第一步

得到控制台调试器实例。

1
Debugger debugger = emulator.attach();
第二步

下断点。下断点有多种重载方便使用:

  • 模块 + 偏移
  • 模块 + 符号名
  • 绝对地址
    1
    2
    3
    4
    5
    6
    7
    8
    9
    //模块 + 偏移
    debugger.addBreakPoint(module, 0x8607);

    //模块 + 符号
    debugger.addBreakPoint(module, "Java_com_github_unidbg_Test_sign");

    // 绝对地址
    long address = module.base + 0x8607;
    debugger.addBreakPoint(address);
    下断点无需手动thumb指令+1操作,手动+1下断点也是一样的效果。但手动+1会有其他的作用,如查看断点等功能,推荐手动+1。
    还有其他三种断点重载,添加一个断点回调:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    BreakPointCallback breakPointCallback = new BreakPointCallback() {
    @Override
    public boolean onHit(Emulator<?> emulator, long address) {
    //当断点命中会回调此方法
    //do something...
    //返回值决定是否进入手动调试。
    //返回true,则只回调此方法,不进入调试
    return false;
    }
    };

    debugger.addBreakPoint(module, 0x8607, breakPointCallback);

    debugger.addBreakPoint(module, "Java_com_github_unidbg_Test_sign", breakPointCallback);

    long address = module.base + 0x8607;
    debugger.addBreakPoint(address, breakPointCallback);
第三步

执行。无论使用哪种方式对目标地址下断点,在执行起来后如果执行到断点位置,就会在控制台断下等待命令的输入。以我目前例子,命中断点后输出如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
debugger break at: 0x40008606 @ Function32 address=0x40008607, arguments=[unidbg@0xfffe12a0[libadd.so]0x2a0, 17037394, 1]
>>> r0=0xfffe12a0(-126304) r1=0x103f852 r2=0x1 r3=0x2 r4=0x0 r5=0x0 r6=0x0 r7=0x0 r8=0x0 sb=0x0 sl=0x0 fp=0x0 ip=0x401315e0
>>> SP=0xbffff730 LR=unidbg@0xffff0000 PC=RX@0x40008606[libnative-doc.so]0x8606 cpsr: N=0, Z=1, C=0, V=0, T=1, mode=0b10000
>>> d0=0x0(0.0) d1=0x3220302034203720(3.002229861217884E-67) d2=0x3436333832203236(3.5366761868402984E-57) d3=0x3120323938343135(4.583358096989596E-72) d4=0x2030203020302030(1.2027122125173386E-153) d5=0x2030203020302030(1.2027122125173386E-153) d6=0x2030203020302030(1.2027122125173386E-153) d7=0x2030203020302030(1.2027122125173386E-153)
>>> d8=0x0(0.0) d9=0x0(0.0) d10=0x0(0.0) d11=0x0(0.0) d12=0x0(0.0) d13=0x0(0.0) d14=0x0(0.0) d15=0x0(0.0)
Java_com_github_unidbg_Test_sign + 0x0
=> *[libnative-doc.so*0x08607]*[80b5 ]*0x40008606:*"push {r7, lr}"
[libnative-doc.so 0x08609] [6f46 ] 0x40008608: "mov r7, sp"
[libnative-doc.so 0x0860b] [84b0 ] 0x4000860a: "sub sp, #0x10"
[libnative-doc.so 0x0860d] [0390 ] 0x4000860c: "str r0, [sp, #0xc]"
[libnative-doc.so 0x0860f] [0291 ] 0x4000860e: "str r1, [sp, #8]"
[libnative-doc.so 0x08611] [0192 ] 0x40008610: "str r2, [sp, #4]"
[libnative-doc.so 0x08613] [0198 ] 0x40008612: "ldr r0, [sp, #4]"
[libnative-doc.so 0x08615] [4ff48061] 0x40008614: "mov.w r1, #0x400"
[libnative-doc.so 0x08619] [fff75aec] 0x40008618: "blx #0x40007ed0"
[libnative-doc.so 0x0861d] [04b0 ] 0x4000861c: "add sp, #0x10"
[libnative-doc.so 0x0861f] [80bd ] 0x4000861e: "pop {r7, pc}"
[libnative-doc.so 0x08621] [80b5 ] 0x40008620: "push {r7, lr}"
[libnative-doc.so 0x08623] [6f46 ] 0x40008622: "mov r7, sp"
[libnative-doc.so 0x08625] [82b0 ] 0x40008624: "sub sp, #8"
[libnative-doc.so 0x08627] [0190 ] 0x40008626: "str r0, [sp, #4]"
[libnative-doc.so 0x08629] [0198 ] 0x40008628: "ldr r0, [sp, #4]"

主要信息有: 断点和当前函数参数信息,当前所有寄存器信息,当前位置的反汇编信息。 看到如上调试信息后,unidbg就会等待命令的输入,命令才是ConsoleDebugger的核心。下面我们就来看如何使用命令来操作ConsoleDebugger。

调试指令

help
1
help 

会看到下面的输出,此为ConsoleDebugger的帮助文档。我们下面也是介绍此帮助文档的内容。

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
help
c: continue
n: step over
bt: back trace

st hex: search stack
shw hex: search writable heap
shr hex: search readable heap
shx hex: search executable heap

nb: break at next block
s|si: step into
s[decimal]: execute specified amount instruction
s(blx): execute util BLX mnemonic, low performance

m(op) [size]: show memory, default size is 0x70, size may hex or decimal
mr0-mr7, mfp, mip, msp [size]: show memory of specified register
m(address) [size]: show memory of specified address, address must start with 0x

wr0-wr7, wfp, wip, wsp <value>: write specified register
wb(address), ws(address), wi(address) <value>: write (byte, short, integer) memory of specified address, address must start with 0x
wx(address) <hex>: write bytes to memory at 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
r: remove breakpoint of register PC
blr: add temporarily breakpoint of register LR

p (assembly): patch assembly at PC address
where: show java stack trace

trace [begin end]: Set trace instructions
traceRead [begin end]: Set trace memory read
traceWrite [begin end]: Set trace memory write
vm: view loaded modules
vbs: view breakpoints
d|dis: show disassemble
d(0x): show disassemble at specify address
stop: stop emulation
run [arg]: run test
gc: Run System.gc()
cc size: convert asm from 0x40008608 - 0x40008608 + size bytes to c function

shr

1
shr 637c777bf26b6fc5

内存查找字节

show disassemble
1
d | dis

打印当前位置反编译信息。

1
2
3
d(address)
//如
d0x40008607

打印指定地址处的反编译信息。

continue
1
c

继续执行。直至遇到下一个断点断下或运行结束。

step over
1
n

单步步过,不进入跳转指令。

step into
1
s | si 

单步执行。执行一句指令。

1
s10 

执行10句指令。

1
sblx 

执行到blx指令。性能较低。

next block
1
nb 

执行到下一block处。

b
1
b 

可在当前PC处下断点。

1
b0x40008607 

指定地址下断点。

1
b0x8607 

指定偏移下断点,会根据当前位置的模块来计算。

1
blr 

在LR处下断点。

r
1
r 

删除当前位置的断点。

blr

在函数返回时候下断点

vbs
1
vbs 

查看当前下的所有断点。

back trace
1
bt 

打印当前位置调用栈。

show memory
1
2
3
mr0-mr7 | mfp | mip | msp [size]
//如
mr1 0x10

读取对应寄存器所指向的地址的内存。size指定大小,默认0x70大小。

1
2
3
m(address) [size]
//如
m0xbffff730 10

读取指定地址的内存。地址必须为0x开头的十六进制数。

write memory
1
2
3
wr0-wr7, wfp, wip, wsp <value>
//如
wr2 0x100

向对应寄存器写入value指定的值。此值可为0x开头十六进制数,可为十进制数。

1
2
3
4
wb(address), ws(address), wi(address) <value>
//如
wb0x40008606 1
ws0x40008606 0xb502

向指定地址写入value指定的值。写入的宽度由不同指令所指定,wb能写入一个字节数据、ws能short数据、wi能写入int数据,且大小端序会被自动转换。

1
2
3
wx(address) <hex>
//如
wx0x40008606 b502

向指定地址写入hex数据,此处hex不能加0x开头,直接写十六进制就可以了。

search stack
1
2
3
st <hex>
//如
st 313233

在当前栈上搜索指定的hex数据。

search writable heap
1
2
3
shr <hex>
//如
shr 313233

在可写的内存搜索指定的hex数据。

search readable heap
1
2
3
shx <hex>
//如
shx 6f46

在可读的内存搜索指定的hex数据。

search executable heap
1
2
3
shx <hex>
//如
shx 6f46

在可执行的内存搜索指定的hex数据。

where
1
where 

打印当前unidbg的调用栈。

vm
1
vm 

查看当前所有加载的模块。

stop
1
stop 

停止运行。

patch
1
2
3
p <assembly>
//如
p movs r0, #1

将当前位置进行patch,无需关心thumb。

trace
1
2
3
4
5
6
trace [begin end]
//如
trace 0x40008606 0x40008612

traceRead [begin end]
traceWrite [begin end]

指定起始结束位置进行trace。

GDB_SERVER

不推荐使用。请使用ConsoleDebugger

ANDROID_SERVER_V7

不推荐使用。请使用ConsoleDebugger

Block

什么叫做block?简单来说就是未发生跳转的一整段汇编为一个block,如发生B系列指令跳转,就会进入下一block。

image

RegisterContext

TODO

SyscallHandler

SyscallHandler介绍

SyscallHandler模块为处理系统调用处理器的核心模块,模拟了在执行过程中所产生的大大小小的系统调用。虽然该模块为核心模块,但提供的API并不多,我们来看。

API

获取SyscallHandler实例

1
SyscallHandler<AndroidFileIO> syscallHandler = emulator.getSyscallHandler(); 

setVerbose

1
syscallHandler.setVerbose(true); 

部分系统调用日志开关。

addIOResolver

1
2
3
4
5
6
7
8
9
syscallHandler.addIOResolver(new IOResolver<AndroidFileIO>() {
@Override
public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
//参数二: 文件路径
//参数三: 操作文件标志
//返回值: FileResult
return null;
}
});

添加IO处理器。如果在模拟执行期间,有读写文件操作,可由自定义指定的IO处理器进行处理,且自定义IO处理器的优先级最高。关于返回值请查看FileResult

setEnableThreadDispatcher

1
syscallHandler.setEnableThreadDispatcher(true); 

多线程自动调度开关。开启此开关,会以多线程模式运行,当有线程创建会自动进行处理调度。

setFileListener

1
2
3
4
5
6
7
8
9
10
11
syscallHandler.setFileListener(new FileListener() {
@Override
public void onOpenSuccess(Emulator<?> emulator, String pathname, FileIO io) {
System.out.println(pathname+"文件打开成功!");
}

@Override
public void onClose(Emulator<?> emulator, FileIO io) {
System.err.println(io.getPath()+"文件打开失败");
}
});

设置文件监听器。此API在在运行期间,可以监听所有成功打开和关闭的文件,打开失败的文件不会进行回调。

getFileIO

1
FileIO fileIO = syscallHandler.getFileIO(0); 

根据fd查询对应的File。

自处理系统调用

如果unidbg处理的系统调用不足以满足你的需求,可以自己参考unidbg的处理方式,定制SyscallHandler来处理。

FileResult

在自定义IO处理中,FileResult作为回调方法的返回值。其有3个静态方法,分别代表对文件的不同操作。

success

1
2
FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags, new File("filepath"),pathname));

success方法表示一个文件正常处理。参数是一个NewFileIO接口的实现,根据不同的场景返回不同。如上面例子,就是返回一个普通文件,就使用SimpleFileIO实现,如果是一个文件夹,就使用DirectoryFileIO实现,其他场景可自行查看接口的实现类。

failed

1
FileResult.failed(2);

表示文件打开失败。参数是一个errno,会由上层处理将errno设置为该参数。

fallback

1
FileResult.<AndroidFileIO>fallback(new DirectoryFileIO(oflags,pathname,new File("filepath")));

表示降低自己的优先级,在其他处理器无法找到该文件时,由fallback指定的文件IO来代替。

VirtualModule

VirtualModulem介绍

VirtualModule是unidbg提供的一种虚拟模块方案。它的用途是当一个So由于某种原因,无法完整加载进unidbg,或想自己实现该So的部分功能时,就可以使用虚拟模块来解决。

unidbg自带虚拟模块

unidbg中自带了两个虚拟模块:

  • AndroidModule(libandroid.so)
  • JniGraphics(libjnigraphics.so)

这两个模块中的部分常用符号已经在unidbg中有提供,当我们要模拟执行一个So时,而它又正好使用了libandroid.so,我们就可以这样做:

1
new AndroidModule(emulator,vm).register(memory); 

然后再加载目标So。libjnigraphics.so同理。

自定义虚拟模块

当然我们也可以自己实现一个虚拟模块。下面我们以一个例子来说明虚拟模块应该如何使用。

模拟场景

我们先来模拟一个使用虚拟So的场景。先来看Java层的代码:

1
2
3
4
5
6
7
8
9
10
package com.github.unidbg;

public class Test {

static {
System.loadLibrary("native-doc");
}

public native int sign(int a);
}

Java层加载了libnative-doc.so,有一个sign方法,参数跟返回值都为int类型。我们再来看So层的代码:

1
2
3
4
5
6
7
8
#include <jni.h>
#include "myAdd.h"

extern "C"
JNIEXPORT jint JNICALL
Java_com_github_unidbg_Test_sign(JNIEnv *env, jobject thiz, jint a) {
return myAdd(a, 1024);
}

So的代码有sign方法的实现,而且实现非常简单,调用了myAdd函数。但是这个myAdd函数是来自其他的So的一个函数,而且这个So还由于某种原因不能被正常加载或没必要去加载,我们来看在unidbg中该如何去做。

首次尝试

现在首先要做的是在unidbg中加载native-doc模块,看代码:

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
package com.github.unidbg.android;

import com.alibaba.fastjson.util.IOUtils;
import com.github.unidbg.*;
import com.github.unidbg.Module;
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.memory.Memory;

import java.io.File;

public class DocTest extends AbstractJni {

public static void main(String[] args) {
DocTest test = new DocTest();
test.sign();
test.destroy();
}

private void destroy() {
//清理资源
IOUtils.close(emulator);
}

private final AndroidEmulator emulator;
private final Module module;
private final VM vm;

private DocTest() {
//创建模拟器实例
emulator = AndroidEmulatorBuilder
.for32Bit()
.build();
//获取Memory接口
Memory memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
//创建虚拟机
vm = emulator.createDalvikVM();
//加载模块
module = emulator.loadLibrary(new File("unidbg-android/src/test/java/com/github/unidbg/libnative-doc.so"), true);
//执行JNI_OnLoad函数
vm.callJNI_OnLoad(emulator, module);
}

private void sign() {
//调用com/github/unidbg/Test类下的sign方法
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
int ret = obj.callJniMethodInt(emulator, "sign(I)I", 1);
System.out.println("执行结果: " + ret);
}
}

当我们写完上面的代码,执行时会发现如下错误:

1
2
libnative-doc.so load dependency libadd.so failed

unidbg会提示你libnative-doc.so需要一个libadd.so,而unidbg又找不到这个So来加载,所以就无法执行出正常的结果。这个libadd.so提供了myAdd函数的实现,但是它无法被正常加载,我们知道的是myAdd函数的实现是什么样子的。它就是将两个参数进行相加,然后返回结果。我们知道实现就好办了,下面通过虚拟So来解决这个问题。

处理问题

处理这种问题就分两步。第一步创建虚拟模块,来处理未找到的符号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//继承VirtualModule类
public static class Add extends VirtualModule<VM>{

public Add(Emulator<?> emulator, VM extra){
//参数三为我们无法加载的So模块名称全称
super(emulator,extra,"libadd.so");
}

@Override
protected void onInitialize(Emulator<?> emulator, VM extra, Map<String, UnidbgPointer> symbols) {
SvcMemory svcMemory = emulator.getSvcMemory();
//添加一个myAdd符号
symbols.put("_Z5myAddii",svcMemory.registerSvc(new ArmSvc() {
@Override
public long handle(Emulator<?> emulator) {
RegisterContext context = emulator.getContext();
int arg0 = context.getIntArg(0);
int arg1 = context.getIntArg(1);
return arg0 + arg1;
}
}));
}
}

第二步,在加载我们目标So时,先来加载我们的虚拟So:

1
2
//加载虚拟模块
new Add(emulator, vm).register(memory);

这样就可以搞定啦。

完整代码

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package com.github.unidbg.android;

import com.alibaba.fastjson.util.IOUtils;
import com.github.unidbg.*;
import com.github.unidbg.Module;
import com.github.unidbg.arm.ArmSvc;
import com.github.unidbg.arm.context.RegisterContext;
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.memory.Memory;
import com.github.unidbg.memory.SvcMemory;
import com.github.unidbg.pointer.UnidbgPointer;
import com.github.unidbg.virtualmodule.VirtualModule;

import java.io.File;
import java.util.Map;

public class DocTest extends AbstractJni {

public static void main(String[] args) {
DocTest test = new DocTest();
test.sign();
test.destroy();
}

private void destroy() {
//清理资源
IOUtils.close(emulator);
}

private final AndroidEmulator emulator;
private final Module module;
private final VM vm;

private DocTest() {
//创建模拟器实例
emulator = AndroidEmulatorBuilder
.for32Bit()
.build();
//获取Memory接口
Memory memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
//创建虚拟机
vm = emulator.createDalvikVM();

//加载虚拟模块
new Add(emulator, vm).register(memory);

//加载模块
module = emulator.loadLibrary(new File("unidbg-android/src/test/java/com/github/unidbg/libnative-doc.so"), true);
//执行JNI_OnLoad函数
vm.callJNI_OnLoad(emulator, module);
}

private void sign() {
//调用com/github/unidbg/Test类下的sign方法
DvmClass testClass = vm.resolveClass("com/github/unidbg/Test");
DvmObject<?> obj = testClass.newObject(null);
int ret = obj.callJniMethodInt(emulator, "sign(I)I", 1);
System.out.println("执行结果: " + ret);
}

public static class Add extends VirtualModule<VM> {

public Add(Emulator<?> emulator, VM extra) {
super(emulator, extra, "libadd.so");
}

@Override
protected void onInitialize(Emulator<?> emulator, VM extra, Map<String, UnidbgPointer> symbols) {
SvcMemory svcMemory = emulator.getSvcMemory();
//添加一个myAdd符号
symbols.put("_Z5myAddii", svcMemory.registerSvc(new ArmSvc() {
@Override
public long handle(Emulator<?> emulator) {
RegisterContext context = emulator.getContext();
int arg0 = context.getIntArg(0);
int arg1 = context.getIntArg(1);
return arg0 + arg1;
}
}));
}
}
}

Jnitrace

使用

1
jnitrace -l so文件 包名 > xxx.txt

jnitrace函数调用返回的是地址的时候

image
SO运行存在多线程的问题,多线程会给Trace分析带来干扰,我们在文本中查找TID 11372,跟着这个线 程走下去
image
此处在获取0x49即我们的字符串的长度,继续往下
image
此处将jstring转成Native 字符串,在JNI开发中,大部分JObject都属于局部引用,局部引用在用完之后需要手动释放
image
比如GetStringUTFChars获取的资源,最后一定要ReleaseStringUTFChars,JNItrace会在Releasexxx这 系列API中,打印内容
image
但是需要注意的是,引用的释放方式有两种,除了Releasexx系列,也可以用DeleteLocalRef释放局部引 用,如果通过DeleteLocalRef释放,JNItrace并不会打印出具体值

堆栈检测

“java/lang/Throwable->()V”
new一个异常之后
“java/lang/Throwable->getStackTrace()[Ljava/lang/StackTraceElement;”
查看堆栈,这不就是堆栈检测吗,如果被Xposed Hook了,堆栈里会有Xposed这一 层。
new 异常之后,getStackTrace即得到异常堆栈数组,写代码验证一下
image继续看JNItrace
image
首先获取数据长度,这很好理解嘛,后面肯定是想循环遍历取出完整的调用栈,得知道调用栈有多长
image
获取数组第0个元素
image
getClassName,即获取具体的类名
image
因为是在JNI中处理,所以后面还得getString
image
用完之后Release释放这个字符串资源,防止内存泄漏
image
com.xunmeng.pinduoduo.secure.DeviceNative即调用栈的第一层
然后它对数组的第二个元素做同样的操作,一套流程总结下来,就是构造异常后读取调用栈嘛

推荐使用objection查看堆栈

image

函数调用两种方式

Unidbg call 参数传递,到底是自己call 地址 还是用Unidbg封装的API?
各有优劣,用API的话代码量小一些,自己call的话更灵活,限制小。熟能生巧。

符号调用

1
2
3
4
5
DvmObject obj = ProxyDvmObject.createObject(vm,this);
String data = "dta";
DvmObject dvmObject = obj.callJniMethodObject(emulator, "md5(Ljava/lang/String;)Ljava/lang/String;", data);
String result = (String) dvmObject.getValue();
System.out.println("[symble] Call the so md5 function result is ==> "+ result);

地址调用

1
2
3
4
5
6
7
8
9
10
11
12
13
Pointer jniEnv = vm.getJNIEnv();
DvmObject obj = ProxyDvmObject.createObject(vm,this);
StringObject data = new StringObject(vm,"dta");

List<Object> args = new ArrayList<>();
args.add(jniEnv);
args.add(vm.addLocalObject(obj));
args.add(vm.addLocalObject(data));

Number[] numbers = module.callFunction(emulator, 0x8E81, args.toArray());
DvmObject<?> object = vm.getObject(numbers[0].intValue());
String value = (String) object.getValue();
System.out.println("[addr] Call the so md5 function result is ==> "+ value);

构建类的两种方式

以当前包类为类名

1
DvmObject obj = ProxyDvmObject.createObject(vm,this);

自定义包名类名

1
DvmObject<?> context = vm.resolveClass("android/content/Context").newObject(null);

类继承的写法

image
链式继承
vm.resolveClass(“子类”, vm.resolveClass(“父类”, vm.resolveClass(“父类的父类”))).newObject(signature);

1
2
3
case "android/app/ActivityThread->getApplication()Landroid/app/Application;": {
return vm.resolveClass("android/app/Application", vm.resolveClass("android/content/ContextWrapper", vm.resolveClass("android/content/Context"))).newObject(signature);
}

多行的写法

1
2
3
4
DvmClass context = vm.resolveClass("android/content/Context");
DvmClass ContextWrapper = vm.resolveClass("android/content/ContextWrapper",context);
DvmClass Application = vm.resolveClass("android/app/Application",ContextWrapper);
return Application.newObject(signature);

unidbg补获取系统属性

unidbg补获取系统属性
可以使用unidbg封装好的hook方法知道系统调用了哪些系统属性,__system_property_get

1
2
3
4
5
6
7
8
9
10
11
SystemPropertyHook systemPropertyHook = new SystemPropertyHook(emulator);
systemPropertyHook.setPropertyProvider(new SystemPropertyProvider() {
@Override
public String getProperty(String key) {
System.out.println("lilac Systemkey:"+key);
switch (key){
}
return "";
};
});
memory.addHookListener(systemPropertyHook);

文件重定向,实现 IOResolver

需要继承 IOResolver 重写 resolve方法
在定义emulator好后,在so加载前添加文件重定位器
emulator.getSyscallHandler().addIOResolver(this);
指定路径文件定向(单文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if(pathname.equals("/data/user/0/com.example.fileinunidbg/files/key.txt")){
return FileResult.success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/FileDemo/key.txt"), pathname));
// return FileResult.success(new ByteArrayFileIO(oflags, pathname, String.valueOf(System.currentTimeMillis()).getBytes(StandardCharsets.UTF_8)));
// return null;
// return FileResult.failed(13);
}
// 返回对应的文件夹(目录文件)
if(pathname.equals("/data/user/0/com.example.fileinunidbg/files/demo2")){
return FileResult.success(new DirectoryFileIO(oflags, pathname,
new DirectoryFileIO.DirectoryEntry(true, String.valueOf(System.currentTimeMillis())), new DirectoryFileIO.DirectoryEntry(true, "1234")));
}
if(pathname.equals("/data/user/0/com.example.fileinunidbg/files/demo2/1.txt")){
return FileResult.success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/FileDemo/demo2/1.txt"), pathname));
}
if(pathname.equals("/data/user/0/com.example.fileinunidbg/files/demo2/2.txt")){
return FileResult.success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/FileDemo/demo2/2.txt"), pathname));
}
if(pathname.equals("/data/user/0/com.example.fileinunidbg/files/demo2/3.txt")){
return FileResult.success(new SimpleFileIO(oflags, new File("unidbg-android/src/test/resources/FileDemo/demo2/3.txt"), pathname));
}
if (("proc/" + emulator.getPid() + "/status").equals(pathname)) {
return FileResult.success(new ByteArrayFileIO(oflags, pathname, ("文本内容.....").getBytes())}

或者是虚拟目录(对比文件夹里的文件比较多的情况下比较好)
在创建emulator的时候直接指定目录

1
emulator = AndroidEmulatorBuilder.for32Bit().setRootDir(new File("unidbg-android/src/test/resources/FileDemo/VFS")).build();

补了虚拟目录和代码,哪边会生效?
代码执行会先看有没有重写文件重定位器,没有就默认重定位器,两个都没有找到就到虚拟目录系统找,

一些常用功能

打印内存,hexdump

1
Inspector.inspect(ctx.getR0Pointer().getByteArray(0, 0x10), "Arg1");

unidbg下断点

1
2
3
4
5
普通断点
emulator.attach().addBreakPoint(module.base + 0x3161E);

内存写入断点
emulator.traceWrite(module.base + 0x3A0C0,module.base + 0x3A0C0);

开启所有的日志

1
2
3
4
5
6
7
8
9
10
Logger.getLogger("com.github.unidbg.linux.ARM32SyscallHandler").setLevel(Level.DEBUG);
Logger.getLogger("com.github.unidbg.unix.UnixSyscallHandler").setLevel(Level.DEBUG);
Logger.getLogger("com.github.unidbg.AbstractEmulator").setLevel(Level.DEBUG);
Logger.getLogger("com.github.unidbg.linux.android.dvm.DalvikVM").setLevel(Level.DEBUG);
Logger.getLogger("com.github.unidbg.linux.android.dvm.BaseVM").setLevel(Level.DEBUG);
Logger.getLogger("com.github.unidbg.linux.android.dvm").setLevel(Level.DEBUG);

所需要的头文件
import org.apache.log4j.Level;
import org.apache.log4j.Logger;

计算汇编的行数

计算某个函数的汇编行数前使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void traceLength(){
emulator.getBackend().hook_add_new(new CodeHook() {
int count = 0;
@Override
public void hook(Backend backend, long address, int size, Object user) {
count += 1;
System.out.println(count);
}

@Override
public void onAttach(UnHook unHook) {

}

@Override
public void detach() {

}
}, module.base, module.base + module.size, null);
}

打印函数返回值或修改返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void hookRandom(){
emulator.attach().addBreakPoint(module.findSymbolByName("lrand48", true).getAddress(), new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
System.out.println("call lrand48");
// 打印调用栈
emulator.getUnwinder().unwind();
// 打印返回值或修改返回值
emulator.attach().addBreakPoint(emulator.getContext().getLRPointer().peer, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
//emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_R0, 0x12345678);
Number number = emulator.getBackend().reg_read(ArmConst.UC_ARM_REG_R0);
System.out.println("number " + number.intValue());
return true;
}
});
return true;
}
});
}

随机数函数返回值固定

程序中最常见的随机变量是时间戳和随机数,绝大多数情况下,程序会使用 arc4random 这样的库函数获取随机数,其底层依赖于 /dev/urandom/dev/random 等随机文件,或者直接访问这些文件,获取随机数。
在 Unidbg 里,这几个伪设备文件的模拟位置在 src/main/java/com/github/unidbg/linux/file/DriverFileIO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static DriverFileIO create(Emulator<?> emulator, int oflags, String pathname) {
if ("/dev/urandom".equals(pathname) || "/dev/random".equals(pathname) || "/dev/srandom".equals(pathname)) {
return new RandomFileIO(emulator, pathname);
}
if ("/dev/alarm".equals(pathname) || "/dev/null".equals(pathname)) {
return new DriverFileIO(emulator, oflags, pathname);
}
if ("/dev/ashmem".equals(pathname)) {
return new Ashmem(emulator, oflags, pathname);
}
if ("/dev/zero".equals(pathname)) {
return new ZeroFileIO(emulator, oflags, pathname);
}
return null;
}

到 RandomFileIO 类里,把 randBytes 函数里的 ThreadLocalRandom.current().nextBytes(bytes); 注释掉

获取 Trace

使用方式看 Trace,下面是保存到文件
如果不希望 trace 只是打印在日志里,而是要重定位到文件里,所以要多几行代码

1
2
3
4
5
6
7
8
String traceFile = "unidbg-android/src/test/resources/trace/tracecode.txt";
PrintStream traceStream;
try {
traceStream = new PrintStream(new FileOutputStream(traceFile), true);
emulator.traceCode(module.base,module.base+module.size).setRedirect(traceStream);
} catch (FileNotFoundException e) {
e.printStackTrace();
}

大概80w行耗时3分钟左右,是否可以优化和加速 Unidbg trace 的速度。
指令追踪的耗时有两部分,1 是为了实现指令追踪的能力,所做的指令膨胀和插桩。2 是在展示上的耗时。前一部分的损耗我们很难控制,而后一部分展示上的耗时,可做很多优化。
在展示上,trace 需要包括三要素。
● 地址
● 汇编代码
● 寄存器、内存的值变化展示
耗时主要在要素二,反汇编机器码获取汇编代码上。在 Unidbg 的处理逻辑里,运行每一条指令都会对它重新做反汇编。但我们都知道,如果地址和机器码都不变化(即没有发生代码自修改的情况里),使用先前对这个地址缓存下来的反汇编即可。
接下来让我们对 Unidbg 的源码做一点修改,下面只处理 ARM64。
找到unidbg-api/src/main/java/com/github/unidbg/arm/AbstractARM64Emulator.java,在类中添加一个 HashMap 用于存储反汇编结果,我们将使用它来缓存反汇编。

1
private final Map<Long, Instruction[]> disassembleCache = new HashMap<>();

printAssemble 函数的代码原先如下

1
2
3
4
5
6
@Override
public Instruction[] printAssemble(PrintStream out, long address, int size, int maxLengthLibraryName, InstructionVisitor visitor) {
Instruction[] insns = disassemble(address, size, 0);
printAssemble(out, insns, address, ARM.isThumb(backend), maxLengthLibraryName, visitor);
return insns;
}

修改它,当调用 printAssemble 函数时,首先检查缓存中是否已有相应的反汇编结果,如果有则直接使用,否则进行反汇编并将结果存入缓存。

1
2
3
4
5
6
7
8
9
10
@Override
public Instruction[] printAssemble(PrintStream out, long address, int size, int maxLengthLibraryName, InstructionVisitor visitor) {
Instruction[] insns = disassembleCache.get(address);
if (insns == null) {
insns = disassemble(address, size, 0);
disassembleCache.put(address, insns);
}
printAssemble(out, insns, address, maxLengthLibraryName, visitor);
return insns;
}

重新测试 trace,这次重定向到 tracecode2.txt。

1
2
3
4
5
6
7
8
String traceFile = "unidbg-android/src/test/resources/trace/tracecode2.txt";
PrintStream traceStream;
try {
traceStream = new PrintStream(new FileOutputStream(traceFile), true);
emulator.traceCode(module.base,module.base+module.size).setRedirect(traceStream);
} catch (FileNotFoundException e) {
e.printStackTrace();
}

在耗时上,仅花费半分钟不到,可以说是速度大大提升,这么算的话,即使 trace 指令流的行数以亿计,也能在短短几个小时内 trace 完。
接下来继续修改 printAssemble 函数的代码,上面的代码没有考虑到代码自修改的情况,自修改即程序在运行期间修改自身指令,不管是出于解密、反调,还是其它目的,自修改都是一件常事。所以我们还要判断一下字节是否发生了改变。

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
@Override
public Instruction[] printAssemble(PrintStream out, long address, int size, int maxLengthLibraryName, InstructionVisitor visitor) {
Instruction[] insns = disassembleCache.get(address);
byte[] currentCode = backend.mem_read(address, size);
boolean needUpdateCache = false;
if (insns != null) {
byte[] cachedCode = new byte[size];
int offset = 0;
for (Instruction insn : insns) {
byte[] insnBytes = insn.getBytes();
System.arraycopy(insnBytes, 0, cachedCode, offset, insnBytes.length);
offset += insnBytes.length;
}

if (!Arrays.equals(currentCode, cachedCode)) {
needUpdateCache = true;
}
} else {
needUpdateCache = true;
}
if (needUpdateCache) {
insns = disassemble(address, currentCode, false, 0);
disassembleCache.put(address, insns);
}
printAssemble(out, insns, address, maxLengthLibraryName, visitor);
return insns;
}

新代码会判断当前地址的机器码是否和缓存的这个地址上的机器码一致,这增加了一点开销,运行完毕后比较一下,也就慢了几秒钟,完全能承受。

1
2
3
4
5
6
7
8
String traceFile = "unidbg-android/src/test/resources/trace/tracecode3.txt";
PrintStream traceStream;
try {
traceStream = new PrintStream(new FileOutputStream(traceFile), true);
emulator.traceCode(module.base,module.base+module.size).setRedirect(traceStream);
} catch (FileNotFoundException e) {
e.printStackTrace();
}

获取函数调用流

基于对指令流中BLBLR等函数调用指令的监控,Unidbg 实现了对函数的监控和追踪,我们稍作处理,将它重定向到文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String traceFile = "unidbg-android/src/test/resources/trace/tracefunction.txt";
try {
final PrintStream traceStream;
traceStream = new PrintStream(new FileOutputStream(traceFile), true);
emulator.attach().traceFunctionCall(module, new FunctionCallListener() {
@Override
public void onCall(Emulator<?> emulator, long callerAddress, long functionAddress) {
}
@Override
public void postCall(Emulator<?> emulator, long callerAddress, long functionAddress, Number[] args) {
traceStream.println("onCallFinish caller=" + UnidbgPointer.pointer(emulator, callerAddress) + ", function=" + UnidbgPointer.pointer(emulator, functionAddress));
}
});
} catch (FileNotFoundException e) {
e.printStackTrace();
}

代码置于 JNI_OnLoad 后。

在内存中搜索数据出现的位置

假如目标数据是EC 03 CE 8A 99 16 7F 9A 8F 2D F5 CD 8E E1 47 F2。
● 这串数据在第 25w 行左右的地址生成,地址是 A
● 在 36w 行的位置由 A 赋值给 B,即 A –> B
● 在 47w 行的位置 B –> C
● 在 55w 行的位置 C –> D
● 在 82w 行的位置 D –> E
● 在 97w 行的位置 E–> F
在逆向追溯时,从 F 开始做 traceWrite,经过四五次跳转,最终才能找到 A,非常的麻烦。
在这个 Android Native Runtime 里,只需要处理单个 SO 里的单个函数,因此它所使用和操纵的内存区块很有效,只有几 MB 大小。因此我们可以从函数起始点开始,高频率的搜索内存里是否出现了某个值,从而确定某个数据最早出现的位置,下面是基本的实现代码。

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
import com.github.unidbg.Emulator;
import com.github.unidbg.arm.backend.*;
import com.github.unidbg.pointer.UnidbgPointer;
import com.sun.jna.Pointer;

import java.util.*;

public class DataSearch {
private final Emulator<?> emulator;
private final TreeSet<Long> addressUsed = new TreeSet<>();
private final Map<Long, Byte> memoryData = new HashMap<>();
private final byte[] data;
private boolean find = false;
private int interval;
private long count = 0;
private final List<long[]> activePlace = new ArrayList<>();


public DataSearch(Emulator<?> emulator, String data) {
this.emulator = emulator;
this.data = hexStringToByteArray(data);
this.interval = 100;
hookReadAndWrite();
hookBlock();
}

public DataSearch(Emulator<?> emulator, String data, int interval) {
this.emulator = emulator;
this.data = hexStringToByteArray(data);
this.interval = interval;
hookReadAndWrite();
hookBlock();
}

public static byte[] hexStringToByteArray(String s) {
int len = s.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+ Character.digit(s.charAt(i + 1), 16));
}
return data;
}


private void hookReadAndWrite() {
WriteHook writeHook = new WriteHook() {
@Override
public void onAttach(UnHook unHook) {

}

@Override
public void detach() {

}

@Override
public void hook(Backend backend, long address, int size, long value, Object user) {
for (int offset = 0; offset < size; offset++) {
memoryData.put(address + offset, (byte) (value >>> (offset * 8)));
}
updateActivePlace(address, size);
}
};

ReadHook readHook = new ReadHook() {
@Override
public void onAttach(UnHook unHook) {

}

@Override
public void detach() {

}

@Override
public void hook(Backend backend, long address, int size, Object user) {
byte[] readBytes = emulator.getBackend().mem_read(address, size);
for (int offset = 0; offset < size; offset++) {
memoryData.put(address + offset, readBytes[offset]);
}
updateActivePlace(address, size);
}
};

emulator.getBackend().hook_add_new(writeHook, 1, 0, null);
emulator.getBackend().hook_add_new(readHook, 1, 0, null);
}

private void hookBlock() {
BlockHook blockHook = new BlockHook() {
@Override
public void onAttach(UnHook unHook) {

}

@Override
public void detach() {

}

@Override
public void hookBlock(Backend backend, long address, int size, Object user) {
count++;
if (!find && (count % interval == 0)) {
searchData();
}
}
};

emulator.getBackend().hook_add_new(blockHook, 1, 0, null);
}


private void searchData() {
if (!find) {
for (long[] range : activePlace) {
if (searchDataInRange(range)) {
find = true;
printAddress(range);
emulator.attach().debug();
break;
}
}
}
}

private boolean searchDataInRange(long[] range) {
for (long i = range[0], max = range[1] - data.length; i <= max; i++) {
boolean found = true;
for (int j = 0; j < data.length; j++) {
Byte value = memoryData.get(i + j);
if (value == null || value != data[j]) {
found = false;
break;
}
}
if (found) {
return true;
}
}
return false;
}

private void printAddress(long[] range) {
Collection<Pointer> pointers = searchMemory(range[0], range[1], data);
System.out.println("Search data matches " + pointers.size() + " count");
for (Pointer pointer : pointers) {
System.out.println("data address: " + pointer);
}
}

private Collection<Pointer> searchMemory(long start, long end, byte[] data) {
List<Pointer> pointers = new ArrayList<>();
if (end - start >= data.length) {
for (long i = start, max = end - data.length; i <= max; i++) {
boolean found = true;
for (int j = 0; j < data.length; j++) {
Byte value = memoryData.get(i + j);
if (value == null || value != data[j]) {
found = false;
break;
}
}
if (found) {
pointers.add(UnidbgPointer.pointer(emulator, i));
}
}
}
return pointers;
}

private void updateActivePlace(long address, int size) {
for (int i = 0; i < size; i++) {
long newAddress = address + i;
if (addressUsed.add(newAddress)) {
updateActivePlace(newAddress);
}
}
}

private void updateActivePlace(long address) {
Long lower = addressUsed.lower(address);
Long higher = addressUsed.higher(address);

boolean connectToLower = lower != null && lower + 1 == address;
boolean connectToHigher = higher != null && address + 1 == higher;

long[] lowerRange = null;
long[] higherRange = null;

for (long[] range : activePlace) {
if (connectToLower && range[1] == lower) {
lowerRange = range;
}
if (connectToHigher && range[0] == higher) {
higherRange = range;
}
}

if (connectToLower && connectToHigher) {
assert lowerRange != null;
lowerRange[1] = higherRange[1];
activePlace.remove(higherRange);
} else if (connectToLower) {
assert lowerRange != null;
lowerRange[1] = address;
} else if (connectToHigher) {
assert higherRange != null;
higherRange[0] = address;
} else {
activePlace.add(new long[]{address, address});
}
}

}

它使用上非常简单,在 loadlibaray 前添加下面一行代码即可。

1
new DataSearch(Emulator<?> emulator, String data);

其中 data 是你所需要搜索内容的 hexstring 形式,加不加空格都行。

1
2
new DataSearch(emulator, "cd 8e ec");
new DataSearch(emulator, "cd8eec");

DataSearch 默认的搜索频率是每 100指令流,如果处理千万级或亿级的指令流,那么可以使用 DataSearch 的另外一个构造函数。

1
new DataSearch(Emulator<?> emulator, String data, int interval);

interval 用于指定每隔多少个基本块做一次内存检索,建议千万级指令流选择 500 左右,亿级指令流选择 2000 左右。检索频率越高,搜索的准确度越高,越可能发现数据的第一生成现场;检索越稀疏,搜索的耗时就越小,但可能会错过第一生成现场。因此,我们需要在性能和准确度之间找个均衡点。
DataSearch 并非万能,如果数据过短小,比如两三个字节,那么在内存中就不具有标识性和唯一性,无法用它处理。这种情景请使用污点分析或类似的其它分析技术。
除了搜索某个指定字节数组外,DataSearch 稍加修改,可以用于搜索 Key、字符串等等,比如下面是 StringSearch,它会打印内存中出现过的所有可见字符串。

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
import com.github.unidbg.Emulator;
import com.github.unidbg.arm.backend.*;
import com.github.unidbg.pointer.UnidbgPointer;
import com.sun.jna.Pointer;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.PrintWriter;
import java.util.*;

public class StringSearch {
private final Emulator<?> emulator;
private final TreeSet<Long> addressUsed = new TreeSet<>();
private final Map<Long, Byte> memoryData = new HashMap<>();
private Set<String> foundStrings = new HashSet<>();
private int interval;
private int minLength;
private long count = 0;
private final List<long[]> activePlace = new ArrayList<>();
private PrintWriter output;

public StringSearch(Emulator<?> emulator, int minLength, String outputFile) {
this.emulator = emulator;
this.interval = 100;
this.minLength = minLength;
try {
this.output = new PrintWriter(new FileOutputStream(outputFile));
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
hookReadAndWrite();
hookBlock();
}

public StringSearch(Emulator<?> emulator, int minLength, int interval, String outputFile){
this.emulator = emulator;
this.interval = interval;
this.minLength = minLength;
try {
this.output = new PrintWriter(new FileOutputStream(outputFile));
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
hookReadAndWrite();
hookBlock();
}

private void hookReadAndWrite() {
WriteHook writeHook = new WriteHook() {
@Override
public void onAttach(UnHook unHook) {

}

@Override
public void detach() {

}

@Override
public void hook(Backend backend, long address, int size, long value, Object user) {
for (int offset = 0; offset < size; offset++) {
memoryData.put(address + offset, (byte) (value >>> (offset * 8)));
}
updateActivePlace(address, size);
}
};

ReadHook readHook = new ReadHook() {
@Override
public void onAttach(UnHook unHook) {

}

@Override
public void detach() {

}

@Override
public void hook(Backend backend, long address, int size, Object user) {
byte[] readBytes = emulator.getBackend().mem_read(address, size);
for (int offset = 0; offset < size; offset++) {
memoryData.put(address + offset, readBytes[offset]);
}
updateActivePlace(address, size);
}
};

emulator.getBackend().hook_add_new(writeHook, 1, 0, null);
emulator.getBackend().hook_add_new(readHook, 1, 0, null);
}

private void hookBlock() {
BlockHook blockHook = new BlockHook() {
@Override
public void onAttach(UnHook unHook) {

}

@Override
public void detach() {

}

@Override
public void hookBlock(Backend backend, long address, int size, Object user) {
count++;
if (count % interval == 0){
searchString();
}
}
};

emulator.getBackend().hook_add_new(blockHook, 1, 0, null);
}


private void searchString() {
for (long[] range : activePlace) {
searchStringInRange(range);
}
}

private void searchStringInRange(long[] range) {
int length = (int) (range[1] - range[0] + 1);
byte[] codes = new byte[length];
for (int i = 0; i < length; i++) {
codes[i] = memoryData.get(i + range[0]);
}
searchVisibleStringsInMemory(codes, range[0]);
}

public void searchVisibleStringsInMemory(byte[] memory, long addr) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < memory.length; i++) {
byte b = memory[i];
if (b >= 32 && b <= 126) {
sb.append((char) b);
} else {
processVisibleString(sb, addr + i - sb.length());
}
}

processVisibleString(sb, addr + memory.length - sb.length());
}

private void processVisibleString(StringBuilder sb, long addr) {
if (sb.length() >= minLength) {
String visibleString = sb.toString();
if (foundStrings.add(visibleString)) {
String result = "Address: 0x" + Long.toHexString(addr) + ", String: " + visibleString;
output.println(result);
output.flush();
}
}
sb.setLength(0);
}

private void updateActivePlace(long address, int size) {
for (int i = 0; i < size; i++) {
long newAddress = address + i;
if (addressUsed.add(newAddress)) {
updateActivePlace(newAddress);
}
}
}


private void updateActivePlace(long address) {
Long lower = addressUsed.lower(address);
Long higher = addressUsed.higher(address);

boolean connectToLower = lower != null && lower + 1 == address;
boolean connectToHigher = higher != null && address + 1 == higher;

long[] lowerRange = null;
long[] higherRange = null;

for (long[] range : activePlace) {
if (connectToLower && range[1] == lower) {
lowerRange = range;
}
if (connectToHigher && range[0] == higher) {
higherRange = range;
}
}

if (connectToLower && connectToHigher) {
assert lowerRange != null;
lowerRange[1] = higherRange[1];
activePlace.remove(higherRange);
} else if (connectToLower) {
assert lowerRange != null;
lowerRange[1] = address;
} else if (connectToHigher) {
assert higherRange != null;
higherRange[0] = address;
} else {
activePlace.add(new long[]{address, address});
}
}

}

使用上也很简单,参数 1 是模拟器实例,参数 2 是可见字符串的最短长度,参数 3 是输出的文件路径。

1
public StringSearch(Emulator<?> emulator, int minLength, String outputFile)

它的默认搜索频率是 100 个基本块,可以使用它的重载方法,修改这个值。

1
public StringSearch(Emulator<?> emulator, int minLength, int interval, String outputFile)

具体使用上,比如下面这样

1
new StringSearch(emulator, 5,"unidbg-android/src/test/resources/trace/strings.log");

效果如下,运行时的字符串可以给出很多指引。
image

findCryptInTrace

哈希算法主要关注下面这几点。
● 使用了哪种哈希算法
● 哈希的输入是什么
● 哈希的输出是什么
● 哈希算法是否魔改
首先要确定使用了什么哈希算法,上面用 IDA Findcrypt 插件看到,样本里有 SHA256 的魔数,但这并不意味着我们的目标函数就使用了这个算法,对吧。
但是,如果我们能在 trace 里找到 SHA256 算法的相关魔数,那就证实了 SHA256 的存在。我写过一个 010Editor 小脚本,叫 findCryptInTrace.1sc,其内容如下。

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
int md5[4] = {0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee};
int sha1[4] = {0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xCA62C1D6};
int sha256[4] = {0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5};
int sm3[2] = {0x79cc4519, 0x7a879d8a};
int crc32[2] = {0x77073096, 0xEE0E612C};
int chacha20[2] = {0x61707865, 0x3320646e};
int hmac[2] = {0x36363636, 0x5C5C5C5C};
int tea[1] = {0x9e3779b9};
int twofish[4] = {0xBCBC3275, 0xECEC21F3, 0x202043C6, 0xB3B3C9F4};
int salsa20[4] = {0x61707865, 0x3320646e, 0x79622d32, 0x6b206574};
int blowfish[2] = {0x243f6a88, 0x85a308d3};
int rc6[2] = {0xb7e15163, 0x9e3779b9};
int aes[2] = {0xC66363A5, 0xF87C7C84};
int aplib[1] = {0x32335041};
void search(int arr[], string name){
int num = sizeof(arr)/sizeof(arr[0]);
local int i;
local string s;
local uchar exist = false;
for( i = 0; i < num; i++ ){
SPrintf(s, "%X", arr[i]);
if(FindFirst(s, false) > 0){
exist = true;
break;
}
}
if(exist){
Printf("Find "+name +", num:0x"+s+"\n");
}
}

Printf("***********Findcrypt Search Start****************\n");
search(md5, "MD5");
search(sha1, "SHA1");
search(sha256, "SHA256");
search(sm3, "sm3");
search(crc32, "crc32");
search(chacha20, "chacha20");
search(hmac, "hmac");
search(tea, "tea");
search(twofish,"twofish");
search(salsa20, "salsa20");
search(blowfish, "blowfish");
search(rc6,"rc6");
search(aes,"aes");
search(aplib,"aplib");
Printf("***********Findcrypt Search Over****************\n");

原理和 Findcrypt 类似,搜索各种常见算法所使用的常量,下面演示它的使用。
image
打开我们的脚本,选择运行。
image
trace 里明确找到了 SHA256 算法以及 HMAC 方案的特征。这说明样本可能使用了 HMAC-SHA256,也可能是 SHA256 + HMAC-SHA256,或者其它,这都说不清,需要进一步分析。

反制unidng

如何增加跑通Unidbg模拟执行的成本 ?

一、JNI交互

主要有两点

  1. 尽可能增加补JAVA环境所需的时间成本

打个比方,在SO中编写1000个对JAVA层的访问和调用函数,每次计算时根据时间戳使用其中的50 个,这样做的话,运算的时间成本不高,但因为时间戳一直在变,所以得在Unidbg中补齐全量的 JAVA访问。

  1. 使用Unidbg暂不支持的JNI调用

src/main/java/com/github/unidbg/linux/android/dvm/DalvikVM.java 是Unidbg中实现JNI方法 的类,可以发现,只实现了最常用的那部分JNI方法,所以可以在样本中使用这些Unidbg尚未实现 的JNI方法。

二、系统调用

Unidbg实现了许多常见的系统调用,但还有一些偏冷门的系统调用未实现,以及不偏僻但较难完美实现 的系统调用,典型代表——fork相关系统调用。

三、文件访问

尽可能多的文件访问和交互

四、检测Unidbg

样本可以感知并检测自己运行在Unidbg环境中,并走向错误的分支。这一块我的了解不多,但确实是可 以的,Unidbg的执行环境和真机是有很大差距的。打个比方,每个Android进程都有一些初始化环境变 量,Android系统启动时通过setEnv设置,Unidbg中则没有。

五、目标函数无法被单独执行

尽其所能的增加目标函数被执行的”前置条件“,或者说初始化函数。这里面可以玩的花招、阴招太多 了。这应该作为一个主要的反Unidbg的方向,它是有效且杀伤力巨大的

六、在真机上使用GetStaticMethodID寻找一个不存在的方法

在真机上,使用GetStaticMethodID寻找一个不存在的方法,系统会返回一个错误。在unidbg中使用GetStaticMethodID,像是无论存不存在都会模拟一个jmethodID返回给so
关于这个反unidbg的思路和形成,如果java层不存在某个类或者方法,jni中使用findclass或者getmethodid就会报错(这很合理嘛,因为找不到),而Unidbg因为没办法了解Java环境具体咋样,所以不管你find啥class还是get什么method,它都当做这是存在的。
解决,如果你想让Unidbg findclass找不到一个类,可以使用BaseVM的addNotFoundClass方法,如果想让Unidbg找不到某个方法,可以修改AbstractJni中的acceptMethod方法。

Unidbg报错

Illegal JNI version

image
JNI版本错误,而只意味 JNIOnLoad 运行过程中出了错。至于具体出错的原因有很多,一个常见的问题是SO的依赖库缺失。
我们的目标SO依赖了 libc++_shared.so ,这个库是C++的支持库,但不在Unidbg默认支持的SO里。 我们要在apk的lib里把它拷贝出来。
还有另一个更通用的办法 ,当报错时,就把 traceCode打开,记录执行流,看最后在哪儿停下来。
image
比如此处,我在JNI_OnLoad前面打开traceCode,在IDA中跳转到最后一条运行地址 0x177C
image
可以发现正是C++的标准库函数,符合上面的猜测。
添加之后正常加载
image

app开发过程中的失误,释放变量后还调用

运行样本后,发现内存错误
image
第一种尝试,把Unidbg的日志全开,src/test/resources/log4j.properties中的INFO全配置改为DEBUG
再次运行后会在报错的地方停下来。输入bt查看调用栈
image
unidbg提供了大量详情的调用回溯信息,直接拉到底部,看目标样本是从哪里调用。用IDA跳到0xabf
image
奇了怪了,打个日志还报错?把日志等级改为INFO,在此处下个断点分析。
emulator.attach().addBreakPoint(module.base+0x00abf);
image
_android_log_print是常用函数,参数1是此日志的优先级,参数2是tag,参数3是格式化字符串,之后的都是内容。都可以在Console Debugger中验证
image
参数1是6,可以直接看,通过mrx查看其余参数,参数23也都正常,那参数4呢
image
内存异常了,返回IDA查看代码
image
可以看到上面释放了变量,下面还在调用。
怎么解决?
直接patch,改为两条无效指令,需要修改4个字节
image
首先写入0046,debug发现不对,调整了一下大小端序,再DEBUG似乎OK了

1
2
3
4
public void patchLog(){
int patchCode = 0x46004600;
emulator.getMemory().pointer(module.base + 0xABE).setInt(0,patchCode);
}

另一种填入两个nop也可以

1
2
3
4
5
6
7
8
9
10
public void patchLog1(){
Pointer pointer = UnidbgPointer.pointer(emulator, module.base + 0xABE);
assert pointer != null;
try (Keystone keystone = new Keystone(KeystoneArchitecture.Arm,KeystoneMode.ArmThumb)) {
KeystoneEncoded encoded = keystone.assemble("nop");
byte[] patch = encoded.getMachineCode();
pointer.write(0, patch, 0, patch.length);
pointer.write(2, patch, 0, patch.length);
}
};

第三种方法,前面两种都是静态打patch,和用IDA的KeyPatch直接打patch没啥区别

1
2
3
4
5
6
7
8
9
10
public void patchLog2(){
emulator.attach().addBreakPoint(module.base + 0xABE, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_PC,(address)+5);
return true;
}
});
}

解释一下做了什么,我们在log的调用处下了一个断点,inline hook 都是在调用前断下来了,所以在执 行前断了下来,然后加了BreakPointCallback回调,在这个时机做处理,我们将PC指针直接往前加了 5,PC指向哪里,函数就执行哪里,本来PC指向“blx log”这个地址,程序即将去执行log函数。但我们直 接将PC加了5,为什么加5?
我们知道这里的log是个坑,它长四个字节,我们要越过这个坑,但加4不够,我们是thumb模式,再+1,所以就是+5。

 评论