这次只有一项新功能,但它分量十足。我们要直面那个一直存在却少有人谈的问题:性能。

Frida 的插桩核心 Gum 使用 C 编写,也可以从 C 中使用;不过对于大多数场景,使用其 JavaScript 绑定会更合适。

然而,在某些情况下性能会成为问题。即使使用基于 V8 的运行时——这意味着 JavaScript 在运行时会接受性能分析,并根据热点位置进行优化……(顺便说一句,这实在令人惊叹,V8 真是一项了不起的工程成就!)

……进入和离开 JavaScript VM 仍要付出少量代价。在 iPhone 5S 上,如果使用 Interceptor.attach(),只指定空的 onEnter,这项开销大约是六微秒。

这听起来可能不多,但如果一个函数被调用一百万次,就会增加 6 秒开销。而 hook 可能只需做一件极其简单的事,因此大部分时间其实都花在进入和离开 VM 上。

需要向 API 传递回调时也有同类问题:API 可能遍历数百万个项目,并为每项调用一次回调;回调也许只是查看一个字节,再收集少数符合特定条件的项目。

最直接的做法是使用 NativeCallback 实现该回调,但很快就会发现这种方式无法扩展。

又或者,你正在编写模糊测试器,需要在紧密循环中调用 NativeFunction;进入/离开 VM 再加上 libffi 的成本会不断累积。

除了用 C 编写整个 agent,还可以构建原生库,并使用 Module.load() 加载。这确实可行,但意味着必须为每一种架构编译并部署到目标等。

另一种方案是使用 X86Writer/Arm64Writer 等 API 在运行时生成代码。这同样很痛苦,因为支持每种架构都需要大量工作。但直到现在,对于 frida-java-bridge 等模块来说,这仍是唯一可移植的选择。

而现在,我们终于有了好得多的方案:CModule 登场:

CModule Hello World 示例

它接收包含 C 源代码的字符串,将其编译为机器码并直接写入内存。该功能使用 TinyCC 实现,因此只给 Frida 增加约 100 kB 的体积。

如图所示,所有全局函数都会自动导出为 NativePointer 属性,名称与 C 源代码中完全相同。

而且,它很快:

CModule 速度

(在主频 3.1 GHz 的 Intel i7 上测得。)

还可以将这项新功能与 Interceptor 等 API 结合使用:

const m = new CModule(`
#include <gum/guminterceptor.h>

#define EPERM 1

int
open (const char * path,
      int oflag,
      ...)
{
  GumInvocationContext * ic;

  ic = gum_interceptor_get_current_invocation ();
  ic->system_error = EPERM;

  return -1;
}
`);

const openImpl = Module.getExportByName(null, 'open');

Interceptor.replace(openImpl, m.open);

(请注意,本例及后续示例使用了模板字面量等现代 JavaScript 特性,因此需要在 V8 运行时上执行,或使用 frida-compile 编译。)

还可以将它与 Interceptor.attach() 结合:

const openImpl = Module.getExportByName(null, 'open');

Interceptor.attach(openImpl, new CModule(`
  #include <gum/guminterceptor.h>
  #include <stdio.h>

  void
  onEnter (GumInvocationContext * ic)
  {
    const char * path;

    path = gum_invocation_context_get_nth_argument (ic, 0);

    printf ("open() path=\\"%s\\"\\n", path);
  }

  void
  onLeave (GumInvocationContext * ic)
  {
    int fd;

    fd = (int) gum_invocation_context_get_return_value (ic);

    printf ("=> fd=%d\\n", fd);
  }
`));

太棒了。不过最后这个示例实际上会写入目标进程的 stdout;调试时这样做没有问题,但可能并不十分实用。

可以通过回调 JavaScript 来解决。来看看具体写法:

const openImpl = Module.getExportByName(null, 'open');

