在喝掉数不清的咖啡、经历许多愉快的编程时光后,@hsorbo 和我很高兴为大家带来 Frida 17.0.0。距离上一次主版本升级已近三年,我们一直苦于找不到合适的时机引入破坏性变更,现在终于决定是时候了。

运行时桥接

长期以来,最困扰我们的一点是运行时桥接,也就是 frida-{objc,swift,java}-bridge,都捆绑在 Frida 的 GumJS 运行时中。这带来了几个主要痛点:

  • 惯性:它们受制于 Frida 的发布周期。
  • 臃肿:用户可能并不需要某个特定的运行时桥接。
  • 可扩展性:我们希望看到面向各种运行时的桥接,但加入 Frida 的桥接越多,惯性和臃肿问题就越严重。
  • 可发现性:社区维护的桥接更难被发现,因为使用它们需要另一套工作流。

不过,我一直不愿停止捆绑这些桥接,因为要求自定义 agent 必须经过构建步骤,似乎会增加太多阻力。而且,一想到这会破坏书籍、博客文章和 CodeShare 等处的示例,我也难以接受。

正因为构建阻力,我们在 15.2 中引入了 frida.Compiler API,同时 frida-tools 还附带了基于它构建的 CLI 工具 frida-compile。我们的 REPL 也得到改进,支持直接加载 .ts(TypeScript),其背后使用的正是 frida.Compiler。

但这仍然多了一步,对于一次性脚本,以及使用 Frida REPL 或 frida-trace 进行的早期原型开发来说,这一步还是太麻烦了。而且,它会破坏大量现有示例。为解决这个问题,刚刚发布的 frida-tools 14.0.0 已将这三个桥接内置进 REPL 和 frida-trace agent。

我们的桥接也已迁移到 ESM,因此可以由最新版 frida-compile 使用。(感谢 @yotamN 将 frida-java-bridge 迁移到 ESM ♥️)

从源码构建 Frida 的用户还可能注意到构建速度有所提升。由于不再捆绑这些桥接,我们终于可以移除 Gum 对 frida-compile 的依赖,并让 Gum 本身不再依赖 Node.js + npm。

我们仍然保留 GumJS 自己的运行时,它实现了 console.log() 等内置功能;不过,将它移植到 ESM,并直接逐个嵌入各模块后,我们就不再需要 JavaScript 打包器了。这意味着 Gum 自身的构建速度更快:在运行 Linux 的 i9-12900K 系统上,构建时间从约 24 秒降至约 6 秒。

可以在桥接文档中查看快速参考教程。

旧式枚举 API

过去,我们所有同步枚举 API 都是这样的:

Process.enumerateModules({
  onMatch(module) {
    console.log(module.name);
  },
  onComplete() {
  }
});

此外还有一个名称带 Sync 后缀的等效方法;在这个例子中就是 Process.enumerateModulesSync()。当初的想法是,底层实现未来可能变为异步;但在当时,大多数实现并非异步,所以带 Sync 后缀的实现只是套在那个看起来像异步的 API 外的一层薄包装。

后来,随着支持的平台越来越多,我意识到所有这些假装异步的实现始终都是快速且低开销的操作。因此,提供异步形式毫无意义。而少数从一开始就真正异步的 API,例如 Memory.scan(),继续保持异步仍然合理。

不过,我不愿直接破坏 API,于是选择为每个不带后缀的实现增加一项检查:如果省略回调参数,它就会像带 Sync 后缀的对应方法一样工作。为了推动用户迁离旧式 API,我还更新了 TypeScript 绑定,使其中只包含现代形式。

对应的现代写法如下:

for (const module of Process.enumerateModules()) {
  console.log(module.name);
}

其中 Process.enumerateModules() 返回一个 Module 对象数组。

这些旧式 API 现在终于全部移除了。使用 TypeScript 编写 agent 的用户无需做任何改动,除非你使用的是非常古老的类型定义版本。

内存读写 API

过去,你会这样访问内存:

const playerHealthLocation = ptr('0x1234');
const playerHealth = Memory.readU32(playerHealthLocation);
Memory.writeU32(playerHealthLocation, 100);

现代等效写法是:

const playerHealthLocation = ptr('0x1234');
const playerHealth = playerHealthLocation.readU32();
playerHealthLocation.writeU32(100);

每个写入方法都会返回 NativePointer 本身,以支持链式调用:

const playerData = ptr('0x1234');
playerData
    .add(4).writeU32(13)
    .add(4).writeU16(37)
    .add(2).writeU16(42)
    ;

这些旧版 API 现在也已移除;它们从 TypeScript 绑定中消失的时间和旧式枚举 API 一样久。因此,大多数用户应该也不会察觉这项变化。

静态 Module API

接下来介绍同样会影响那些在 19.0.0(与 Frida 17 同时发布)之前一直使用最新版 TypeScript 绑定的用户的破坏性变更。以下静态 Module 方法现已移除:

  • Module.ensureInitialized()
  • Module.findBaseAddress()
  • Module.getBaseAddress()
  • Module.findExportByName()
  • Module.getExportByName()
  • Module.findSymbolByName()
  • Module.getSymbolByName()

从这些方法迁移都很直接。

不过,先来看看其中比较特殊的一个:

Module.getSymbolByName(null, 'open')

现在要这样实现:

Module.getGlobalExportByName('open')

对于其余方法,需要先查找 Module,再访问其上的所需属性或方法。例如,不再这样写:

Module.getExportByName('libc.so', 'open')

新的写法是:

Process.getModuleByName('libc.so').getExportByName('open')

Module.getBaseAddress() 的等效写法因此是:

Process.getModuleByName('libc.so').base

这意味着 Module 内省现在只有一种方式,而且 API 设计会引导你编写高性能代码。例如,过去你可能会想这样写:

const openImpl = Process.getExportByName('libc.so', 'open');
const readImpl = Process.getExportByName('libc.so', 'read');

但现在,在写出下面这样的代码之前,你可能会多考虑一下:

const openImpl = Process.getModuleByName('libc.so').getExportByName('open');
const readImpl = Process.getModuleByName('libc.so').getExportByName('read');

而会改成这样:

const libc = Process.getModuleByName('libc.so');
const openImpl = libc.getExportByName('open');
const readImpl = libc.getExportByName('read');

这种写法既更易读,性能也更好。

最后同样重要的是,Module.enumerateExports() 等静态枚举 API 现在也已移除。不过,它们很早以前就从 TypeScript 绑定中删除了,所以大多数用户应该无需处理。如果确实需要迁移,方式与上面完全相同。

EOF

大致就是这些。祝各位逆向愉快!