在 NowSecure,我们挑灯夜战、喝掉了数不清的咖啡,而这一次确实有重磅消息要告诉大家。
延续上个版本那一系列底层好东西的精神,这次我们要沿技术栈向上一层。我们将介绍一种使用新版 CodeWriter API 的全新方式,让你能够把自己的指令织入所选任意线程执行的机器码。也就是说,可以按线程进行惰性动态重编译,并精确控制编译过程。
不过先介绍一点背景。大多数 Frida 用户可能都在使用 Interceptor API 执行内联 hook,或者通过 ObjC 和 Java API 进行方法调配或替换。通常的思路是修改某个预计会被调用、且值得关注的 API,再把执行流引向自己的代码,从而观察、扩展或完全替换应用行为。
这类方法有一个缺点:代码或数据会被修改,而这些变化很容易被检测到。不过这并无大碍,因为在进程内插桩时,想对宿主进程自己的代码保持隐形,永远都会是一场猫鼠游戏。
然而,在尝试回答“对于给定输入,这个私有 API 背后究竟还会调用哪些 API?”时,这些技术的能力相当有限。又或者,在逆向和模糊测试中,你可能想知道两个已知输入传给某个函数后,执行流会从哪里开始分歧。另一个例子是测量代码覆盖率。你可以利用 Interceptor 对指令级探针的支持:先用静态分析工具找出所有基本块,再用 Frida 在各处放置一次性探针。
这就轮到 Stalker 登场了。它并不是新 API,但过去能做的事情相当有限。可以把它看作按线程工作的代码跟踪器:线程原有的机器码会被动态重编译到新的内存位置,从而在原始指令之间织入插桩逻辑。
这种重编译以惰性方式进行,每次处理一个基本块。考虑到自修改代码非常常见,它会谨慎缓存已编译的代码块,以应对原始代码随后发生变化的情况。
Stalker 还会竭尽所能地重新编译代码,确保副作用完全一致。例如,如果原始指令是 CALL,它会确保压入栈中的是原始下一条指令的地址,而不是重编译后下一条指令的地址。
总之,Stalker 在这个宠物项目里一直像是另一个宠物项目。它非常有趣,但这些年来 Frida 的其他部分占据了我的大部分精力。不过也有一些很棒的例外。许多年前,我和 @karltk 进行过几次有趣的结对编程,当时我们坐下来,决定让 Stalker 在敌对代码上也能可靠工作。再后来,我制作了 CryptoShark,希望让大家对它的潜力感到兴奋。过了一段时间,Stalker 突然收到 Eloi Vanderbeken 贡献的一项关键错误修复。今年年初,Antonio Ken Iannillo 加入并将它移植到了 arm64。就在最近,Erik Smit 又出现了,修复了一个会为带 REP 前缀的 JCC 指令生成无效代码的严重错误。太棒了!
到目前为止,Stalker 的 API 一直非常有限。你可以让它跟踪某个线程,包括当前所在的线程;与内联 hook(即 Interceptor)结合时,这一点很有用。此前只能做两件事:
- 告诉它你关心哪些事件,例如
call: true,这样每条 CALL 指令都会产生一个事件。这意味着 Stalker 会在每条此类指令之前加入一些日志代码,记录 CALL 发生的位置、目标以及栈深度。其他事件类型与此非常相似。 - 为特定目标添加自己的调用探针,在 CALL 指向特定目标时同步回调 JavaScript。
我非常兴奋地宣布,我们刚刚为这个 API 引入了第三种能力,而它将彻底改变玩法。现在你可以自定义重编译过程,而且非常简单:
const appModule = Process.enumerateModulesSync()[0];
const appStart = appModule.base;
const appEnd = appStart.add(appModule.size);
Process.enumerateThreadsSync().forEach(thread => {
console.log('Stalking ' + thread.id);
Stalker.follow(thread.id, {
transform(iterator) {
const instruction = iterator.next();
const startAddress = instruction.address;
const isAppCode = startAddress.compare(appStart) >= 0 &&
startAddress.compare(appEnd) === -1;
do {
if (isAppCode && instruction.mnemonic === 'ret') {
iterator.putCmpRegI32('eax', 60);
iterator.putJccShortLabel('jb', 'nope', 'no-hint');
iterator.putCmpRegI32('eax', 90);
iterator.putJccShortLabel('ja', 'nope', 'no-hint');
iterator.putCallout(onMatch);
iterator.putLabel('nope');
}
iterator.keep();
} while ((instruction = iterator.next()) !== null);
}
});
});
function onMatch (context) {
console.log('Match! pc=' + context.pc +
' rax=' + context.rax.toInt32());
}每当一个新的基本块即将被编译时,transform 回调都会同步执行。它会提供一个迭代器,你可以用它逐条指令推动重编译过程。Instruction 返回值会告诉你即将重编译的指令所需的全部信息。随后调用 keep(),让 Stalker 按照通常方式重编译该指令。如果想跳过某些指令,比如已经用自己的代码替换了它们,就可以省略此调用。迭代器还允许插入自己的指令,因为它公开了当前架构的完整 CodeWriter API,例如 X86Writer。
上面的示例先确定应用自身代码在内存中的位置,再在属于应用的任意代码中每条 RET 指令之前插入几条额外指令。这段代码会检查 eax 是否包含 60 到 90 之间的值;如果是,就回调 JavaScript,由它实现任意复杂的逻辑。这个回调可以随意读取和修改寄存器。这种方法的优势在于,可以把代码插入热点代码路径,并有选择地调用 JavaScript,从而很容易用机器码执行非常快速的检查,再把更复杂的任务交给高级语言。你也可以调用 Memory.alloc(),让生成的代码直接写入分配的内存,完全无需进入 JavaScript。
这就是 10.5 的重大新功能。特别感谢 @asabil 协助塑造这个新 API。
最后,另一项重大变化是 Instruction API 现在公开了底层 Capstone 指令的更多详细信息。Stalker 在 x86 和 arm64 上的内存占用也大幅降低,并且更加可靠。最后,Process.setExceptionHandler() 现在已成为有正式文档的 API,我们的 SQLite API 也是如此。
尽情使用吧!
oleavr