Interceptor.attach(openImpl, new CModule(`
  #include <gum/guminterceptor.h>

  extern void onMessage (const gchar * message);

  static void log (const gchar * format, ...);

  void
  onEnter (GumInvocationContext * ic)
  {
    const char * path;

    path = gum_invocation_context_get_nth_argument (ic, 0);

    log ("open() path=\\"%s\\"", path);
  }

  void
  onLeave (GumInvocationContext * ic)
  {
    int fd;

    fd = (int) gum_invocation_context_get_return_value (ic);

    log ("=> fd=%d", fd);
  }

  static void
  log (const gchar * format,
       ...)
  {
    gchar * message;
    va_list args;

    va_start (args, format);
    message = g_strdup_vprintf (format, args);
    va_end (args);

    onMessage (message);

    g_free (message);
  }
`, {
  onMessage: new NativeCallback(messagePtr => {
    const message = messagePtr.readUtf8String();
    console.log('onMessage:', message);
  }, 'void', ['pointer'])
}));

不过这只是玩具示例:这样做实际上违背了用 C 编写 hook 来提升性能的初衷。真正的实现可以先获取 GLib.Mutex,再追加到 GLib.Array,并通过回调 JS 定期刷新缓冲数据。

就像可以从 C 调用 JavaScript 函数一样,也可以在两个世界之间共享数据:

const calls = Memory.alloc(4);

const openImpl = Module.getExportByName(null, 'open');

Interceptor.attach(openImpl, new CModule(`
  #include <gum/guminterceptor.h>

  extern volatile gint calls;

  void
  onEnter (GumInvocationContext * ic)
  {
    g_atomic_int_add (&calls, 1);
  }
`, { calls }));

setInterval(() => {
  console.log('Calls so far:', calls.readInt());
}, 1000);

目前还没有内置 C API 的文档,但可以浏览 frida-gum/bindings/gumjs/runtime/cmodule 中的头文件来了解概况。对于 GLib 等非 Frida API,可以把函数名放进互联网搜索引擎查找文档。

我们的目标是只公开标准 C 库、GLib、JSON-GLib 和 Gum API 的最小子集,以尽量减小体积并最大化性能。纳入的功能应当是无法通过调用 JS 实现,或以这种方式实现时成本高得难以接受的功能。

可以把 JS 端想象成操作系统,接入其中的函数就是系统调用;CModule 只应用于 hook 高频函数,或实现高性能粘合代码,例如传给性能敏感 API 的回调。

还要记住,TinyCC 生成的机器码不如 Clang 或 GCC 高效,因此计算密集型算法用 JavaScript 实现反而可能更快(使用基于 V8 的运行时时)。但对于 hook 和粘合代码,这种差异并不显著;如果需要优化内层循环,随时可以使用 Arm64Writer 等生成机器码并接入 CModule。

一个重要限制是所有数据都只读,因此可写全局变量应声明为 extern,使用 Memory.alloc() 等方式分配,并通过构造函数的第二个参数以符号形式传入。(就像上一个示例中的 calls。)

CModule 被销毁时(例如脚本被卸载),可能还需要初始化并清理某些内容;为此我们提供了两个生命周期 hook:

const cm = new CModule(`
#include <stdio.h>

void
init (void)
{
  printf ("init\\n");
}

void
finalize (void)
{
  printf ("finalize\\n");
}
`);

cm.dispose(); // or wait until it gets GCed or script unloaded

总之,这篇文章越来越长了;不过在结束之前,先看看如何将 CModule 与 Stalker API 配合使用:

const cm = new CModule(`
#include <gum/gumstalker.h>

static void on_ret (GumCpuContext * cpu_context,
    gpointer user_data);

void
transform (GumStalkerIterator * iterator,
           GumStalkerOutput * output,
           gpointer user_data)
{
  cs_insn * insn;

  while (gum_stalker_iterator_next (iterator, &insn))
  {
    if (insn->id == X86_INS_RET)
    {
      gum_x86_writer_put_nop (output->writer.x86);
      gum_stalker_iterator_put_callout (iterator,
          on_ret, NULL, NULL);
    }

    gum_stalker_iterator_keep (iterator);
  }
}

static void
on_ret (GumCpuContext * cpu_context,
        gpointer user_data)
{
  printf ("on_ret!\n");
}
`);

