最近一直在研究 unidbg 来还原一些so的逻辑,但是遇到了一些问题:
- Unidbg太慢了,有一些so会在app运行5-10分钟后才会触发一些逻辑,Unidbg模拟起来会进入空转状态。
- 有时候还会与网络等进行交互,弄起来很糟心,一些特征也需要伪造。
- 生命周期问题,有些so的一些方法是有调用顺序的,还会涉及到消息队列等,这些模拟起来就得先摸清楚整套so的java与native交互,不然会在奇葩的地方卡住,或者走不全逻辑。
于是就往真机 trace 方向研究了一下。真机的trace,之前是尝试过 Frida 的 Stalker,但是不稳定,挂上去就crash,后来尝试了 GumTrace,说是提升了稳定性,有一个 so 确实可以 trace 了,但是依然无法 trace 另一个 so。
这篇文章主要是介绍一下 qbdi,先看一个基于 qbdi 的项目。
我写自己写了一个demo,下面也会介绍。
https://bbs.kanxue.com/thread-290574.htm
直接看代码,发现有 jnitrace 功能,直接看看是如何实现的。
首先需要了解一个前提,就是 qbdi 调用 call 的时候,需要传递参数,如果我们需要trace真实环境的话,那么就需要等到这个方法主动触发才行。
所以,如何才能知道这个方法被调用了还能获取方法参数呢?使用 hook 框架。
比如:
https://github.com/jmpews/Dobby
https://github.com/bytedance/android-inline-hook
有了 hook 框架,我们在我们想要监控的函数上下 hook,然后在 hook 逻辑里面去调用 vm call 方法,就可以 trace 方法了。
看一下具体的代码:
addJNITrace((void*)pJFunc->NewStringUTF,"NewStringUTF",trace_NewStringUTF);
这个方法只是将需要 trace 的函数地址给储存了起来。
shadowhook_hook_func_addr((void*)(_g_trace_data->start + _g_trace_data->target),
(void*)(hook_and_trace_arg8),
(void**)&ori_arg8);
_g_trace_data->start + _g_trace_data->target是需要 trace 的函数的地址,将函数入口地址替换为 hook_and_trace_arg8。
在这个方法里面先做了 unhook,再开启 call:
shadowhook_unhook(_g_trace_data->hooktask);
size_t function_address = _g_trace_data->start + _g_trace_data->target;
auto qvm = vm_->init(_g_trace_data->start,_g_trace_data->end);
bool qbdi_success = qvm.call(&qbdi_retval, (uint64_t)function_address);
shadowhook_hook_func_addr((void*)(_g_trace_data->start + _g_trace_data->target),
(void*)(hook_and_trace_arg8),
(void**)&ori_arg8);
可以看到 call 的时候传递的是函数地址,所以必须先 unhook。如果不先 unhook,function_address 入口还是被 shadowhook patch 过的。那么 QBDI 一调用目标函数,又会跳回 hook_and_trace_arg8,造成递归/死循环。
trace 完成后,需要重新再次 hook 上,如果,你只想trace一次的话,那就没必要。
看看 vm_ 的 init 方法,里面肯定有设置 callback 的:
//指令前hook
cid = qvm.addCodeCB(QBDI::PREINST, showPreInstruction, this);
//读写trace
cid = qvm.addMemAccessCB(MEMORY_READ_WRITE, showMemoryAccess, this);
// 该模块需要插桩
bool ret = qvm.addInstrumentedModuleFromAddr(reinterpret_cast<QBDI::rword>(start));
函数执行的过程中,会触发 showPreInstruction 和 showMemoryAccess 回调,由于我们设置了整个so范围插桩,所以,这个函数调用了其他so内的函数,也可以被记录下来。但是,调用了 libc.so 中的方法就无法记录了。
在 showPreInstruction 里面,它判断了指令:
if(instAnalysis->isCall && !strcmp(instAnalysis->mnemonic,"BLR"))
if(instAnalysis->isBranch && !strcmp(instAnalysis->mnemonic,"BR") && hasLibctrace())
是这两个指令的话,再去详细查询这个地址在不在 map 里面,前面我们设置 jni trace 的时候,是保存了需要trace的函数地址的,所以这里用来做比较。如果相等,就调用设置的回调函数。
libc 函数的 trace 基本是同样的逻辑。
有 EXEC_TRANSFER_CALL,这里处理的过于麻烦了。
实战演示
我写了一个demo:https://github.com/aprz512/qbdi-android,里面演示了如何使用真机来做trace。
目标APP
设计比较简单,就是几个简单的 case,为了方便展示和学习,方法符号都保留了。
第一个,演示 init 函数的trace:
__attribute__((constructor)) void native_constructor() {
integrity_capture_baseline();
demo_init_stage();
}
第二个,演示 JNI 函数的 trace(只 trace 了 call 调用,参数,返回值等):
extern "C" std::string demo_jni_case(JNIEnv *env, jobject thiz) {
第三个,演示 libc 函数的 trace(只 trace 了 call 调用,参数,返回值等):
extern "C" std::string demo_libc_case() {
第四个,演示检测对抗,做了一个简单的 maps 扫描,和 .text 完整性检测,使用 QBDI 来修改寄存器/指令的方式来绕过检测:
extern "C" std::string demo_integrity_case() {
第5个,演示trace算法进行还原:
extern "C" uint64_t demo_algorithm_case(const uint8_t *data, size_t size) {
trace框架的设计与使用
整个框架的的入口在:
qbdi_tracer_install_module
这个方法由 frida 的 js 调用,参数也从 js 里面传递进去,installModuleObserver会监控 so 的加载,检测到目标 so 加载时,会调用上面的 c++ 方法。
Frida 的 moduleObserver.onAdded 本身就会在 so 的 init 阶段前触发,所以我们可以 trace so 的 init 内的方法。
参数配置:
const config = {
packageName: 'com.aprz.qbdiandroid',
remoteDir: '/data/local/tmp/qbdi-android',
tracer: 'libqbdi_tracer.so',
targetSo: 'libdemo_target.so',
scenes: {
init: { offset: '0x6AC90' },
jni: { offset: '0x6DCA8' },
libc: { offset: '0x6E204' },
algorithm: { offset: '0x6DB38' }
integrity: { offset: '0x6E584' }
}
};
scenes 里面就是配置了一些 trace 点,一共支持 256 个,每个 scene 都会调用 trace_proxy_for方法,这个方法会先 unhook,再调用 vm.call,再hook,具体可以看代码。
核心逻辑就是 run_with_qbdi,里面设置了各种回调:
vm.recordMemoryAccess(QBDI::MEMORY_READ_WRITE);
vm.addCodeCB(QBDI::PREINST, on_pre_instruction, &state);
vm.addCodeCB(QBDI::POSTINST, on_post_instruction, &state);
vm.addMemAccessCB(QBDI::MEMORY_READ_WRITE, on_memory, &state);
vm.addVMEventCB(QBDI::EXEC_TRANSFER_CALL | QBDI::EXEC_TRANSFER_RETURN, on_exec_transfer,
&state);
所以,trace会有比较详细的信息。
监控 libc 与 jni 都是使用的EXEC_TRANSFER_CALL,简单又方便,当然现在还很简陋,没有打印足够的信息,好在后续可以扩展。
另外,还设计了一个指令hook方案,需要自行在register_user_code_rules这个方法里面添加逻辑,下面是一个 demo ,可以做到类似 ida patch 指令的效果,后面会演示如何修改指令执行来过检测:
* class ForceEqualsRule final : public OffsetCodeRule {
* public:
* explicit ForceEqualsRule(uintptr_t offset) : OffsetCodeRule(offset) {}
*
* QBDI::VMAction on_pre_instruction(CodeRuleContext &context) override {
* context.set_zero_flag(true);
* context.trace_rule("force_equals", "z=1");
* return QBDI::CONTINUE;
* }
* };
trace抓取
先安装目标 app。
运行frida 脚本:
frida -U -f com.aprz.qbdiandroid -l scripts/spawn_trace.js
app 启动时会生成一个 init 阶段的 trace,点击 app 里面的按钮会生成对应的 trace,点击最后一个 按钮刚开始会crash。
init 函数 trace 效果如下:
TRACE_BEGIN scene=init target=libdemo_target.so+0x6ac90 base=0x758a0c0000 address=0x758a12ac90 pid=16850 tid=16850
1 libdemo_target.so+0x6ac90 stp x29, x30, [sp, #-0x10]! | R:FP=0xb400007589fbb000 LR=0x2a SP=0xb400007589fbaf80
MEM libdemo_target.so+0x6ac94 type=w addr=0xb400007589fbaf70 size=8 value=0xb400007589fbb000
MEM libdemo_target.so+0x6ac94 type=w addr=0xb400007589fbaf78 size=8 value=0x2a
2 libdemo_target.so+0x6ac94 mov x29, sp | R:SP=0xb400007589fbaf70
3 libdemo_target.so+0x6ac98 bl #0x3ab4 | R:SP=0xb400007589fbaf70
4 libdemo_target.so+0x6e74c sub sp, sp, #0x20 | R:SP=0xb400007589fbaf70
对比 ida 中的汇编:
.text:000000000006AC90 STP X29, X30, [SP,#-0x10+var_s0]!
.text:000000000006AC94 MOV X29, SP
.text:000000000006AC98 BL integrity_capture_baseline
.text:000000000006AC9C BL demo_init_stage
.text:000000000006ACA0 LDP X29, X30, [SP+var_s0],#0x10
.text:000000000006ACA4 RET
这里,由于我没有传 end,所以默认 trace 整个 so,会将 init 函数调用的子函数也一起 trace 进去。
算法还原
将抓到的trace文件丢给AI,以deepseek v4 pro 为例:
分析一下 1783670470970_16203_16203_algorithm_0x6db38.trace.txt, 还原一下算法逻辑
AI3分钟后回复:
———
### 核心算法
这是一个 TEA 结构 + MurmurHash3 终结器 的混合哈希函数,逐字节处理:
state = len ^ 0x9E3779B185EBCA87 ← 初始化(黄金比例常数异或长度)
for each byte in data:
tmp = byte + 0x9E3779B97F4A7C15 + (state << 6) + (state >> 2)
state ^= tmp ← TEA 风格混叠
state = rotl(state, 13) ← 左旋 13 位
state *= 0xFF51AFD7ED558CCD ← MurmurHash3 fmix64 常数
state ^= state >> 33
state *= 0xC4CEB9FE1A85EC53 ← MurmurHash3 fmix64 常数
state ^= state >> 29
return state ← 最终哈希值
———
可以对比一下原代码,看看效果。
libc/JNI 监控
目前做的比较简单,需要详细信息可以自己扩展:
CALL jni.RegisterNatives target=0x7615e45960
CALL return.RegisterNatives jni.RegisterNatives target=0x7615e45960 ret=0x78d1294065
CALL jni.GetObjectClass target=0x7615e466a0
CALL return.GetObjectClass jni.GetObjectClass target=0x7615e466a0 ret=0xb40000774a27af40
CALL libc.fopen target=0x78c54fe330 x0=0x758a102b22 preview="/proc/self/maps"
CALL return.fopen libc.fopen target=0x78c54fe330 ret=0xb40000780a239888
CALL libc.__system_property_get target=0x78c54acb10 x0=0x758a101782 preview="ro.build.version.sdk"
CALL return.__system_property_get libc.__system_property_get target=0x78c54acb10 ret=0x2
检测绕过
查看 ida ,发现绕过检测逻辑很简单:
.text:000000000006E5C0 TBNZ W0, #0, loc_6E5D0
.text:000000000006E5C4 B loc_6E5C8
.text:000000000006E5C8 ; ---------------------------------------------------------------------------
.text:000000000006E5C8
.text:000000000006E5C8 loc_6E5C8 ; CODE XREF: demo_integrity_case+40↑j
.text:000000000006E5C8 BL integrity_crash
只需要修改 6E5C0 处的寄存器值为1,即可绕过:
// 6E5C0: TBNZ W0, #0, loc_6E5D0
// Set bit #0 of W0 so TBNZ takes the branch to loc_6E5D0
class ForceTbnzW0Rule final : public OffsetCodeRule {
public:
explicit ForceTbnzW0Rule(uintptr_t offset) : OffsetCodeRule(offset) {}
QBDI::VMAction on_pre_instruction(CodeRuleContext &context) override {
uint64_t w0 = context.reg(0);
context.set_reg(0, w0 | 1ULL);
context.trace_rule("force_tbnz", "W0 bit#0 set, jump to loc_6E5D0");
return QBDI::CONTINUE;
}
};
另一处类似,就不贴代码了。
使用 frida 注入 so, 再次点击 trace integrity case 按钮,就不会闪退了,但是注意,这里trace的范围很多,生成一个 400多M的trace文件,因为so里面将 string 的一些函数打进去了,所以会卡一下,需要优化一下文件读写。