首页
社区
课程
招聘
[原创]QBDI原理详解
发表于: 19小时前 463

[原创]QBDI原理详解

19小时前
463

QBDI的代码位于:  https://github.com/QBDI/QBDI

QBDI的含义为: A Dynamic Binary Instrumentation framework based on LLVM。

它对标的是像Frida Stalker这样的工具,但是QBDI没有像frida那样提供了代码注入的功能,需要自己实现注入代码并且启动QBDI。

其他相似的工具有:

  1. valgrind: 一款用于内存调试、内存泄漏检测以及性能分析的软件开发工具,只支持linux平台,使用起来比较复杂

  2. DynamoRIO: 开源多平台的应用程序动态instrumentation框架


以下内容没有特别说明都是针对arm64平台。



一. 交叉编译在安卓上运行的目标:

从github下载代码以后,修改cmake/config/config-android-aarch64.sh文件加入以下行 :

-DCMAKE_BUILD_TYPE=Debug \
-DQBDI_EXAMPLES=true \
-DQBDI_LOG_DEBUG=true \
-DCMAKE_EXPORT_COMPILE_COMMANDS=1 \

编译:

$ export NDK_PATH=/your_ndk_dir/android-ndk-r28b-linux/android-ndk-r28b
$ mkdir build
$ cd build
$ ../cmake/config/config-android-aarch64.sh
$ ninja

运行示例程序:

$ 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 push到手机中


在手机端执行:

 $ ./lldb-server platform --listen "*:10086" --server


在PC端执行:

$ adb forward tcp:10086 tcp:10086
$ lldb
$ platform select remote-android 
$ platform connect connect://[adb devices返回的id]:10086  
$ file fibonacci_cpp
$ env LD_LIBRARY_PATH=/data/local/tmp
$ b main

可以看到在这个示例的代码(examples/cpp/fibonacci.cpp)中,需要自己调用被trace的函数并且传递需要的参数:

res = vm.call(&retvalue, reinterpret_cast<QBDI::rword>(fibonacci),{static_cast<QBDI::rword>(n)});

其实我们可以利用frida的inline hook功能拦截原始函数的参数并且转交给qbdi让它trace,就不需要我们自己准备参数了


二. qbdi的基本原理:

qbdi本质上是一个VM,监视程序的运行指令流并跟随该流实时的patch代码。

所以会有qbdi context和guest context并有上下文切换的操作。

遇到不在指定trace范围的代码经由ExecBroker将控制权递交出去并监控返回点,返回以后再接管控制。

执行流程:

  1. 从被trace的代码开始位置处调用llvm库反编译,并对其中的代码patch(修复pc相关指令)直到遇到改动pc指令为止
  2. 对上面得到的基本块调用instrument函数对每条指令插入trace相关指令,并且执行寄存器分配与保存相关工作
  3. 创建ExecBlock,它由连续的两页组成: Code Block和Data Block, 这样安排的好处是Code Block只需要pc相对指令就可以访问到Data Block的内容,无需引入额外的寄存器, 引入额外的寄存器会破坏guest寄存器需额外保存. 而Data Block则存放着host上下文数据. 因为ExecBlock的空间有限,空间不够存放的时候必须创建新的ExecBlock
  4. 执行当前ExecBlock,根据回调返回值做相应的处理
  5. 执行完基本块以后获取新的pc地址,跳到步骤1继续循环直到函数执行完毕

三. API:

知道了大概原理以后来看一下trace相关API:


操纵trace范围的api:


/*
限制:
    范围只能在函数级别或者库级别,不支持只指定函数内的部分指令
    ExecBroker不支持异常机制: 如setjmp/longjmp
*/
//添加删除跟踪范围
void VM::addInstrumentedRange(rword start, rword end);
void VM::removeInstrumentedRange(rword start, rword end);

//添加删除跟踪模块
bool VM::addInstrumentedModule(const std::string &name);
bool VM::removeInstrumentedModule(const std::string &name);

//通过模块内的一个地址添加删除跟踪模块
bool VM::addInstrumentedModuleFromAddr(rword addr);
bool VM::removeInstrumentedModuleFromAddr(rword addr);

//跟踪所有的可执行映射
bool VM::instrumentAllExecutableMaps();
//删除所有的跟踪范围
void VM::removeAllInstrumentedRanges()


跟踪指令执行:

//PREINST执行前,POSTINST执行后
QBDI_EXPORT uint32_t addCodeCB(InstPosition pos, InstCallback cbk, void *data,
int priority = PRIORITY_DEFAULT);



VM事件API:


/*
BASIC_BLOCK_NEW -> 解析目标代码遇到一个新的bb时事件
BASIC_BLOCK_ENTRY -> bb开始执行时事件
BASIC_BLOCK_EXIT -> bb执行完毕时事件
SEQUENCE_ENTRY -> 序列开头时事件
SEQUENCE_EXIT -> 序列退出时事件
EXEC_TRANSFER_CALL -> 当遇到不在trace范围内的指令跳转到ExecBroker执行时事件
EXEC_TRANSFER_RETURN -> ExecBroker返回以后事件
*/ 
uint32_t addVMEventCB(VMEvent mask, VMCallback cbk, void *data);



内存访问API:



//注册内存访问回调
VM.addMemAccessCB(MemoryAccessType type, InstCallback cbk,void *data,int priority);

//下面两个回调其实是qbdi帮我们做了范围过滤的操作
//注册指定地址范围内的内存访问回调
VM.addMemRangeCB(rword start, rword end,MemoryAccessType type, InstCallback cbk,void *data);

//注册指定地址的内存访问回调
VM.addMemAddrCB(rword address, MemoryAccessType type, const InstCbLambda &cbk);


四. 更进一步观察细节:

dbi意味着需要修改原始指令添加instrument代码,那么就需要创建新的内存空间容纳这些代码并且重定向,由于不可能事先处理整个二进制代码,因此需要运行时监视程序指令流,只处理真正需要执行的代码块。因此就引入了qbdi上下文和guest(我这里用虚拟化中的术语称之为guest)上下文,这有点类似于qemu tcg中的用x86指令模拟arm指令运行时的上下文切换与指令处理技术,两者有些相通性,只不过qbdi运行在和guest一样的用户进程中,也因此带来了一些缺陷: trace框架本身用到的非重入性库函数可能会导致死锁,而且qbdi对目标程序属于弱控制,不像qemu或者java虚拟机可以完全控制目标的执行流。


由于目标trace代码可能有pc相关指令因此需要重定向修复操作,这个步骤称为patch,而且需要加入跟踪指令,这个步骤称为instrument,还需要进一步组装加入上下文切换相关代码,因此整个执行过程如下: 反汇编 -> patch -> instrument -> 组装 -> 执行 -> 反汇编 ...



上下文切换: 

guest上下文主要由GPRState和FPRState结构组成,GPRState包括了体系结构的所有通用寄存器(也包括条件码),FPRState则包括了所有浮点寄存器。它们都作为Engine类的成员变量。

typedef struct QBDI_ALIGNED(8) {
rword x0;
rword x1;
rword x2;
rword x3;
rword x4;
rword x5;
rword x6;
rword x7;
rword x8;
rword x9;
rword x10;
rword x11;
rword x12;
rword x13;
rword x14;
rword x15;
rword x16;
rword x17;
rword x18;
rword x19;
rword x20;
rword x21;
rword x22;
rword x23;
rword x24;
rword x25;
rword x26;
rword x27;
rword x28;
rword x29; // FP (x29)
rword lr;  // LR (x30)
rword sp;
rword nzcv;
rword pc;
  // ? rword daif; ?
  /* Internal CPU state
   * Local monitor state for exclusive load/store instruction
   */
struct {
rword addr;
rword enable; /* 0=>disable, 1=>exclusive state, use a rword to not break
                     align */
} localMonitor;
} GPRState;

typedef struct QBDI_ALIGNED(8) {
__uint128_t v0;
__uint128_t v1;
__uint128_t v2;
__uint128_t v3;

__uint128_t v4;
__uint128_t v5;
__uint128_t v6;
__uint128_t v7;

__uint128_t v8;
__uint128_t v9;
__uint128_t v10;
__uint128_t v11;

__uint128_t v12;
__uint128_t v13;
__uint128_t v14;
__uint128_t v15;

__uint128_t v16;
__uint128_t v17;
__uint128_t v18;
__uint128_t v19;

__uint128_t v20;
__uint128_t v21;
__uint128_t v22;
__uint128_t v23;

__uint128_t v24;
__uint128_t v25;
__uint128_t v26;
__uint128_t v27;

__uint128_t v28;
__uint128_t v29;
__uint128_t v30;
__uint128_t v31;

rword fpcr;
rword fpsr;
} FPRState;