const mainThread = Process.enumerateThreads()[0];

Stalker.follow(mainThread.id, {
  transform: cm.transform,
  data: ptr(1337)
});

这展示了如何用 C 同时实现 transform 回调和 callout;也可以采用混合方式,用 JS 编写 transform 回调,只用 C 编写部分 callout。

还值得一提的是,我重写了 ObjC.choose(),让它使用 CModule,现在速度大约提升了 100 倍。在 iPhone 6S 上用 Twitter 应用登录界面测试时,耗时从约 5 秒缩短到约 50 毫秒。

希望你喜欢这个版本。我很期待看到大家使用新的 CModule API 构建出怎样的东西。我尤其期待改进 REPL,让它支持在 .js 旁加载 .c 文件,以便快速制作原型。

祝使用愉快!

12.7.0 的变更

  • 全新 CModule API,由 TinyCC 驱动。(你刚刚已经读过相关介绍。)
  • 改进 TinyCC,以支持 macOS/x86 上的 Apple ABI。
  • 现在向 JS 公开 Stalker.exclude(),可以将特定内存范围标记为排除。这有助于提升性能并减少噪声。
  • 现在支持并发调用 Java.use(),感谢 @gebing 的精彩贡献。
  • 改进 hexdump() 实现,将 length 选项限制在 ArrayBuffer 长度以内,再次感谢 @gebing 的精彩贡献。

12.7.1 的变更

  • 更多 CModule 功能,包括 GLib.String、GLib.Timer 和 Json.Builder。
  • 改进 TinyCC,以支持 iOS/arm64 上的 Apple ABI。
  • 使用 CModule 重写 ObjC.choose(),现在速度约快 100 倍。

12.7.2 的变更

  • 为 CModule 补充了一些缺失的引用计数 API。

12.7.3 的变更

  • 现在会正确隐藏 CModule 内存范围。
  • 现在会将外部分配的 CModule 内存告知 V8 垃圾回收器,使其能更好地决定何时执行 GC。
  • 附加到 CModule 的符号现在也会在 V8 运行时中正确保持存活;CModule 自身也不再无限期(或直到脚本卸载)保持存活。
  • 添加 CModule.dispose(),用于主动清理内存。

12.7.4 的变更

  • frida-inject 工具现在支持 spawn()。感谢 @hunterli 贡献这项实用功能。
  • 在 i/macOS 上仍持有 JS 锁时调用 thread_suspend(),V8 运行时不再发生死锁;要求 Stalker.follow() 跟踪另一线程时会间接触发这种情况。

12.7.5 的变更

  • 全新 channels API,用于与已连接的 iOS 或 Android 设备建立 TCP 连接,也可与已连接 iOS 设备上的 lockdown 服务通信。
  • DeviceManager.find_device() 及其同类方法背后的超时逻辑现在可以正常工作。
  • java.lang.Class 的 Java 编组现在可以正常工作,也可以在没有实例的情况下内省实例字段。感谢 @gebing 贡献这些精彩修复!

12.7.6 的变更

  • 现在可在 Android 10 上正确检测 Android linker。
  • Android SELinux 策略修补器现在也能处理 Samsung S10 等设备,感谢 @cbayet 的精彩贡献。
  • frida-inject 工具现在支持 -D/–device,可用于非本地设备。
  • 改进错误处理,避免 i/macOS 进程在早期插桩期间意外终止时导致崩溃。
  • iOS 崩溃报告器集成更加健壮,感谢 @mrmacete 贡献的出色修复。其中一项还确保针对同一消息类型并行调用 recv().wait() 时不会无限等待。
  • Linux/arm64 现在支持跟踪线程创建。感谢 @alvaro_fe 的出色贡献!
  • V8 运行时的 WebAssembly 支持现在也能在非 iOS 平台上正常工作。
  • Gum.DarwinModule API 现已成为跨平台 Gum API 的一部分,可用于在非 Apple 系统上解析 Mach-O 文件。

