stalker别来沾边,我怕qbdi误会

汇编粒度trace唯一指定框架

什么是QBDI

QDBI全名QuarkslaB-Dynamic bianry Instrumentation,是一种基于DBI框架的细粒度trace框架,其运行原理和Frida Stalker类似,采用对目标程序的指令按基本块切分后JIT编译成插入了回调的代码,然后再在目标程序同一进程空间中执行

why QBDI

trace的方案有很多,但QBDI是这里面性能最好的,ebpf需要在用户态和内核态之间来回切换开销不可接受,而且还不支持windows,unidbg需要各种补环境,代码越写越臃肿,frida-stalker是这里面最接近qbdi方案的,但是stalker本身bug很多,而且性能不如qbdi,而QBDI不仅性能好,而且跨平台跨架构,支持C APIPython API,还有迁移到fridajs/ts API

frida/QBDI

frida/QBDIqbdijs API,在设计初就和frida联动,而且上手非常容易

VM

VMqbdi中管理回调和代码执行的对象,这里介绍几个基本的方法

VM.addInstrumentedModuleFromAddr(addr)

这个函数将一个地址所在的整个内存段纳入VM的插桩范围,注意到qbdiVM默认不对任何地址插桩,也就是说就算调用VM.call,也需要执行的地址处于插桩范围内才会触发回调,这个addr参数可以传number也可以传fridaNativePointer

VM.newInstCallback(cbk)

这个函数返回一个指令回调对象,也就是VM每执行一句指令就会触发一次的回调,cbk参数是一个签名为function(vm,gpr,fpr,data)的js函数,这里vm就是执行指令的vm对象,gpr是触发回调时的常规寄存器信息,fpr是浮点寄存器信息,data是创建回调时用户提供的额外数据,这个函数必须返回一个VMAction枚举量(continue,skip ....),否则会导致vm的执行出现不可预测的行为

VM.addCodeCB(pos, cbk, data, priority)

这个函数用于把一个回调绑定到VM对象,pos指的是要在指令执行前还是执行后触发该回调(有PreInst和PostInst)两个选项cbk是回调对象,data则是传递给回调的数据,priority指的是这个回调的优先级,处于同一位置,数字越大的回调会越先触发

VM.addMemAccessCB(type, cbk, data, priority)

这个函数专门用来为任意位置的内存访问行为添加回调,type可以选择读,写或者读写都监测,cbk是指令回调对象,datapriority的意思和CodeCB一样

VM.switchStackAndCall(address, args, stackSize)

VM中执行并跟踪一个函数,与VM.call不同这个函数创建一个新栈然后把qbdi引擎放到新栈上执行,而目标函数则在原栈上执行,避免目标函数的行为污染qbdi引擎,address直接传NativePointer即可,argsnumber或者NativePointer都行,stacksize则是分配的栈大小,可以手动设置也可以用默认的

VM.getInstAnalysis(type)

获取当前指令的信息,这个函数在回调中使用,type是下面五个枚举量的掩码

  • ANALYSIS_DISASSEMBLY
  • ANALYSIS_INSTRUCTION
  • ANALYSIS_OPERANDS
  • ANALYSIS_SYMBOL
  • ANALYSIS_JIT

其中ANALYSIS_SYMBOL运行时基本没正常过,ANALYSIS_JIT一般用不到,ANALYSIS_DISASSEMBLY可以生成指令的汇编,ANALYSIS_INSTRUCTION记录指令的地址之类的信息,ANALYSIS_OPERANDS记录了指令所有参数的信息

VM.getInstMemoryAccess()

获取上一次指令的内存访问信息,记录了访问类型,读写地址,读写值,读写大小

VM.setGPRState(state) VM.getGPRState()

获取和设置VM对象的常规寄存器信息,通常用在执行前和frida同步数据上

GPRState.synchronizeContext(FridaCtx, direction)

用于从FridaCtx(回调里的this.context)中读取上下文信息并写入到GPRState中,direction参数因为从GPRFrida写入的功能未实现所以并没有什么意义,只能填FRIDA_TO_QBDI

GPRState.getRegister(rid)

根据寄存器名或寄存器id返回目标上下文中的对应寄存器值

InstAnalysis

这个对象是VM.getInstAnalysis返回的对指令的分析,有一下几个属性是比较重要的

  • inst.address 指令的地址
  • inst.disassembly 反汇编
  • inst.operands 一个数组,记录了指令的所有参数信息
  • inst.operands[i].regAccess 这个参数的寄存器访问类型,0就是没访问,1是读,2是写,3是读写
  • inst.operands[i].regName 如果访问了寄存器则这一项是寄存器名
  • inst.operands[i].regCtxIdx 寄存器id,注意到如果访问eax不会返回rax而是返回eax,但两者对应的寄存器id是一样的,而gprState.getRegister如果以寄存器名为参数只认识rax而不认识eax,使用寄存器id查询则没有这个问题