切换到guest执行需要恢复GPRState和FPRState,而切回qbdi则需要保存GPRState和FPRState。在执行guest时宗旨是不能修改guest状态,包括栈和寄存器,因为被trace的代码可能各种各样,不能假设trace代码如何使用栈和寄存器,最好的方式就是原样维持否则将会引发与原有程序执行不一致的问题。

而对原始指令进行pc重定位和添加instrument代码可能会不可避免的引入寄存器的修改。设想有一个需要trace的代码片段,它使用了所有的通用寄存器进行某种计算,在里边添加的instrument指令是某种函数调用,调用到qbdi提供的指令跟踪函数(处于qbdi上下文),那么这些instrument指令如何实现? 如果是近端可以使用pc相对寻址,如果是远端则需要借助于ADRP/LDR这样的指令,这样就引入了对某个guest寄存器的修改,就需要保存该寄存器,执行完指令以后再恢复。那么保存到哪里又成了问题,像普通的函数调用是有调用约定,caller保存一些可能被callee修改的寄存器在栈上,调用完之后从栈中恢复。但对qbdi来说它不能保存在guest的栈上(会破坏原代码环境),那么就需要保存到qbdi上下文的内存中,这段内存需要事先配置好让guest上下文中的代码可以相对寻址访问到,这个方案类似于arm中的常量池(Literal Pool), qbdi对应的则为ExecBlock。

借用官方文档里边的图:


每一段需要执行的代码都被放置在了一个ExecBlock对象当中,它由两个4096大小的页组成:

llvm::sys::MemoryBlock codeBlock;
llvm::sys::MemoryBlock dataBlock;

这样在codeBlock当中的代码就可以使用相对寻址方式访问到dataBlock中的数据,在qbdi所支持的体系结构中,都支持相对寻址至少4096字节。

如果需要执行的代码多于4096字节那么会有多个ExecBlock,每一条需要执行的指令经过重定位并添加instrument片段以后都放置在codeBlock中,伴随着的还有prologue和epilogue代码用于上下文切换以及控制管理,而dataBlock中的GPRState和FPRState用于保存guest上下文, Host Context则保存qbdi一侧所需的上下文信息,因此这个方案会有一些内存冗余。

Shadows区域则保存着和patch、instrument相关的shadow数据如常量等:

shadows = reinterpret_cast<rword *>(
reinterpret_cast<rword>(dataBlock.base()) + sizeof(Context));

结构图(引用自官方):


用户代码通过QBDI::VM暴露出来的api来和Engine对象进行交互,Engine负责总管整个控制流并且利用PatchDSL来对目标指令重定位、instrument和组装,PatchDSL是QBDI自己提出的概念,它用一个中间层让重新组装目标代码变的简单,如果是RET或者BR指令,以下的代码就可以实现重定位:

 /* Rule #1: Simulate RET and BR
   * Target:  RET REG64 Xn
   * Patch:   DataBlock[Offset(PC)] := Xn
   */
rules.emplace_back(
    Or::unique(conv_unique<PatchCondition>(OpIs::unique(llvm::AArch64::RET),
    OpIs::unique(llvm::AArch64::BR))),
    conv_unique<PatchGenerator>(GetOperand::unique(Temp(0), Operand(0)),
    WriteTemp::unique(Temp(0), Offset(Reg(REG_PC))),
    SaveX28IfSet::unique()));

ExecBlockManager顾名思义管理各个ExecBlock,最终执行的是ExecBlock中的Code Block代码。


LLVM:

qbdi使用LLVM的MC功能来反编译以及生成目标指令,比如反编译我们可以执行: echo "0x76 0x02 0x40 0xf9" | llvm-mc --disassemble -triple=aarch64

qbdi使用CMake的FetchContent_Populate函数将d05K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6D9L8s2k6E0i4K6u0r3L8r3I4$3L8g2)9J5k6s2m8J5L8$3A6W2j5%4c8Q4x3V1k6J5k6h3I4W2j5i4y4W2M7#2)9J5c8X3c8G2N6$3&6D9L8$3q4V1i4K6u0r3L8r3I4$3L8h3!0J5k6#2)9J5k6o6p5&6i4K6u0W2x3g2)9J5k6e0g2Q4x3V1k6D9L8s2k6E0i4K6u0V1x3e0W2Q4x3X3f1I4i4K6u0W2y4g2)9J5k6i4y4J5j5#2)9J5k6i4c8S2M7W2)9J5k6i4S2*7i4@1f1$3i4K6V1$3i4K6R3%4i4@1f1@1i4@1u0n7i4@1t1$3i4@1f1@1i4@1t1^5i4K6S2n7i4@1f1^5i4@1u0p5i4@1u0p5i4@1f1#2i4K6R3^5i4@1t1H3i4@1f1$3i4K6W2o6i4@1q4o6i4@1f1#2i4K6W2o6i4@1t1H3i4@1f1#2i4K6S2r3i4K6R3J5i4@1f1@1i4@1t1^5i4K6S2q4i4@1f1%4i4@1u0o6i4K6V1$3i4@1f1^5i4@1q4r3i4K6V1I4i4@1g2r3i4@1u0o6i4K6S2o6i4@1f1#2i4@1t1&6i4@1t1$3i4@1f1@1i4@1t1^5i4K6V1@1i4@1f1$3i4K6S2o6i4K6R3%4i4@1f1#2i4@1q4q4i4K6W2m8i4@1f1%4i4@1u0o6i4K6V1$3i4@1f1^5i4@1q4r3i4K6V1I4i4@1f1%4i4K6W2m8i4K6R3@1i4@1f1%4i4@1u0n7i4K6R3@1i4@1f1@1i4@1u0n7i4@1t1$3i4@1f1@1i4@1t1^5i4@1u0m8i4K6y4m8

  1. LLVMBinaryFormat

  2. LLVMMCDisassembler

  3. LLVMMCParser

  4. LLVMMC

  5. LLVMSupport

  6. LLVMObject

  7. LLVMTextAPI

  8. LLVMCore

  9. LLVMBitReader

  10. LLVMBitstreamReader

  11. LLVMRemarks

在qbdi中使用了LLVMCPU类做为LLVM库的封装,那么反编译就可以这么来调用:

void disass_test() {
    LLVMCPU llvmcpu;
    unsigned char data[] = {0x76, 0x02, 0x40, 0xf9};
    rword start = (rword)data;
    rword address = (rword)start;
    const llvm::ArrayRef<uint8_t> code((uint8_t *)start,sizeof(data) / sizeof(*data));
    bool dstatus;
    do {
        llvm::MCInst inst;
        uint64_t instSize;
        dstatus = llvmcpu.getInstruction(inst, instSize,
        code.slice(address - start), (rword)start);
        if (!dstatus)
            break;
        std::string disass = llvmcpu.showInst(inst, address);
        std::cout << "disass:" << disass << std::endl;
        const llvm::MCInstrInfo &MCII = llvmcpu.getMCII();
        const llvm::MCInstrDesc &desc = MCII.get(inst.getOpcode());
        unsigned opIsUsedBegin = desc.getNumDefs();
        unsigned opIsUsedEnd = inst.getNumOperands();
        std::cout << "opIsUsedBegin:" << opIsUsedBegin << std::endl;
        std::cout << "opIsUsedEnd:" << opIsUsedEnd << std::endl;
        address += instSize;
    } while (dstatus);
}

生成指令:

void assemble_test() {
    LLVMCPU llvmcpu;
    llvm::MCInst inst = mrs(llvm::AArch64::X0, llvm::AArch64SysReg::TPIDR_EL0);
    std::string disass = llvmcpu.showInst(inst, 0);
    std::cout << "disass:" << disass << std::endl;
}

可以看到指令的低层表示为llvm::MCInst类

ps: llvm::MCInstrDesc的getNumDefs的含义: This is the number of "outs" in the .td file

对应着AArch64InstrInfo.td文件中的outs,比如:

let Uses = [FPSR] in
def MRS_FPSR : Pseudo<(outs GPR64:$dst), (ins),
                    [(set GPR64:$dst, (int_aarch64_get_fpsr))]>,
               PseudoInstExpansion<(MRS GPR64:$dst, 0xda21)>,
               Sched<[WriteSys]>;

let Defs = [FPSR] in
def MSR_FPSR : Pseudo<(outs), (ins GPR64:$val),
                  [(int_aarch64_set_fpsr i64:$val)]>,
              PseudoInstExpansion<(MSR 0xda21, GPR64:$val)>,
              Sched<[WriteSys]>;