12.7.7 的变更

  • 关闭最后一个会话时,现在会保留永久化 agent;只要 HostSession 端(例如 frida-server)仍然存在,就可以复用它们。很多情况下因此能避免创建额外的 frida-agent 副本。感谢 @mrmacete 的出色改进。
  • 方法返回 this 时,Java bridge 不再触发释放后使用。
  • Android SELinux 策略修补器不再在旧版 Android 上打印警告。这项无害但令人困惑的回归由上一版本针对 Samsung S10 ROM 的修复引入。
  • 改进与 SELinux 相关的错误消息。
  • 初步支持 iOS/arm64e。

12.7.8 的变更

  • 得益于 @Alien-AV 的杰出贡献,Java bridge 现已支持 Android 10。
  • 改进 Android 应用的 spawn() 处理:应用没有启动器 activity 时可以使用 activity 参数。这项实用改进由 @muhzii 贡献。
  • 得益于 @timstrazz 的优雅贡献,Android linker 查找逻辑已具备面向未来的适应性。
  • 大幅提高 iOS 上的容错能力:launchd agent 卸载时现在会终止待处理进程。这意味着 frida-server 退出后不会留下卡在暂停状态的进程。感谢 @mrmacete 的出色改进。

12.7.9 的变更

  • 修复上一版本最后一刻混入的构建回归,macOS 上恢复正常。

12.7.10 的变更

  • MemoryAccessMonitor 现在可用于所有平台,甚至支持 Duktape 运行时。感谢 @alvaro_fe 的出色改进。
  • Android linker 检测恢复正常。(12.7.8 引入的回归。)
  • 改进 Gadget,支持在 i/macOS 上通过构造函数传入配置。
  • 移除 Gadget 仅在 i/macOS 上实现的系统循环集成,以避免某些场景下的未定义行为。

12.7.11 的变更

  • Frida 不再在没有 vDSO 的 Android 进程中崩溃。(12.7.8 引入的回归。)
  • 改进解析 Mach-O 映像时的错误处理。
  • 在 i/macOS 上恢复异常时正确处理 ARM 与 Thumb。感谢 @alvaro_fe!
  • CModule 的 JSON-GLib 头文件现在按预期实现自包含。

12.7.12 的变更

  • 完整的 iOS lockdown 集成和统一设备,使基于 Frida 的工具不必过多考虑受限设备与越狱设备的差异。与受限 iOS 设备交互时,现在会自动注入 Gadget,无需重新打包应用,只需保证应用可调试。
  • Frida 终于可以在 Windows 上检测较新的 iOS 设备。
  • 定位了 V8 中的错误,并从上游回移修复。感谢 @mrmacete 找到这个问题!

12.7.13 的变更

  • 改进 frida-objc-bridge 中结构体和联合体的处理。感谢 @gebing!
  • Node.js 绑定现在也公开 Crash 和 CrashParameters 的类型定义。
  • 尝试附加受限 iOS 上的系统会话时会提前抛出异常,并提供更清晰的错误消息。
  • 调整与 iOS Developer Disk Image 相关的错误消息,使其保持一致。

12.7.14 的变更

  • 在 Android >= 10 上访问代码前,Frida 现在会确保代码可读。这是完整支持 Android 10 所缺的最后一块拼图。能够插桩系统进程意味着早期插桩(即 spawn())可以工作;启动 frida-server 时,也不会因为它试图预加载以加速第一次 spawn() 而使 system_server 崩溃。感谢 @Alien-AV 和 @esanfelix 的艰苦研究,让这个解决方案得以在某个周六深夜完成实现。:-)

12.7.15 的变更

  • Node.js 绑定也公开 Crash 类型中的“summary”字段。

12.7.16 的变更

  • frida-gadget-ios 元软件包附带类型定义,因此可从 TypeScript 中使用。
  • Node.js 绑定为 Stdio 和 ChildOrigin 提供正确的类型。