MemoryAccess

vm.getInstMemoryAccess返回的记录指令内存访问信息的对象,有以下几个属性比较重要

  • memAcc[i].instAddress 同样记录了哪个位置的指令访问了内存
  • memAcc[i].type 记录了访问类型,即读,写,和读写
  • memAcc[i].accessAddress 访问的内存地址
  • memAcc[i].size 访问大小
  • memAcc[i].value 读或写的值

简单Trace模板

代码

有了上面这些API,就能搓一个简单的Trace模板了,这里直接贴代码

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
import {AnalysisType, CallbackPriority, InstPosition, MemoryAccessType, OperandFlag, rword, SyncDirection, VM, VMAction} from './frida-qbdi'

export function startTrace(
vm: VM,
module: Module,
funcOffset: number,
writeLogInHost: boolean,
logs: any,
args?: any[],
) {
type CallbackDataT = {
module: Module,
funcOffset: number,
logFile: any,
};
type RegInfo = {
name: string,
idx: number,
};
var log = '';
vm.addInstrumentedModuleFromAddr(module.base.add(funcOffset));
var RegRead: RegInfo[] = [];
var RegWrite: RegInfo[] = [];
var pre_RegValue: Map<string, undefined|NativePointer> = new Map();
// var post_RegValue: Map<string, undefined|NativePointer> = new Map();

var pre_inst_callback = vm.newInstCallback(function(
vm: VM, gprState, fprState, data: CallbackDataT) {
var inst = vm.getInstAnalysis(
AnalysisType.ANALYSIS_DISASSEMBLY | AnalysisType.ANALYSIS_INSTRUCTION |
AnalysisType.ANALYSIS_OPERANDS);
log = data.module.name + '!+0x' +
ptr(inst.address).sub(data.module.base).toString(16) + ' ' +
inst.disassembly;
RegRead = [];
RegWrite = [];
for (let i = 0; i < inst.operands.length; i++) {
if ((inst.operands[i].regAccess == 3 ||
inst.operands[i].regAccess == 2) &&
inst.operands[i].regCtxIdx != -1)
RegWrite.push({
name: inst.operands[i].regName as string,
idx: inst.operands[i].regCtxIdx as number
});
else if (
inst.operands[i].regAccess == 1 && inst.operands[i].regCtxIdx != -1)
RegRead.push({
name: inst.operands[i].regName as string,
idx: inst.operands[i].regCtxIdx as number
});
if (inst.operands[i].regAccess != 0 && inst.operands[i].regCtxIdx != -1) {
pre_RegValue.set(
inst.operands[i].regName as string,
gprState.getRegister(inst.operands[i].regCtxIdx as number));
}
}
return VMAction.CONTINUE;
});

var post_inst_callback = vm.newInstCallback(function(
vm: VM, gprState, fprState, data: CallbackDataT) {
let regReadLog = 'r[';
for (let i = 0; i < RegRead.length; i++) {
regReadLog += RegRead[i].name + ':0x' +
pre_RegValue.get(RegRead[i].name)?.toString(16) + ', ';
}
regReadLog += '] ';
let regWriteLog = 'w[';
for (let i = 0; i < RegWrite.length; i++) {
regWriteLog +=
`${RegWrite[i].name}:${pre_RegValue.get(RegWrite[i].name)}=>${
gprState.getRegister(RegWrite[i].idx)}, `;
}
regWriteLog += ']';
log += ' ' + regReadLog + regWriteLog + '\n';

if (writeLogInHost)
send({type: 'qbdi', log: log});
else
(data.logFile as any).write(log);
return VMAction.CONTINUE;
});
var mem_acc_callback = vm.newInstCallback(function(
vm: VM, gprState, fprState, data: CallbackDataT) {
var memAcc = vm.getInstMemoryAccess();
var memlog = '';
for (let i = 0; i < memAcc.length; i++) {
let addr = data.module.name + '!+0x' +
ptr(memAcc[i].instAddress).sub(data.module.base).toString(16);
addr = '';
if (memAcc[i].type == MemoryAccessType.MEMORY_READ)
memlog += `mem_r[0x${memAcc[i].accessAddress.toString(16)}]:0x${
memAcc[i].value.toString(16)} (size:${memAcc[i].size}) ${addr}\n`
if (memAcc[i].type == MemoryAccessType.MEMORY_WRITE)
memlog += `mem_w[0x${memAcc[i].accessAddress.toString(16)}]:0x${
memAcc[i].value.toString(16)} (size:${memAcc[i].size}) ${addr}\n`
if (memAcc[i].type == MemoryAccessType.MEMORY_READ_WRITE)
memlog += `mem_rw[0x${memAcc[i].accessAddress.toString(16)}]:0x${
memAcc[i].value.toString(16)} (size:${memAcc[i].size}) ${addr}\n`
}
if (writeLogInHost)
send({type: 'qbdi', log: memlog});
else
data.logFile.write(memlog);
return VMAction.CONTINUE;
});

vm.addCodeCB(
InstPosition.PREINST, pre_inst_callback,
{module: module, funcOffset: funcOffset, logFile: logs});
vm.addCodeCB(
InstPosition.POSTINST, post_inst_callback,
{module: module, funcOffset: funcOffset, logFile: logs});
vm.addMemAccessCB(
MemoryAccessType.MEMORY_READ_WRITE, mem_acc_callback,
{module: module, funcOffset: funcOffset, logFile: logs});

vm.switchStackAndCall(module.base.add(funcOffset), args);
}