对于MRS_FPSR它的getNumDefs就为1(outs GPR64:$dst),对于MSR_FPSR它的getNumDefs就为0(因为outs后面为空)



接下来就可以看一条具体指令的组成,下图仍然取自官网:

一条原始指令被llvm库反编译为MCInst对象以后,会进入到patch环节,该步骤主要重定位pc相关指令,patch逻辑用PatchRule类表示,PatchRule由PatchCondition和PatchGenerator列表组成,PatchCondition表示指令匹配到该PatchRule需满足的条件,PatchGenerator表示满足条件以后需执行的patch动作。比如下面一条PatchRule:

 /* Rule #2: Simulate BLR
   * Target:  BLR REG64 Xn
   * Patch:   DataBlock[Offset(RIP)] := Xn
   *          SimulateLink(Temp(0))
   */
rules.emplace_back(OpIs::unique(llvm::AArch64::BLR),
    conv_unique<PatchGenerator>(
    GetOperand::unique(Temp(0), Operand(0)),
    WriteTemp::unique(Temp(0), Offset(Reg(REG_PC))),
    SimulateLink::unique(Temp(0)),
    SaveX28IfSet::unique()));


OpIs::unique(llvm::AArch64::BLR)为PatchCondition,它匹配BLR这条指令,后面的PatchGenerator表示如果是BLR指令那么应该如何生成新的指令进行替换。

很明显有个PatchRule列表,每一条PatchRule匹配一类指令,处理的指令有: SVC,BRK,RET,BR,BLR,B,Bcc,ADR,ADRP,TBZ,TBNZ,BRAA, BRAB, BRAAZ, BRABZ, RETAA, RETAB等。如果所有匹配都没命中还会有个default匹配(条件为True)用于确保可以正确保存和恢复x28寄存器:


rules.emplace_back(True::unique(), conv_unique<PatchGenerator>(
    ModifyInstruction::unique(
    InstTransform::UniquePtrVec()),
    SaveX28IfSet::unique()));

x28寄存器的作用后面详述。


接下来是一个重要的类:Patch,它的成员变量含义如下:

  1. InstMetadata metadata : 保存原始指令的信息如llvm::MCInst inst、地址address,指令大小instSize等。

  2. std::vector<std::unique_ptr<RelocatableInst>> insts: 可重定位指令由RelocatableInst抽象类表示,在上图中,经过patch以后Patch的insts列表就会被填充为重定位后的RelocatableInst,此时指令还未写入到codeBlock,只有调用RelocatableInst的reloc()以后返回的llvm::MCInst指令才会被qbdi写入到codeBlock。

  3. std::array<RegisterUsage, NUM_GPR> regUsage: 记录了当前指令所使用到的寄存器情况,涉及到寄存器分配。

  4.  std::vector<InstrPatch> instsPatchs: 保存instrument以后的RelocatableInst列表,这个列表最终会被合并到insts列表中去。

经过patch阶段以后,接下进入instrument阶段处理,instrument规则由InstrRule表示,它和PatchRule共用一套PatchDSL,生成出来的RelocatableInst列表存放于Patch类的instsPatchs成员变量中。

Patch对象对应的指令最终会在ExecBlockManager::writeBasicBlock函数中被写入到内存。

指令在内存中的变化:


五. PatchDSL

我们来看一下pc相对寻址ldr指令是如何处理的:

 /* Rule #9: Simulate load literal
   * Target:    LDR Xn, label
   * Patch:     Operand(0) := LDR(PC + Operand(1))
   */
