QBDI(Quantum Binary Dynamic Instrumentation)是一个基于LLVM的动态二进制插桩(DBI)框架,旨在对目标程序的指令流进行实时监控与干预。其核心设计理念是通过运行时反汇编、指令修补(patching)和代码插桩(instrumentation),在不修改原始二进制文件的前提下,实现对程序执行流程的细粒度追踪与分析。该项目的源码托管于GitHub:github.com/QBDI/QBDI。
与其他同类工具相比,QBDI更侧重于底层机制的灵活性与可控性。它不像Frida的Stalker模块那样提供开箱即用的代码注入和自动化追踪,而是专注于构建一个高效的插桩引擎,需要使用者自行实现注入逻辑。这种设计使其在性能和定制化方面更具优势,但也增加了使用门槛。类似的开源项目还包括Valgrind和DynamoRIO,前者主要用于内存调试,后者则是一个成熟的多平台应用级动态插桩框架。
交叉编译与基础使用
QBDI支持跨平台编译,以在Android设备上运行为例,首先需要修改其CMake配置文件cmake/config/config-android-aarch64.sh,启用调试和示例构建选项:
-DCMAKE_BUILD_TYPE=Debug \
-DQBDI_EXAMPLES=true \
-DQBDI_LOG_DEBUG=true \
-DCMAKE_EXPORT_COMPILE_COMMANDS=1 \
随后,使用Ninja进行编译:
export NDK_PATH=/your_ndk_dir/android-ndk-r28b-linux/android-ndk-r28b
mkdir build
cd build
../cmake/config/config-android-aarch64.sh
ninja
编译完成后,可将生成的libQBDI.so和示例程序fibonacci_cpp推送到Android设备并执行:
adb push libQBDI.so /data/local/tmp
adb push examples/cpp/fibonacci_cpp /data/local/tmp
adb shell
cd /data/local/tmp
LD_LIBRARY_PATH=. ./fibonacci_cpp
为了便于调试,可以将NDK中的lldb-server推送到设备,并在PC端通过LLDB进行远程连接和断点调试。
核心架构与执行流程
QBDI本质上是一个轻量级的虚拟机(VM),它通过创建独立的执行块(ExecBlock)来接管目标代码的执行。整个系统由多个核心组件协同工作,其架构可以概括为:
- HOST层:包含用户代码(User Code)、C/C++ API、引擎(Engine)和执行块管理器(ExecBlockManager)。引擎是核心,负责反汇编(Disassembler)、修补(Patching)、插桩(Instrumentation)和汇编(Assembler)。
- GUEST层:包含被插桩的二进制程序(Instrumented Binary)及其生成的代码块(Code Block)和数据块(Data Block)。
执行流程始于用户代码调用QBDI的API。引擎从指定的起始地址开始,利用LLVM的MC库对目标指令进行反汇编。对于每一条指令,引擎会执行以下步骤:
- 修补(Patching):处理与程序计数器(PC)相关的指令(如
ADR, LDR),将其转换为位置无关的代码,以适应在ExecBlock中的执行。
- 插桩(Instrumentation):根据用户注册的回调(如
addCodeCB),在指令前后插入用于追踪的代码片段。
- 组装(Relocation):将修补和插桩后的指令组装成可执行的机器码。
- 执行(Execution):将组装好的代码写入ExecBlock并执行。
执行完一个基本块后,引擎会获取新的PC地址,并重复上述流程,直至目标函数执行完毕。
ExecBlock:隔离与通信的基石
ExecBlock是QBDI实现插桩的核心数据结构,它由两个相邻的4KB内存页组成:
- Code Block (RX):存放修补和插桩后的可执行代码。
- Data Block (RW):存放与插桩相关的上下文数据。
这种设计巧妙地解决了在不引入额外寄存器的情况下访问数据的问题。由于Code Block和Data Block物理上相邻,Code Block中的代码可以通过PC相对寻址(通常可达±4KB)直接访问Data Block中的数据,无需占用宝贵的通用寄存器。
Data Block内部又细分为几个关键区域:
- GPR Context:保存Guest的通用寄存器状态(GPRState)。
- FPR Context:保存Guest的浮点寄存器状态(FPRState)。
- Host Context:保存QBDI自身(Host)的上下文信息,如回调函数指针、临时数据等。
- Shadows:存放插桩过程中产生的常量、元数据等临时数据。
上下文切换与寄存器管理
QBDI与目标程序(Guest)之间的切换是通过精心设计的序言(Prologue)和尾声(Epilogue)代码实现的。
序言(Prologue) 的主要任务是:
- 设置一个临时寄存器(如x28)指向Data Block的基址。
- 从Data Block中恢复Guest的寄存器状态(GPR和FPR)。
- 保存Host的返回地址(LR)和栈指针(SP)。
- 跳转到由
Selector指定的具体基本块代码处执行。
尾声(Epilogue) 的主要任务是:
- 保存当前Guest的寄存器状态(GPR和FPR)到Data Block。
- 恢复Host的栈指针(SP)。
- 通过
RET指令返回到Host的控制流中。
在执行Guest代码时,QBDI必须保证不污染Guest的执行环境。然而,插桩代码本身可能需要使用通用寄存器。为此,QBDI引入了Temp(临时寄存器)和ScratchRegister(刮擦寄存器)的概念。
- Temp寄存器:由
TempManager分配,用于在插桩代码中临时存储数据。QBDI优先选择x28寄存器,因为它在常规函数中较少被使用,可以减少保存和恢复的开销。如果x28被Guest代码使用,则会分配其他寄存器,并在使用前后进行显式的保存和恢复。
- ScratchRegister:这是一个特殊的寄存器(在Arm64上通常是x27),它被固定用来指向Data Block的基址,以便插桩代码能够通过相对寻址访问Data Block。由于Arm64的PC不可直接访问,因此必须牺牲一个通用寄存器来充当此角色。
ScratchRegister的分配和保存是QBDI实现中最复杂的部分之一。
PatchDSL:指令修补的领域特定语言
QBDI使用一种名为PatchDSL的内部语言来定义指令修补规则。每条规则由一个条件(PatchCondition)和一系列生成器(PatchGenerator)组成。
以处理PC相对寻址的LDR Xn, label指令为例,其修补规则如下:
rules.emplace_back(
Or::unique(OpIs::unique(llvm::AArch64::LDRSl),
OpIs::unique(llvm::AArch64::LDRDl),
OpIs::unique(llvm::AArch64::LDRQl),
OpIs::unique(llvm::AArch64::LDRXl),
OpIs::unique(llvm::AArch64::LDRWl),
OpIs::unique(llvm::AArch64::LDRSWl)),
conv_unique<PatchGenerator>(
GetPCOffset::unique(Temp(0), Operand(1)),
ModifyInstruction::unique(conv_unique<InstTransform>(
ReplaceOpcode::unique(std::map<unsigned, unsigned>({
{llvm::AArch64::LDRSl, llvm::AArch64::LDRSui},
{llvm::AArch64::LDRDl, llvm::AArch64::LDRDui},
{llvm::AArch64::LDRQl, llvm::AArch64::LDRQui},
{llvm::AArch64::LDRXl, llvm::AArch64::LDRXui},
{llvm::AArch64::LDRWl, llvm::AArch64::LDRWui},
{llvm::AArch64::LDRSWl, llvm::AArch64::LDRSWui},
})),
AddOperand::unique(Operand(1), Temp(0)),
SetOperand::unique(Operand(2), Constant(0))
)),
SaveX28IfSet::unique()
)
);
该规则的执行逻辑是:
GetPCOffset:计算PC + imm的值,并将其存入Shadows区域,同时生成一条LDR指令将该值加载到Temp寄存器(x28)。
ModifyInstruction:将原始的LDR Xn, label指令修改为LDR Xn, [X28],从而消除对PC的依赖。
SaveX28IfSet:如果原始指令修改了x28寄存器,则需要在执行前从Data Block中恢复其值,并在执行后保存。
处理控制流转移
当遇到改变PC的指令(如BLR, RET)时,QBDI必须介入以保持对执行流的控制。例如,对于BLR X8指令,QBDI会将其修补为:
mov x28, x8
str x28, [x27, #336] // 将目标地址存入Context.gprState.pc
ldr x28, [x27, #1112] // 加载下一条指令地址(返回地址)
mov x30, x28 // 设置LR为返回地址
b #3200 // 跳转回Epilogue,交还控制权
这样,当目标函数执行完毕后,会返回到QBDI的Epilogue,而不是原始的调用者,从而实现了对整个调用过程的监控。
特殊场景与局限性
QBDI在处理某些特殊指令时会遇到挑战,最典型的便是原子操作指令LDREX/STREX(或LDAXR/STLXR)。这些指令依赖于处理器的独占监视器(Exclusive Monitor),一旦在LDREX和STREX之间执行了任何内存访问,监视器就会被清除,导致STREX失败。
由于QBDI的插桩代码必然会在LDREX和STREX之间插入大量指令,这几乎总会导致监视器失效,从而使程序陷入无限重试的死循环。QBDI对此的应对策略是为单线程程序模拟一个软件监视器,但这在多线程环境下无效。因此,最佳实践是将包含此类指令的关键代码段排除在插桩范围之外。
此外,QBDI还存在其他局限性:
- 非重入性函数风险:如果插桩代码调用了非重入性库函数,可能导致死锁。
- 动态代码修改:QBDI的缓存机制无法处理运行时动态修改的代码,需要使用者自行管理。
- ExecBroker假设:
ExecBroker在跳过未插桩代码时,对目标函数的调用约定和寄存器使用有较强的假设,可能在极端情况下失效。
与Frida集成
尽管QBDI本身不提供注入功能,但它可以与Frida完美结合。通过Frida的Inline Hook能力,可以在目标函数入口处捕获参数,并将其转交给QBDI进行后续的精细追踪。官方文档提供了详细的集成指南,使得开发者既能利用Frida强大的注入和Hook能力,又能享受QBDI在指令级追踪上的高性能和精确性。
参考资料
[1] QBDI原理详解, 微信公众号:mp.weixin.qq.com/s/cLlRY38yjhwEXhCbc-MNeA
版权声明:本文由 云栈社区 整理发布,版权归原作者所有。