function syncCtx_AndTrace(
vm: VM, module: Module, funcOffset: number, args: any[], FridaCtx: any,
logs: any, writeLogInHost: boolean = true) {
var GPRctx = vm.getGPRState();
GPRctx.synchronizeContext(FridaCtx, SyncDirection.FRIDA_TO_QBDI);
vm.setGPRState(GPRctx);
startTrace(vm, module, funcOffset, writeLogInHost, logs, args);
send({type: 'qbdi', signal: '[-] Function Return.'});
}

var logs = new (File as any)('trace.log', 'w');
function traceWrapper() {
var vm = new VM();
var lib = Process.getModuleByName('test_qbdi');
var funcOffset = 0x1838;
var originFunc =
new NativeFunction(lib.base.add(funcOffset), 'void', ['pointer', 'int']);

var fridaCbk =
new NativeCallback(function(str: NativePointer, len: number): void {
Interceptor.revert(lib.base.add(funcOffset));
Interceptor.flush();
// 这条的args与NativeCallback的args一致
syncCtx_AndTrace(
vm, lib, funcOffset, [str, len], this.context, logs, false);
traceWrapper();
}, 'void', ['pointer', 'int']);


Interceptor.replace(lib.base.add(funcOffset), fridaCbk);
}
traceWrapper();

这里我们先注册指令前和指令后回调分别记录指令执行前后的寄存器信息,然后再注册一个内存读写回调记录内存读写信息,然后做一点简单的格式化,把采集到的信息以相对整齐的格式记录下来,主要是指令地址,寄存器变化和内存变化,这里还可以用frida获取到的二进制文件基址来算出指令的偏移并记录,这样Trace回调的部分就完成了
然后设计Trace触发的部分,这里我们希望其尽可能还原真实运行的状态,所以我们用frida替换原函数,然后在hook触发时先恢复原函数入口点,这一点很重要,不然qbdi会跑进frida的指令里,然后我们直接同步上下文并把原函数hook到的参数传进去,同步寄存器后直接运行,采用switchStackAndCall是因为这个api会让指令在原栈上运行且不用我们自己管理栈

目标测试程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
int add(int x, int y) { return x + y; }
int sub(int x, int y) { return x - y; }
int runCnt = 0;
void _xor(char *data_, int len) {
for (int i = 0; i < len; i++) {
data_[i] ^= 0x55;
}
runCnt++;
}

int main() {
int a, b, c;
a = 3, b = 4, c = 5;
printf("%d %d %d\n", a, b, c);
char data[] = "Hello, World!";
_xor(data, sizeof(data) - 1);
printf("%d %d\n", add(a, b), sub(b, c));
_xor(data, sizeof(data) - 1);
printf("%s\n", data);
printf("%d\n", runCnt);
return 0;
}

这里trace _xor这个函数

trace日志

最后甚至可以在日志中加点ANSI字符上色,忽略ai做的幽默配色

可以看出来效果还是不错的,配合vscode相同字符串高亮还可以帮忙分析循环节
缺点就是qbdix86上还是有不少bug,这个代码的内存读写监视部分在x86上就随机出现内存访问错误,导致内存监视不全,对arm64测试下来倒是没发现问题

stalker别来沾边,我怕qbdi误会

https://sgsgsama.github.io/ctf/auto-re-dev/qbdi/

Author

SGSG

Posted on

2025-10-23

Updated on

2025-11-02

Licensed under