项目地址:dbi_detect
上周让 AI 分析了几个大厂 VMP 样本,只给 trace 日志,两天秒了三个 VMP ,而且两天是因为,iOS端 APP 主程序太大了,最大的一个有 900MB ,两天里面有 1.9 天浪费在 IDA 反编译上。基本上 AI 40分钟左右搞定一个,给我吓哭了。这还原算法的成本还是太低了
这个问题产生的原因主要有两点吧:
当然我的日常工作主要聚焦在 iOS 端,所以本文后续的 检测内容都是基于 Android 平台的ovo
官方的说法:DBI全称Dynamic Binary Instrumentation,动态二进制插桩。它可以在程序运行期间分析、重写原始机器码,并在其中插入回调、计数、访存记录等逻辑
我们以QBDI这类用户态DBI为例,它大概会经历下面几个步骤:

QBDI官方文档对这个过程描述得很直接:DBI核心依赖JIT重编译,插桩后的basic block会写入可执行内存,并保存在code cache中避免重复翻译
那么这时会同时就会存在两套PC概念:
原生执行时,两者基本一致,都位于目标SO的.text中。进入DBI后,Guest PC仍然可以被维护成原始地址,但Physical PC通常已经落在code cache或者DBI调度路径里了
当然,你可能会想那我直接写一句ADR读取PC不就行了吗
实际测下来确实没啥卵用,原始指令adr x8, #4执行后,DBI给出的x8仍然是下一条Guest指令的地址,结果依旧落在目标 so 代码段内
因为ADR本身也会被DBI翻译,它可以在重定位时维持Guest PC语义,所以我们需要找一个不完全受DBI控制的观察者
Linux内核正好可以充当这个观察者
从信号上下文偷看真实PC
Linux向用户态线程投递信号时,会保存线程被打断瞬间的寄存器上下文。使用SA_SIGINFO安装handler后,可以从第三个参数拿到ucontext_t,ARM64下的PC位于:
如果目标函数原生执行,内核保存的PC应该落在我们预先确定的探针窗口内。如果目标函数已经被翻译到code cache执行,而DBI又没有额外虚拟化信号上下文,那么这里就会泄露Physical PC
围绕这个思路,当前检测流程如下:

图里虽然有一个watchdog线程,但它不是后台轮询检测线程。它平时阻塞在条件变量上,只在受保护函数主动执行检测宏时被唤醒,本质上仍然是同步检测
我们不能在整个业务函数执行期间要求PC始终位于目标SO,因为业务函数可能正常调用libc、系统库或者其他SO
所以宏里放了一段纯汇编探针窗口。窗口内部不调用外部函数,也不执行系统调用,只维护几个状态并等待采样完成:
async_begin和async_end都是由ADR在运行时生成的实际地址,因此ASLR不会影响判断
检测第一次运行时会创建一个共享watchdog线程,此后它一直休眠,只有探针开始前才被唤醒
目标线程进入汇编窗口并设置active后,watchdog通过pthread_kill()向目标线程发送SIGUSR2。handler从ucontext_t保存PC,watchdog等待本次样本写入后再发送下一次,一共采集8个样本
这里不能简单地连续发送8次普通信号,因为非实时信号会合并,最后可能只收到一次
最终判断很朴素:
只要有一个有效样本落在窗口外,就记录一项强证据
如果信号被延迟或者没有拿到样本,则不能单凭这一点判断存在DBI。调度抖动、信号屏蔽和设备负载都有可能造成相同现象,所以当前实现只把明确的窗口外PC当成命中
异步采样依赖另一个线程和信号调度,优点是触发时机不容易预测,缺点是流程稍微复杂一些。第二种方案就简单粗暴一点,直接主动制造一次同步异常
探针在编译时记录BRK标签地址,然后执行:
原生执行时,BRK触发SIGTRAP,内核交给handler的PC应当等于brk_expected。handler记录PC、SP和LR,再把上下文中的PC增加4字节,跳过当前ARM64指令继续执行:
结束时检查:
如果BRK在code cache中执行,而DBI直接把异常交给内核,ucontext_t.pc就可能指向code cache。如果DBI不执行BRK,而是自己模拟后续流程,那trap_seen就可能一直是0
这两个结果都和原生语义不一致
这两种方案本质上都在检查PC,但观察时机不同:
单独使用BRK,DBI可以识别这条固定指令并伪造异常现场。单独依赖异步采样,又会受到调度和信号投递的影响。两种方案一起上,就需要同时处理异步中断和同步异常两条路径
最终结果使用进程级sticky状态:
一旦任意一次检测获得强证据,后续调用都会继续返回已检测状态,不会因为下一次刚好没有采到异常PC又恢复正常
具体建议直接去拉源码看一下。实现上没有搞得太复杂,对外只留了一个DBI_TRACE_DETECTED()宏,直接插在想保护的函数里就行
宏内部大概就是跑一轮探针,然后返回进程级检测状态
[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!