rules.emplace_back(
    Or::unique(
        conv_unique<PatchCondition>(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()));

LDR Xn, label:

这条指令加载pc+label地址处的值赋值给Xn寄存器


上面PatchDSL中的GetPCOffset、ModifyInstruction、AddOperand以及SaveX28IfSet都属于PatchGenerator,它用来生成RelocatableInst列表:

  virtual std::vector<std::unique_ptr<RelocatableInst>>
  generate(const Patch &patch, TempManager &temp_manager) const = 0;

假设目标指令为ldr x8, #0x14

经过重定位以后指令变为两条:

ldr x28, [x27, #1248]

ldr x8, [x28]

让我们来看看这中间发生了什么。

首先看一下条件部分: LDRSl | LDRDl | LDRQl | LDRXl | LDRWl | LDRSWl

这些值的含义对应于llvm中的tablegen : https://llvm.org/docs/TableGen/

在build/_deps/qbdi_llvm/llvm/lib/Target/AArch64/AArch64InstrInfo.td文件中我们可以找到上述变量的定义:


def LDRWl : LoadLiteral<0b00, 0, GPR32z, "ldr",
  [(set GPR32z:$Rt, (load (AArch64adr alignedglobal:$label)))]>;
def LDRXl : LoadLiteral<0b01, 0, GPR64z, "ldr",
  [(set GPR64z:$Rt, (load (AArch64adr alignedglobal:$label)))]>;
let Predicates = [HasFPARMv8] in {
def LDRSl : LoadLiteral<0b00, 1, FPR32Op, "ldr",
  [(set (f32 FPR32Op:$Rt), (load (AArch64adr alignedglobal:$label)))]>;
def LDRDl : LoadLiteral<0b01, 1, FPR64Op, "ldr",
  [(set (f64 FPR64Op:$Rt), (load (AArch64adr alignedglobal:$label)))]>;
def LDRQl : LoadLiteral<0b10, 1, FPR128Op, "ldr",
  [(set (f128 FPR128Op:$Rt), (load (AArch64adr alignedglobal:$label)))]>;
}
// load sign-extended word
def LDRSWl : LoadLiteral<0b10, 0, GPR64z, "ldrsw",
  [(set GPR64z:$Rt, (sextloadi32 (AArch64adr alignedglobal:$label)))]>;


可以看到这些指令对应于LoadLiteral。

再来看一下PatchGenerator是怎么执行的,分以下步骤:

  1. GetPCOffset

  2. ModifyInstruction

  3. SaveX28IfSet

执行的时候还涉及Temp,Operand和Constant的概念,这些都是PatchDSL相关的。前面提到DataBlock和CodeBlock这两页紧靠在一起是提供给guest一个Literal Pool常量池的内存,guest指令可以直接读写DataBlock中的数据。针对

ldr x8, #0x14这样的指令,我们需要计算pc+0x14的值,将该值存放于DataBlock中,这个存储的地方就由Constant表示,然后将结果保存在一个临时寄存器中,这个寄存器由Temp表示,而Operand则是针对llvm::MCInst操作数下标的封装,在这个例子中llvm::MCInst有两个操作数,下标分别为0和1,0为x8寄存器, 1为imm,它的值为5。

总结起来步骤如下:

  1. GetPCOffset  --> 得到pc+0x14的值写入dataBlock的Shadow区域并且生成ldr指令即ldr x28, [x27, #1248], x27称为ScratchRegister,它指向dataBlock的起始位置,偏移1248即Shadow区域, x28为TempManager分配出来的寄存器。

  2. ModifyInstruction --> 原地修改指令,将原来的ldr x8, #0x14修改为ldr x8, [x28],等同于以下代码:
    inst.setOpcode(llvm::AArch64::LDRXui)
    inst.insert(inst.begin() + 1, llvm::MCOperand::createReg(llvm::AArch64::X28));
    inst.getOperand(2).setImm(0);

  3. SaveX28IfSet  --> 如果原始指令修改了x28的值那么需要保存x28的值至dataBlock的Context的gprState对应的x28内存中。


是时候来看一下PatchDSL了,老样子引用一下官方的图: 

这张图这样来理解:

  1. Program一列表示guest环境,QBDI表示qbdi环境。

  2. Temp为qbdi所使用的寄存器,由TempManager分配,分配时总是优先使用x28寄存器,原因是这个寄存器靠后,被guest使用的概率比较小有助于提升性能。如果x28被目标指令所使用,TempManage就会分配其他的通用寄存器做为Temp。上例中Reg = x8, Temp = x28。

  3. Temp寄存器一般需要读写Shadow数据,上例中Shadow数据就是[x27, #1248],它存放于dataBlock。

  4. Reg会需要保存和恢复寄存器数据,那么这些数据就存放在dataBlock的Context结构中。

  5. Temp也可能会直接写dataBlock的Context结构。

是时候考虑一下寄存器保存和恢复的问题了,前面提到过qbdi执行的原则是不修改guest的环境包括栈和寄存器,如果qbdi的patch和instrument代码需要某个寄存器就需要先保存该寄存器至Context,执行完再从Context当中恢复,由于保存和恢复可以基于pc相对寻址来实现,所以这种方式是可行的,这个寄存器我们可以称之为qbdi保存寄存器。相应的也有guest保存寄存器,这个寄存器为x28,TempManager分配出来非x28寄存器都为qbdi保存寄存器,当guest使用到了x28就需要先从Context中恢复该寄存器,而guest中任何写x28的指令最后都会将x28的值写入Context进行保存:

rules.emplace_back(True::unique(), conv_unique<PatchGenerator>(
                                    ModifyInstruction::unique(
                                    InstTransform::UniquePtrVec()),
                                    SaveX28IfSet::unique()));

假如guest函数片段从来没有使用到x28(比较常见),那么qbdi使用x28寄存器就无需恢复和保存。

如果guest函数片段有读写x28指令,由于x28是非参数寄存器,qbdi就会假设函数指令肯定会先写x28寄存器,这样就触发了x28的保存(上面的SaveX28IfSet::unique()),然后每次使用x28寄存器的时候需要从内存当中恢复该值:

// If the instruction uses X28, restore it
if (patch.regUsage[28] != 0) {
    append(p, LoadReg(Reg(28), Offset(Reg(28))).genReloc(*patch.llvmcpu));
}

此时Temp寄存器就会选择非x28寄存器,就需要qbdi自己保存和恢复:


//PatchRule.cpp
void PatchRule::apply(Patch &patch, const LLVMCPU &llvmcpu) const {
    ...
    RelocatableInst::UniquePtrVec saveReg, restoreReg;
    Reg::Vec unrestoredReg;
    temp_manager.generateSaveRestoreInstructions(0, saveReg, restoreReg,unrestoredReg);
    patch.prepend(std::move(saveReg));
    patch.append(std::move(restoreReg));
}

选择x28寄存器就是大部分时间无需考虑保存和恢复,性能比较好。


什么是ScratchRegister?

上面我们提到ldr x8, #0x14经过patch变为两条指令:

ldr x28, [x27, #1248]

ldr x8, [x28]

其中的x27叫做ScratchRegister,ScratchRegister的用途是指向dataBlock让PatchDSL中的寄存器可以访问dataBlock,对于x86和arm32是没有ScratchRegister的,这是因为在这x86和arm32中,pc是作为通用寄存器存在的,比如x86可以这样直接访问dataBlock: movq %rbx, 4330(%rip)

而对于arm64来说pc并不是通用寄存器,想访问dataBlock必须额外分配一个寄存器做为ScratchRegister,因此也必须考虑ScratchRegister的分配和保存问题,这就带来了一些复杂性。

如何分配ScratchRegister?在src/ExecBlock/AARCH64/ExecBlock_AARCH64.cpp文件的以下函数中分配ScratchRegister

void ExecBlock::initScratchRegisterForPatch(
    std::vector<Patch>::const_iterator seqStart,
    std::vector<Patch>::const_iterator seqEnd) {

范围是当前需执行的BasicBlock,逻辑是从x0到x28,排除各个Temp所使用到的寄存器,排除原始指令所使用到的寄存器,选择剩余的寄存器下标最大的那个做为ScratchRegister。



对于fibonacci这样的函数来说比较简单,它没有使用x27和x28寄存器,x27就作为ScratchRegister,而x28作为temp寄存器。ScratchRegister是以basicblock为单位的。

  1. 先执行patch, 再执行Engine::instrument, 这里没有和ScratchRegister有关的处理,在PatchGenerator的generate函数中会优先分配temp寄存器,然后在剩下的寄存器当中分配ScratchRegister
  2. 执行到ExecBlockManager::writeBasicBlock, 然后执行ExecBlock::writeSequence会调用initScratchRegisterForPatch(),在这个函数中会明确ScratchRegister的值,排除掉所有patch中使用的temp寄存器,得到ScratchRegister的值为x27,然后赋值给srInfo.writeScratchRegister
  3. 调用ExecBlock::applyRelocatedInst函数写入指令到内存中,会依次调用到各个RelocatableInst的reloc函数,在这些函数中访问context数据结构用的都是RegLLVM sr = execBlock->getScratchRegisterInfo().writeScratchRegister
  4. 写入每条patch以后调用函数ExecBlock::finalizeScratchRegisterForPatch给InstInfo的sr.scratchRegisterOffset赋值为内存结构中的偏移
  5. 回到getProgrammedExecBlock函数,调用ExecBlock::selectSeq函数,给context->hostState.currentSROffset赋值为seqRegistry[currentSeq].sr.scratchRegisterOffset,scratchRegisterOffset其实就是寄存器数组的下标。
  6. 开始调用Engine::run函数,将x27所在寄存器的值设置为getDataBlockBase()返回值


如果所有的寄存器都被使用了怎么办?这个时候就需要分割序列并ScratchRegister,我们可以使用以下的程序测试:

long sum_all_gprs() {
  long sum;
  // 内联汇编:读取所有通用寄存器并相加
    __asm__ volatile(
      "mov x0, #0\n"
      "add x0, x0, x1\n"
      "add x0, x0, x2\n"
      "add x0, x0, x3\n"
      "add x0, x0, x4\n"
      "add x0, x0, x5\n"
      "add x0, x0, x6\n"
      "add x0, x0, x7\n"
      "add x0, x0, x8\n"
      "add x0, x0, x9\n"
      "add x0, x0, x10\n"
      "add x0, x0, x11\n"
      "add x0, x0, x12\n"
      "add x0, x0, x13\n"
      "add x0, x0, x14\n"
      "add x0, x0, x15\n"
      "add x0, x0, x16\n"
      "add x0, x0, x17\n"
      "add x0, x0, x18\n"
      "add x0, x0, x19\n"
      "add x0, x0, x20\n"
      "add x0, x0, x21\n"
      "add x0, x0, x22\n"
      "add x0, x0, x23\n"
      "add x0, x0, x24\n"
      "add x0, x0, x25\n"
      "add x0, x0, x26\n"
      "add x0, x0, x27\n"
      "add x0, x0, x28\n"
      "mov %[sum], x0\n"
      : [sum] "=r"(sum)
      :
      : "memory", "cc"
  );
  return sum;
}


下面的代码所对应的patch后的指令,主要关注add x0,x0, x27 这条指令:

--> 发现从此条指令开始x27 ScratchRegister寄存器被使用
--> 从当前指令开始调用initScratchRegisterForPatch计算出新的ScratchRegister为x26
--> 然后执行函数changeScratchRegister
ldr    x28, [x27]               --> 原先hostState.scratchRegisterValue的值赋值给temp Reg X28
str    x26, [x27]               --> 新的ScratchRegister x26存入hostState.scratchRegisterValue
mov    x26, x27                 --> 更换ScratchRegister,从旧的x27到新的x26,x26就指向了datablock地址
mov    x27, x28                 --> 恢复x27的值
mov    x28, #26                 --> #26为x26的下标值
str    x28, [x26, #8]           --> 下标值存入到hostState.currentSROffset中
------------------------------>下面就是instrum逻辑
ldr    x28, [x26, #1344]
str    x28, [x26, #32]
mov    x28, #0
str    x28, [x26, #40]
mov    x28, #28
str    x28, [x26, #48]
ldr    x28, [x26, #1352]
str    x28, [x26, #336]
adr    x28, #12
str    x28, [x26, #24]
b    #2396
add    x0, x0, x27           --> 原始指令

六.改变PC指令

对于改变了PC的指令也需要patch,如blr x8

这是因为qbdi需要掌控程序的执行流,如果不patch的话跳转到目标函数以后就失去了控制权,来看一下PatchDSL的处理:

 /* Rule #2: Simulate BLR
   * Target:  BLR REG64 Xn
   * Patch:   DataBlock[Offset(RIP)] := Xn
   *          SimulateLink(Temp(0))
   */
rules.emplace_back(OpIs::unique(llvm::AArch64::BLR),
    conv_unique<PatchGenerator>(
    GetOperand::unique(Temp(0), Operand(0)),
    WriteTemp::unique(Temp(0), Offset(Reg(REG_PC))),
    SimulateLink::unique(Temp(0)),
    SaveX28IfSet::unique()));

BLR Xn: 跳转到Xn寄存器所代表的函数处执行并且将x30(lr)值设为pc+4

对于blr x8,生成的指令为:

mov x28, x8
str x28, [x27, #336]
ldr x28, [x27, #1112]
mov x30, x28
b #3200

来看一下PatchDSL的内容:

  1. GetOperand::unique(Temp(0), Operand(0)) : 将操作数0即x8赋值给Temp寄存器这里是x28,生成的指令为:
    mov x28, x8

  2. WriteTemp::unique(Temp(0), Offset(Reg(REG_PC))) : 将x28的值保存到dataBlock的Context的pc寄存器中,生成的指令为:
    str x28, [x27, #336]

  3. SimulateLink::unique(Temp(0)): 将原指令的结束地址(也就是下一条指令地址)写入Constant并赋值给x30寄存器,生成的指令为:
    ldr x28, [x27, #1112] 
    mov x30, x28

在ExecBlock.cpp的ExecBlock::writeSequence函数中,发现当前指令改变了pc,添加切换回qbdi上下文的指令以接管控制:

// JIT the jump to epilogue
RelocatableInst::UniquePtrVec jmpEpilogue = JmpEpilogue().genReloc(llvmcpu);

生成的指令为:
b #3200


这就涉及到上下文的切换以及序言和尾声的执行。

我们首先来看一下qbdi执行函数的时候准备了什么环境:

res = QBDI::allocateVirtualStack(state, STACK_SIZE, &fakestack);

分配了一个1M大小虚拟栈将guest的sp指向栈顶,但是虚拟栈也有坏处那就是使用qbdi在安卓系统上调用env函数的时候会遇到StackOverflowError的问题:


https://github.com/QBDI/QBDI/issues/243

这是因为android虚拟机会检查栈指针,具体细节就不在此文章描述了。

然后qbdi会在此栈中准备好函数调用所需的参数,对于arm64来说就是将前8个参数放在r0开始的寄存器中其余参数入栈,并且将Context中的lr寄存器设置为虚拟返回地址42,这样qbdi就可以监控函数的返回。

设置好环境以后qbdi就开始了patch,instrument的操作生成各个ExecBlock,每一块ExecBlock都有序言和尾声片段,序言位于codeBlock起始处,尾声则占据着codeBlock末尾。

序言和尾声部分涉及到存储和保存qbdi和guest上下文,qbdi上下文位于Context.hostState结构中,guest上下文位于Context.grpState和Context.frpState结构中。

我们来看一下序言和尾声部分的代码:


序言Prologue:

  1. hint 0x22   -->  是BTI指令的另一种编码形式, 功能上HINT 0x22完全等价于BTI C --> 允许通过 BR 或 BLR 跳转至此。这一步是为了避开开启了BTI安全扩展机制的机器上跳转的限制。这一点可以通过执行echo "BTI C " | llvm-mc --assemble -triple=aarch64 --show-inst得到验证

  2. adrp    x28, #4096             -->X28 is used to address the DataBlock(此时仍然处于host上下文,用x28保存datablock基址)

  3. str    x30, [sp, #-16]!       --> Save return address

  4. mov    x0, sp                     --> Save Host SP

  5. str    x0, [x28, #16]   --> 保存到Context.hostState.sp

  6. add    x0, x28, #368      --> Restore SIMD

  7. ld1    { v0.2d, v1.2d, v2.2d, v3.2d }, [x0], #64   --> 加载完成后,X0寄存器的值会自动增加64字节(这是后变址寻址模式)。

  8. ld1    { v4.2d, v5.2d, v6.2d, v7.2d }, [x0], #64

  9. ld1    { v8.2d, v9.2d, v10.2d, v11.2d }, [x0], #64

  10. ld1    { v12.2d, v13.2d, v14.2d, v15.2d }, [x0], #64

  11. ld1    { v16.2d, v17.2d, v18.2d, v19.2d }, [x0], #64

  12. ld1    { v20.2d, v21.2d, v22.2d, v23.2d }, [x0], #64

  13. ld1    { v24.2d, v25.2d, v26.2d, v27.2d }, [x0], #64

  14. ld1    { v28.2d, v29.2d, v30.2d, v31.2d }, [x0], #64

  15. ldp    x1, x2, [x0], #16   --> Restore FPCR and FPSR

  16. msr    FPCR, x1

  17. msr    FPSR, x2

  18. add    x0, x28, #72

  19. ldp    x1, x2, [x0, #248]     --> Restore Stack and NZCV

  20. msr    NZCV, x2

  21. mov    sp, x1

  22. ldp    x29, x30, [x0, #232]    --> Restore LR and X29

  23. ldp    x26, x27, [x0, #208]    --> Load other registers

  24. ldp    x24, x25, [x0, #192]

  25. ldp    x22, x23, [x0, #176]

  26. ldp    x20, x21, [x0, #160]

  27. ldp    x18, x19, [x0, #144]

  28. ldp    x16, x17, [x0, #128]

  29. ldp    x14, x15, [x0, #112]

  30. ldp    x12, x13, [x0, #96]

  31. ldp    x10, x11, [x0, #80]

  32. ldp    x8, x9, [x0, #64]

  33. ldp    x6, x7, [x0, #48]

  34. ldp    x4, x5, [x0, #32]

  35. ldp    x2, x3, [x0, #16]

  36. ldp    x0, x1, [x0]

  37. ldr    x28, [x28, #24]        -->  Context.hostState.selector -->  Jump selector , 在ExecBlock::selectSeq的函数中赋值

  38. br    x28   --> 跳转到对应的selector即基本块运行


函数序言总结起来的动作为:

  1. x28设置为datablock起始块
  2. 恢复guest上下文包括(并不包含pc):  通用寄存器x0-x30(除了x28因为x28由guest保存)、sp、nzcv、simd寄存器v0-v31、fpcr、fpsr
  3. 保存host上下文包括: lr(x30)、sp
  4. 跳转到Jump selector执行



尾声Epilogue:

  1. adrp    x28, #4096    -->利用adrp将x28设置为datablock的起始地址

  2. stp    x0, x1, [x28, #72]  --> Save GPR from the guest

  3. stp    x2, x3, [x28, #88]

  4. stp    x4, x5, [x28, #104]

  5. stp    x6, x7, [x28, #120]

  6. stp    x8, x9, [x28, #136]

  7. stp    x10, x11, [x28, #152]

  8. stp    x12, x13, [x28, #168]

  9. stp    x14, x15, [x28, #184]

  10. stp    x16, x17, [x28, #200]

  11. stp    x18, x19, [x28, #216]

  12. stp    x20, x21, [x28, #232]

  13. stp    x22, x23, [x28, #248]

  14. stp    x24, x25, [x28, #264]

  15. stp    x26, x27, [x28, #280]

  16. stp    x29, x30, [x28, #304]    --> Save X29 and LR

  17. mrs    x1, NZCV                      --> Save stack and NZCV

  18. mov    x0, sp

  19. stp    x0, x1, [x28, #320]

  20. add    x0, x28, #368             --> set X0 at the beginning of the FPRState

  21. mrs    x1, FPCR                   --> Get FPCR and FPSR

  22. mrs    x2, FPSR

  23. st1    { v0.2d, v1.2d, v2.2d, v3.2d }, [x0], #64     --> Save FPR

  24. st1    { v4.2d, v5.2d, v6.2d, v7.2d }, [x0], #64

  25. st1    { v8.2d, v9.2d, v10.2d, v11.2d }, [x0], #64

  26. st1    { v12.2d, v13.2d, v14.2d, v15.2d }, [x0], #64

  27. st1    { v16.2d, v17.2d, v18.2d, v19.2d }, [x0], #64

  28. st1    { v20.2d, v21.2d, v22.2d, v23.2d }, [x0], #64

  29. st1    { v24.2d, v25.2d, v26.2d, v27.2d }, [x0], #64

  30. st1    { v28.2d, v29.2d, v30.2d, v31.2d }, [x0], #64

  31. stp    x1, x2, [x0]          --> Set FPCR and FPSR

  32. ldr    x0, [x28, #16]      --> Restore Host SP

  33. mov    sp, x0        

  34. ldr    x30, [sp], #16    --> Return to host

  35. ret



函数尾声总结起来的动作为:

  1. x28设置为datablock起始块
  2. 保存guest上下文包括(并不包含pc):  通用寄存器x0-x30(除了x28)、sp、nzcv、simd寄存器v0-v31、fpcr、fpsr
  3. 恢复host上下文包括: sp
  4. 跳转到保存的host lr(x30)寄存器



我们来看一下执行流程:

bool Engine::run(rword start, rword stop) {
    rword currentPC = start;
    do {
      ...
      curExecBlock = blockManager->getProgrammedExecBlock(currentPC,...);
      //下面的ExecBlock::execute
      curExecBlock->execute(); 
      currentPC = QBDI_GPR_GET(curGPRState, REG_PC);
    }while (currentPC != stop);
      ...
}

VMAction ExecBlock::execute() {
    do {
      ...
      run(); //执行dataBlock序言代码,该代码实现为汇编函数__qbdi_runCodeBlock
      ...
    }while (context->hostState.callback != 0);
}


Engine会一直执行直到currentPC是虚构地址42,执行序言代码以后会跳转到其中的patch代码执行,只要遇到修改了pc的指令像上面的blr x8,就会给GPRState的REG_PC赋值为x8,并且跳转回尾声部分切换回qbdi上下文,此时会得到新的目标地址: currentPC = QBDI_GPR_GET(curGPRState, REG_PC); Engine::run函数的循环中会判断这个地址是否在trace的范围内,如果不在则交由ExecBroker,如果在那么需要处理目标地址指令,接着跳转至该地址执行。


因此结论是: 在遇到修改pc指令的代码时,我们需要patch该指令,将目标地址值写入Context.grpState.pc并且跳转到尾声部分让qbdi重新接管控制。


七.Instrument

instrument是因为我们添加了trace回调,如:

vm.addCodeCB(QBDI::PREINST, showInstruction, nullptr);

instrument代码也是由PatchDSL生成,我们先看一下经过instrument以后指令是什么样:

ldr x28, [x27, #896]

str x28, [x27, #32]

mov x28, #0

str x28, [x27, #40]

mov x28, #0

str x28, [x27, #48]

ldr x28, [x27, #904]

str x28, [x27, #336]

adr x28, #12

str x28, [x27, #24]

b #3764

sub sp, sp, #32             // =32  ----> 这一条为原始指令 


它的含义为:


  1. ldr x28, [x27, #896]  --> 将指令跟踪InstCallback函数的地址写入Context的Shadow区域并且赋值给x28
  2. str x28, [x27, #32]   -->将InstCallback函数的地址写入Context.hostState.callback
  3. mov x28, #0   --> 将调用addCodeCB时的data指针值写入Constant区域并且赋值给x28,由于data为nullptr,因此优化为mov x28, #0
  4. str x28, [x27, #40] --> 将data指针值写入Context.hostState.data
  5. mov x28, #0 --> 将InstID值赋值给x28
  6. str x28, [x27, #48]  --> 将InstID值写入Context.hostState.origin用于上层回调获取正在执行指令id
  7. ldr x28, [x27, #904] --> 将目标指令地址加载至x28寄存器
  8. str x28, [x27, #336] --> 将目标指令地址写入Context.grpState.pc
  9. adr x28, #12           --> 将原始指令地址存入x28
  10. str x28, [x27, #24] --> 将x28存入Context.hostState.selector
  11. b #3764    --> 跳转至尾声部分切换回qbdi上下文
  12. sub sp, sp, #32 ----> 这一条为原始指令 




执行完第11指令b #3764以后流程会回到VMAction ExecBlock::execute()函数:

VMAction ExecBlock::execute() {
    do {
      ...
      run(); //执行dataBlock序言代码,该代码实现为汇编函数__qbdi_runCodeBlock
      if (context->hostState.callback != 0) {
          //执行代码跟踪回调
          VMAction r =
          (reinterpret_cast<InstCallback>(context->hostState.callback))(
              vminstance, &context->gprState, &context->fprState,
              (void *)context->hostState.data);
      }
      ...
    }while (context->hostState.callback != 0);
}


执行完代码跟踪回调以后重新进入run()函数,从而进入到序言部分,跳转至Context.hostState.selector处执行,即原始指令sub sp, sp, #32处。


八. ExecBroker:

如果遇到不在trace范围内的指令,会交由ExecBroker并将控制权递交出去,这样我们就不用trace已知或者不感兴趣的逻辑,提升效率。

测试函数:

long test() {
int i = 10;
// qbdi遇到printf会将控制权转交出去
int ret = printf("Hello test\n");
return i + ret;
}

来看一下遇到printf函数时qbdi的行为

在执行Engine::run函数的时候会有所不同,进入到分支:

if (execBroker->isInstrumented(currentPC) == false and
execBroker->canTransferExecution(curGPRState)) {
    ...
    execBroker->transferExecution(currentPC, curGPRState, curFPRState);
    ...
}

ExecBroker会生成一个ExecBlock用于控制转移它叫

std::unique_ptr<ExecBlock> transferBlock;

它的序言部分后面还有一些指令,紧跟在上面38行的序言br    x28后面,指令列表为:

// Sequence Broker with LR
//生成的指令列表:
ldr    x30, [x27, #32]  // hostState.brokerAddr加载至x30 LR,运行前此地址会设置为目标printf地址
mrs    x28, TPIDR_EL0   // 保存TPIDR_EL0寄存器: 先加载至x28
str    x28, [x27, #56]  // TPIDR_EL0保存至hostState.tpidr
ldr    x28, [x27, #296] // 恢复x28寄存器: 内存datablock当中的x28加载到x28寄存器
ldr    x27, [x27]       // 恢复x27寄存器: [x27]为HostState的scratchRegisterValue
//所有使用到的寄存器已恢复,可以跳转到目标执行了
blr    x30              // 跳转至hostState.brokerAddr
// 从控制流返回以后要接管程序,此时需要做的是重置scratchRegister
// 保存x27原先的值并且设置x27为datablock地址
msr    TPIDR_EL0, x27   // 保存x27至TPIDR_EL0
adrp    x27, #4096       // x27设置为datablock地址
str    x28, [x27, #296] // 保存x28
mrs    x28, TPIDR_EL0   // 读取原先的x27的值
str    x28, [x27]       // 保存scratchRegister
ldr    x28, [x27, #56]  // 将保存的hostState.tpidr赋值给x28
msr    TPIDR_EL0, x28   // 恢复TPIDR_EL0
b    #3752              // 跳转回Epilogue
// Sequence Broker with X28生成的指令列表:
ldr    x28, [x27, #32] // hostState.brokerAddr加载至x28,运行前此地址会设置为目标printf地址
ldr    x27, [x27] //恢复x27
br    x28  // 跳转到目标执行
// 保存x27原先的值并且设置x27为datablock地址
mov    x28, x27
adrp    x27, #4096
str    x28, [x27]
b    #3724  // 跳转回Epilogue


九. 复杂的边界条件处理

ExecBlockManager还需要处理一些边界条件,比如如果往ExecBlock中写,剩下空间只能写一部分的指令怎么办?如何实现缓存机制提升效率?这部分内容也比较复杂但是和核心原理关系不大,这里就不描述了。

只要知道涉及到的基本结构为:

  1. ExecBlockManager为ExecRegion列表
  2. ExecRegion为ExecBlock列表
  3. ExecBlock为BasicBlock列表(或者称为Sequence)
  4. BasicBlock为Sequence列表
  5. Sequence为Patch列表
  6. Patch为RelocatableInst列表



以及qbdi会对指令位置设置标签,这些标签有助于在回调函数中定位到指令的相关信息,如下图:

十. 回调函数返回值VMAction各个值的含义

先来看QBDI::VMAction::SKIP_INST

测试函数:

long test() {
  long sum;
  __asm__ volatile(
      "mov x0, #1\n"
      "mov x0, #2\n"
      "mov %[sum], x0\n"
      : [sum] "=r"(sum)
      :
      : "memory", "cc");
      
// 编译出来的汇编为:
// SUB             SP, SP, #0x10
// MOV             X0, #1
// MOV             X0, #2
// MOV             X8, X0
// STR             X8, [SP,#0x10+var_8]
// LDR             X0, [SP,#0x10+var_8]
// ADD             SP, SP, #0x10
// RET          

  return sum;
}
QBDI::VMAction showInstruction2(QBDI::VM *vm, QBDI::GPRState *gprState,
                                QBDI::FPRState *fprState, void *data) {
  std::cout << "-=-----------------------" << std::endl;
  return QBDI::VMAction::SKIP_INST;
}
QBDI::rword test_addr = (QBDI::rword)test;
//优先级数字大者表示优先级高,先执行
vm.addCodeAddrCB(test_addr + 2 * 4, QBDI::PREINST, showInstruction2, nullptr, 1);

可以使用SKIP_INST跳过某些指令,在上面如果跳过mov x0,#1那么返回值就为1,否则返回值就为2

QBDI::VMAction::SKIP_PATCH和SKIP_INST功能差不多,功能更广泛一些

QBDI::VMAction::BREAK_TO_VM

回调返回到VM,此时有机会处理一些其他事情再返回回去运行,这个时候可以修改寄存器值从而改变程序行为,还是拿上面的举例,如:

static int i = 0;
QBDI::VMAction showInstruction2(QBDI::VM *vm, QBDI::GPRState *gprState,
                                QBDI::FPRState *fprState, void *data) {
  std::cout << "-=-----------------------" << std::endl;
  if(i==0){
    i = 1;
    gprState->x0 = 88;
    return QBDI::VMAction::BREAK_TO_VM;
  }  else{
    return QBDI::VMAction::CONTINUE;
  }
}
// 那么执行了函数以后会打印出88


十一. 原子指令LDREX、STREX死循环的问题

使用过frida stalker和qbdi的同学应该会遇到trace死循环的问题,大部分都现在ldrex, strex这样的指令中,我们来看一下原因。

废话不多说直接上代码:

#include <stdatomic.h>
#include <stdio.h>
/*
使用ndk编译: ~/Android/Sdk/ndk/29.0.13599879/toolchains/llvm/prebuilt/linux-x86_64/bin/clang --target=aarch64-linux-android21 test.c
**/
int add(int value) {
    atomic_int atomic_counter = ATOMIC_VAR_INIT(value);
    atomic_fetch_add(&atomic_counter, 1);
    return atomic_load(&atomic_counter);
}
int main(int argc, char **argv) {
    printf("this is a test:%d\n", add(34));
    return 0;
}

add函数将传递过来的value原子方式加1并返回

ida+f5函数为:

 

unsigned int __fastcall _aarch64_ldadd4_acq_rel(unsigned int a1, atomic_uint *a2)
{
  unsigned int result; // w0
  if ( _aarch64_have_lse_atomics )
    return atomic_fetch_add(a2, a1);
  do
    result = __ldaxr((unsigned int *)a2);
  while ( __stlxr(result + a1, (unsigned int *)a2) );
  return result;
}


_aarch64_ldadd4_acq_rel函数有两个参数,a1表示要增加的值,a2表示要修改的内存地址,调用以后结果为*a2 = *a2 + a1

如果_aarch64_have_lse_atomics为true,则采用atomic_fetch_add方式否则将采用ladxr/stlxr的方式。

这里边涉及到ARMv8.1指令集的LSE(Large System Extensions)功能,这个新功能添加了很多原子操作,而在没有LSE功能的时候,原子操作必须由LL/SC指令来实现,也就是这里的ldaxr/stlxr, LL/SC缩写为load-linked/store-conditional,它是很多平台用于实现原子性和锁的基础。

判断是否支持LSE是通过调用getauxval(AT_HWCAP)实现的: 判断返回值的HWCAP_ATOMICS位(1<<8)是否设置上。


我们可以用以下伪代码来理解LL/SC操作:

    int LoadLinked(int *ptr) {
        return *ptr;
    }
    int StoreConditional(int *ptr, int value) {
        if (no one has updated *ptr since the LoadLinked to this address) {
            *ptr = value;
            return 0; // success!
        } else {
           return 1; // failed to update
       }
   }

LL的加载指令和典型加载指令类似,都是从内存中取出值存入一个寄存器。关键区别来自条件式存储(store-conditional)指令,只有上一次加载的地址在期间都没有更新时,才会成功,(同时更新刚才链接的加载的地址的值)。成功时,条件存储返回0,并将ptr指的值更新为value。失败时,返回1,并且不会更新值。


利用LL/SC实现自旋锁的代码如下:

    void lock(lock_t *lock) {
      while (LoadLinked(&lock->flag)||!StoreConditional(&lock->flag, 1))
        ; // spin
    }

利用LL/SC实现原子操作就是我们这里程序的汇编代码:


  1. LDAXR           W0, [X1]             -->将X1(a2变量)地址处的值存储到w0,并设置本地监视器表示对x1地址处的独占访问

  2. ADD             W17, W0, W16     --> 将读取到的值和a1变量相加,设置给W17

  3. STLXR           W15, W17, [X1]  --> 试图将W17写入到X1地址,这个操作会检查本地监视器是否还拥有对x1地址处的独占访问,如果在STLXR执行前另外一个线程修改了X1地址处的内容(包括当前线程自己)则会清除独占访问标记那么会将W15置为1表示出错,否则将会写入成功并将W15置为0表示成功

  4. CBNZ            W15, loc_188C --> 如果出错那么自旋重试


为什么qbdi在trace包含有LL/SC片段的时候会死循环? 这是因为qbdi在LL/SC中间插入了很多trace相关代码,会导致独占访问标志被清除,即使没有往上面的X1地址处写值也会导致独占访问标志被清除从而导致自旋自循环:


ARMv8 手册 E2.10.5 节:


 An implementation might clear an exclusive monitor between the LoadExcl

  instruction and the StoreExcl, instruction without any application-related

  cause. For example, this might happen because of cache evictions.  Software

  must, in any single thread of execution, avoid having any explicit memory

  accesses or cache maintenance instructions between the LoadExcl instruction

  and the associated StoreExcl instruction.


  Implementations can benefit from keeping the LoadExcl and StoreExcl

  operations close together in a single thread of execution. This minimizes

  the likelihood of the exclusive monitor state being cleared between the

  LoadExcl instruction and the StoreExcl instruction. Therefore, for best

  performance, ARM strongly recommends a limit of 128 bytes between

  LoadExcl and StoreExcl instructions in a single thread of execution.



因为cache机制,LL/SC中间会因为内存访问或者cache管理指令导致独占访问标志被清除,ARM推荐LL/SC中间的代码控制在128字节内。

而qbdi应对策略是针对单线程程序(像上面的LL/SC用于原子操作时),用软件实现一个本地监视器,在执行SC之前再插入一条LL指令重新设置独占访问标志,但是这种方式对多线程明显是不行的:

e4bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6c8b7V1c8u0i4K6u0r3f1f1u0p5d9g2)9J5c8X3W2K6M7%4g2W2M7#2)9J5c8U0t1K6x3R3`.`.


我们来看一下qbdi的相关PatchDSL:


/* Rule #13: exclusive load 1 register
     */
rules.emplace_back(
Or::unique(
conv_unique<PatchCondition>(OpIs::unique(llvm::AArch64::LDXRB),
OpIs::unique(llvm::AArch64::LDXRH),
OpIs::unique(llvm::AArch64::LDXRW),
OpIs::unique(llvm::AArch64::LDXRX),
OpIs::unique(llvm::AArch64::LDAXRB),
OpIs::unique(llvm::AArch64::LDAXRH),
OpIs::unique(llvm::AArch64::LDAXRW),
OpIs::unique(llvm::AArch64::LDAXRX))),
conv_unique<PatchGenerator>(
GetConstant::unique(Temp(0), Constant(1)),
WriteTemp::unique(
Temp(0),
Offset(offsetof(Context, gprState.localMonitor.enable))),
WriteOperand::unique(
Operand(1),
Offset(offsetof(Context, gprState.localMonitor.addr))),
ModifyInstruction::unique(InstTransform::UniquePtrVec()),
SaveX28IfSet::unique()));


    /* Rule #15: exclusive store
     */
rules.emplace_back(
Or::unique(
conv_unique<PatchCondition>(OpIs::unique(llvm::AArch64::STXRB),
OpIs::unique(llvm::AArch64::STXRH),
OpIs::unique(llvm::AArch64::STXRW),
OpIs::unique(llvm::AArch64::STXRX),
OpIs::unique(llvm::AArch64::STXPW),
OpIs::unique(llvm::AArch64::STXPX),
OpIs::unique(llvm::AArch64::STLXRB),
OpIs::unique(llvm::AArch64::STLXRH),
OpIs::unique(llvm::AArch64::STLXRW),
OpIs::unique(llvm::AArch64::STLXRX),
OpIs::unique(llvm::AArch64::STLXPW),
OpIs::unique(llvm::AArch64::STLXPX))),
conv_unique<PatchGenerator>(
CondExclusifLoad::unique(Temp(0)),
ModifyInstruction::unique(InstTransform::UniquePtrVec()),
GetConstant::unique(Temp(0), Constant(0)),
WriteTemp::unique(
Temp(0),
Offset(offsetof(Context, gprState.localMonitor.enable))),
SaveX28IfSet::unique()));


我们可以修改examples/cpp/fibonacci.cpp文件的函数测试:

static inline int atomic_increment_ll_sc(int32_t* ptr) {
    uint32_t status;
    __asm__ volatile (
    "1:                     \n\t"
    "ldaxr   w0, [%1]       \n\t"   // 加载当前值到w0
    "add     w0, w0, #1     \n\t"   // w0 = w0 + 1
    "stlxr   %w0, w0, [%1]  \n\t"   // 尝试存储,结果在status
    "cbnz    %w0, 1b        \n\t"   // 失败则重试
    : "=&r" (status)                // 输出: status
    : "r" (ptr)                     // 输入: ptr
    : "w0", "memory"                // 破坏: w0寄存器,内存
    );
    return (status == 0) ? 0 : 1;      // 成功返回0,失败返回1
}


int fibonacci(int n) {
    int abc = 123;
    atomic_increment_ll_sc(&abc);
    return abc;
}


生成的patch代码如下:

这一条是ldaxr w0, [x9]的patch:

  1. mov x28, #1

  2. str x28, [x27, #352]  // 将1写入gprState.localMonitor.enable表示需要设置独占访问标志

  3. str x9, [x27, #344]    //将存储地址写入gprState.localMonitor.addr

  4. ldaxr w0, [x9]   //原始指令 

这一条是没做更改的add w0, w0, #1  

下面是stlxr w8, w0, [x9]的patch

  1. ldr x28, [x27, #352]  //读取gprState.localMonitor.enable

  2. cbz x28, #12                    //如果为0跳转到第5条指令原样执行

  3. ldr x28, [x27, #344]         //如果为1表示上面有ldaxr指令存在需要确保独占访问标志不被清除,首先读取需要访问的地址

  4. ldxrb w28, [x28]              //多执行一步ldxrb确保独占访问标志被设置

  5. stlxr w8, w0, [x9]             //原始指令

  6. mov x28, #0                    

  7. str x28, [x27, #352]         //清除gprState.localMonitor.enable软件独占访问标志


结论是: qbdi或者frida stalker都没有更好的办法处理多线程的LL/SC 死循环的问题,只有在遇到时将范围内指令排除掉,关键是理解死循环的原理。


十二. Frida QBDI

qbdi并不提供注入进程的功能,而qbdi结合frida则提供了这个能力,而且qbdi官方也给出了相应的支持:

8e4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6I4j5X3c8A6i4K6u0W2M7X3g2S2k6s2c8Z5k6h3c8G2j5%4y4Q4x3X3g2A6L8#2)9J5c8X3g2F1i4K6u0r3M7%4c8S2j5X3I4W2i4K6u0r3k6$3g2@1i4K6g2X3M7%4c8S2M7Y4c8W2k6q4)9J5k6r3k6J5K9h3c8S2i4K6u0W2K9s2c8E0L8l9`.`.



我们可以inline hook目标函数然后将参数转交给qbdi执行: 


import { VM,InstPosition,VMAction,Options,MemoryAccessType,AnalysisType,RegisterAccessType,OperandType } from "./frida-qbdi.js"

var funcPtr = module.findExportByName("what_func_do_you_want_trace");
 
Interceptor.replace(funcPtr, new NativeCallback((env_p, jclass, value) => {
        //恢复原函数让qbdi不会trace到frida inline hook代码
        Interceptor.revert(funcPtr);
        let result = qbdi(funcPtr, env_p, jclass, value);
        var env = Java.vm.getEnv();
        var cstring = env.getStringUtfChars(result, null).readCString();
        console.log("cstring:" + cstring);
        //这里替换掉返回值调用方会得到新的返回值
        return result;
    }, 'pointer', ['pointer', 'pointer', 'int']));
    
function qbdi(funcPtr, ...params) {
    var vm = new VM();
    var state = vm.getGPRState();
    var stack = vm.allocateVirtualStack(state, 0x100000);

    vm.addInstrumentedModuleFromAddr(funcPtr);
    var icbk = vm.newInstCallback(function (vm, gpr, fpr, data) {
        var inst = vm.getInstAnalysis(AnalysisType.ANALYSIS_INSTRUCTION | AnalysisType.ANALYSIS_DISASSEMBLY | AnalysisType.ANALYSIS_OPERANDS | AnalysisType.ANALYSIS_SYMBOL);
        gpr.dump(); // Display context
        console.log("0x" + inst.address.toString(16) + " " + inst.disassembly); // Display instruction dissassembly
        return VMAction.CONTINUE;
    });
    var iid = vm.addCodeCB(InstPosition.PREINST, icbk);
    return vm.call(funcPtr, params);
}



这个功能用起来没问题,问题是效率很低很低,每条trace都要经过javascript引擎执行,更好的方案是自己写一个so库封装qbdi api, 然后利用frida来调用此so库:

// 标准输出重定向到文件
function dup_fd() {
    var open = new NativeFunction(
    Module.findExportByName("libc.so", "open"), "int", ["pointer", "int", "int"]);
    var dup2 = new NativeFunction(
    Module.findExportByName("libc.so", "dup2"), "int", ["int", "int"]);
    console.log("open:" + open + ",dup2:" + dup2);
    // O_WRONLY | O_CREAT | O_TRUNC
    let fd = open(Memory.allocUtf8String("/data/data/com.mypack.test/files/log"), 1 | 64 | 512, 438);
    console.log("fd:" + fd);
    let ret = dup2(fd, 1);
    console.log("ret:" + ret);
}

// setenforce 0
function load_so(path) {
    var dlopenPtr = Module.findExportByName(null, 'dlopen');
    var dlopen = new NativeFunction(dlopenPtr, 'pointer', ['pointer', 'int']);
    var soPath = path;
    var soPathPtr = Memory.allocUtf8String(soPath);
    var handle = dlopen(soPathPtr, 2);
    console.log("handle:" + handle);
    if (handle == 0) {
        var dlopenPtr = Module.findExportByName(null, 'dlerror');
        var dlerror = new NativeFunction(dlopenPtr, 'pointer', []);
        console.log("dlerror:" + ptr(dlerror()).readCString());
    }
}

// func_ret_type  --> such as 'pointer'
// func_args_type --> such as ['pointer', 'pointer', 'int', 'int']
function trace_func(libart_base, module_base, func_addr, func_ret_type, func_args_type) {
    dup_fd();
    load_so("/data/local/tmp/libQBDI.so");
    load_so("/data/local/tmp/libqbdi_trace_helper.so");
    var trace_func = Module.findExportByName("libqbdi_trace_helper.so", 'trace_func');
    var trace_func_native = new NativeFunction(trace_func, 'pointer', ['pointer', 'pointer', 'pointer', 'pointer', 'int']);
    Interceptor.replace(func_addr, new NativeCallback(function (...argsArray) {
        console.log("func_addr start======================>");
        Interceptor.revert(func_addr);
        Interceptor.flush();
        var argsPtr = Memory.alloc(Process.pointerSize * argsArray.length);
        for (let i = 0; i < argsArray.length; i++) {
            console.log("args[" + i + "]:" + argsArray[i]);
            Memory.writePointer(argsPtr.add(i * Process.pointerSize), ptr(argsArray[i]));
        }
        var ret = trace_func_native(libart_base, module_base, func_addr, argsPtr, argsArray.length);
        console.log("ret:" + ret);
        console.log("func_addr end <======================");
        return ret;
    }, func_ret_type, func_args_type));
}


function hook_libtest() {
    let module = Process.getModuleByName("libtest.so");
    var target = module.findExportByName("what_func_do_you_want_trace");
    trace_func(Process.getModuleByName("libart.so").base, module.base, target, 'pointer', ['pointer', 'pointer', 'pointer']);
}


同样是利用frida将参数转交给qbdi,只不过这次是转交给so,so的trace_func则是调用qbdi cpp api来实现,不再经过js引擎,效率会成倍的提升。


十三. QBDI的缺陷

  1. qbdi遇到非常怪异的函数可能会执行错误,比如函数参数传递不按常理出牌,这个需要trace时结合逆向去分析。

  2. 如果trace qbdi本身所使用到的非重入性api可能会死锁

  3. 无法处理动态修改代码,这是我之前给作者提的issue: The program that dynamically modifies instructions does not seem to be handled correctly,这个issue起始于我在阅读qbdi缓存功能的时候发现qbdi并没有处理动态修改代码的缓存刷新问题,而作者也给出了解释,结论是框架不太好处理,交由使用者解决。

  4. (待定)其他站在安全防护角度发现的缺陷。




传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 3小时前 被飞翔的猫咪编辑 ,原因:
收藏
免费 14
支持
分享
最新回复 (8)
雪    币: 1152
活跃值: (6600)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
2
沙发
19小时前
0
雪    币: 4061
活跃值: (6028)
能力值: ( LV9,RANK:200 )
在线值:
发帖
回帖
粉丝
3
太顶了,感谢分享。
19小时前
0
雪    币: 1916
活跃值: (1841)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
4
cy
18小时前
0
雪    币: 162
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5
cy
16小时前
0
雪    币: 102
活跃值: (3425)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
6
感谢分享
13小时前
0
雪    币: 4226
活跃值: (3517)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7
nb
2小时前
0
雪    币: 0
活跃值: (1630)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
感谢大佬分享。大佬能开一篇,对比下qbti与frida stalker的区别吗?比如从性能、trace效率、实用性、复杂度等方面。非常感谢
52分钟前
0
雪    币: 184
活跃值: (577)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9
cy
9分钟前
0
游客
登录 | 注册 方可回帖
返回