12.7.17 的变更

  • 更健壮地支持受限 iOS:现在可以在 attach() 之后、resume() 之前正确调用 kill()。
  • 条件允许时,Frida 现在会直接与远程 iOS/Android agent 通信。实现方式是与 frida-server 建立新的 TCP 连接,并把其文件描述符传给 agent。这样 frida-server 就能退出数据路径,提高性能和可靠性。
  • 客户端连接 frida-server 并 spawn() 进程后,如果还没来得及 resume() 或 kill() 就断开连接,不会再留下悬而未决的孤儿进程。现在会跟踪已生成进程,并在客户端突然断开时将其终止。
  • 不再导致 Android 10 上的 Zygote 崩溃。事实证明缺少了一条 SELinux 规则。
  • TCP 套接字现在设置了 TCP_NODELAY;除了 TCP,还支持使用 UNIX 套接字与远程 frida-server 或 frida-gadget 通信。
  • NativeFunction 现在支持可变参数函数,不再需要为每种唯一的参数列表签名创建一个实例。感谢 @gebing!

12.7.18 的变更

  • Node.js 绑定终于会在 UNIX 上链接所需的 OpenSSL 符号,不再依赖运气——以前通常只是碰巧所在进程已加载另一个全局可见且 ABI 兼容的 OpenSSL(!)。

12.7.19 的变更

  • 现在也能在 V8 运行时中正确处理对无参数 NativeFunction 调用 apply() 的情况。感谢 @taviso 报告这个长期存在的错误。
  • NativeFunction 在 call() 和 apply() 中处理可选参数的行为,现在与内置版本一致。

12.7.20 的变更

  • Frida 现在同时支持明文和加密的 iOS lockdown 通道;在通道地址后附加“?tls=handshake-only”,仍可使用“TLS 握手后转明文”形式的通道。感谢 @mrmacete!
  • 使用“lockdown:”作为通道地址,可以访问配对的 lockdown 通道本身。感谢 @mrmacete!

12.7.21 的变更

  • 现在支持使用 checkra1n 越狱的 iOS 13。(此前已经支持受限 iOS 13。)

12.7.22 的变更

  • iOS 软件包脚本的启动守护进程逻辑现在兼容 checkra1n,因此不必手动启动/停止 frida-server。

12.7.23 的变更

  • 增强对 checkra1n 越狱的支持:Stalker 现在利用 RWX 页面,速度大幅提升。
  • 提高在不支持 RWX 的 iOS 越狱环境中使用 Stalker 时的稳定性。感谢 @mrmacete!
  • CModule 现在兼容更多 iOS 越狱方案。感谢 @mrmacete!
  • CModule 运行时支持使用 ModuleMap 对象。
  • 得益于 Jon Wilson 的出色贡献,现在支持 ARMBE8。
  • 在 i/macOS 上 spawn() 时,Frida 现在会让子进程能够使用父进程文件描述符,与 Linux 当前行为一致。感谢 @wizche!

12.7.24 的变更

  • 也为 Node.js v13 提供预构建包。

12.7.25 的变更

  • 作为一项关键修复,全面调整 Python 和 Node.js 绑定中的日志处理程序 API:Node.js setter 的类型与 getter 不同,因为 setter 还允许 null。这种不一致会导致新版 TypeScript 编译器报错。感谢 @mrmacete!
  • V8 平台集成不再截断时间戳。感谢 @DaveManouchehri 报告问题!
  • Module.enumerateSymbols() API 会在可用时提供“size”属性,目前仅限 Linux/Android。感谢 @DaveManouchehri!
  • 现在可以使用 Java.use(name, { cache: ‘skip’ }) 绕过缓存,在处理多个类加载器和冲突类名时很有用。感谢 @ChaosData 和 @H4oK3!

12.7.26 的变更

  • Stalker 现在支持临时重新激活,从而可以跟踪排除内存范围内部的代码。
  • NativeFunction 新增 traps: 'all' 选项,即使 Frida 自身内存范围被标记为排除,也允许跟踪调用。
  • 使用 Yama 时,Linux 上的线程枚举终于可以正常工作。感谢 Jon Wilson!