这次有一些重大变化。现在,我们在所有平台上默认使用基于 Duktape 的 JavaScript 运行时,iOS 应用启动不再借助 Cydia Substrate,同时还带来了一些大幅性能提升。此外,也修复了一些错误。
先来谈谈 Duktape。Frida 的第一个 JS 运行时基于 V8,我很庆幸当初作出了这个选择。不过,很明显,在一些使用场景中它并不合适。
有些系统(例如 iOS)不允许使用 RWX 内存1,而 V8 没有它就无法运行。另一个例子是资源受限的嵌入式系统,它们根本没有足够的内存。此外,正如用户不时反馈的那样,有些进程会将其线程配置为使用很小的栈。然而,V8 对栈的需求相当大,因此,如果你钩住了由这些线程中的任意一个调用的函数,它未必能够进入 V8,于是你的钩子看起来就像被忽略了2。
另一方面,在原生代码 ⇔ JS 的转换方面,V8 的开销远高于 Duktape。因此,如果你的 Frida agent 主要用于 API 钩子,而且钩子都很小,那么使用 Duktape 实际上可能更合适。Duktape 的垃圾回收也更加可预测,这对钩住时间敏感的代码很有帮助。
不过,如果你的 agent 大量使用 JavaScript,V8 会快得多。它还原生支持 ES6,尽管这不算特别重要,因为复杂一些的 agent 应该使用 frida-compile,它会将你的代码编译为 ES5。
所以 V8 运行时不会消失,它仍将是一等公民。唯一的变化是我们会默认选择 Duktape,从而保证你在所有平台上获得相同的运行时,并且它有很大概率能够正常工作。
不过,如果你的使用场景重度依赖 JS,只需在创建第一个脚本之前调用 Session#enable_jit(),便会使用 V8。对于我们的 CLI 工具,可以传入 –enable-jit 来达到同样的效果。
Duktape 就说到这里。那么应用启动和 Substrate 又是怎么回事呢?此前,我们在 iOS 上启动应用一直借助 Substrate。这是一种务实的解决方案,可以避免 Frida 与 Substrate 同时在 launchd 和 xpcproxy 中钩住 posix_spawn(),继而相互干扰的互操作场景。
不过,修复这个问题一直在我的长期 TODO 清单上,因为它给其他部分增加了很多复杂性。例如,我们需要一个带外回调机制,让 Substrate 插件能够在加载时回调 Frida;还需要管理临时文件,等等。除此之外,这也意味着我们依赖一个闭源的第三方组件,尽管它只是启动 iOS 应用时才需要的软依赖。但无论如何,它是 Frida 中唯一间接要求永久修改运行中系统的部分,而我们确实希望避免这种情况。
下面看看新的应用启动方式。假设你的主机通过 USB 连接了一台已越狱的 iOS 设备,并在主机上运行:
$ frida-trace -U -f com.atebits.Tweetie2 -i open这条命令会启动 Twitter 的 iOS 应用,并跟踪名为 open 的函数。顺便一提,如果你对其中的细节感兴趣,frida-trace 是用 Python 编写的,只有不到 900 行代码,因此它或许是学习如何在 Frida 之上构建自己的工具的一种好方法。又或者,你愿意改进 frida-trace?那就更好了!
它所做的第一件事是取得第一台 USB 设备,并在其上启动 Twitter 应用。归根结底就是:
import frida
device = frida.get_usb_device()
pid = device.spawn(["com.atebits.Tweetie2"])此时,幕后会发生以下事情:
- 我们将 launchd.js agent 注入 launchd(如果此前尚未完成)。
- 调用该 agent 通过 RPC 导出的 prepareForLaunch(),向它传入即将启动的应用标识符。
- 调用 SBSLaunchApplicationWithIdentifierAndLaunchOptions(),让 SpringBoard 启动应用。
- 随后,我们的 launchd.js agent 会拦截 launchd 的 __posix_spawn() 并添加 POSIX_SPAWN_START_SUSPENDED,然后回传信号告知标识符和 PID。这里的进程是 /usr/libexec/xpcproxy 辅助程序,它将执行一次 exec() 风格的转换,成为目标应用。
- 接着,我们把 xpcproxy.js agent 注入该进程,使其能够钩住 __posix_spawn(),并像 launchd agent 那样添加 POSIX_SPAWN_START_SUSPENDED。不过,这一次还会带有 POSIX_SPAWN_SETEXEC,这意味着它会用即将启动的应用替换自身。
- 我们 resume() xpcproxy 进程,并等待 exec 发生以及进程进入挂起状态。
到这里,我们让 device.spawn() 返回刚启动应用的 PID。应用进程已经创建,其主线程挂起在 dyld 的入口点。随后,frida-trace 会希望附加到该进程,以便加载用于钩住 open 的 agent。于是它会执行类似下面的操作:
session = device.attach(pid)
script = session.create_script("""
Interceptor.attach(Module.getExportByName(null, 'open'), {
onEnter() {
console.log('open()');
}
});
""")
script.load()应用插桩完成后,它会要求 Frida 恢复该进程,让主线程可以调用 main(),开始愉快地运行:
device.resume(pid)请注意,这里略过了一些细节,因为考虑到进程此时尚未初始化,attach() 操作实际上要复杂一些;你可以在这里了解更多信息。
最后,我们来谈谈占用空间和性能。首先,看看 Frida 安装在 iOS 设备上并处于完全可运行状态时需要多少磁盘空间:
这是 64 位版本,经过 xz 压缩后只有 1.87 MB。32 位版本显然更小。这里运用了不少优化:
- 以前,我们会将 frida-helper 二进制文件写入临时文件后再启动。如今,frida-helper 程序的核心已静态链接到 frida-server 中,其 entitlement 也一并得到增强。只有当 Frida 作为插件运行在未知进程中,也就是我们无法对 entitlement 和代码签名作出任何保证时,才需要这个二进制文件。而在 frida-server 场景中,它能够保证满足所有这些约束。
- 我们注入待插桩进程的库 frida-agent.dylib 也不再写入临时文件。我们使用自己的进程外动态链接器,从 frida-server 的内存中映射它,并直接放入目标进程的地址空间。这些映射采用写时复制,因此其内存效率与旧的 dlopen() 方案相同。
- iOS 二进制文件中禁用了 V8,因为它实际上只适用于内核已打补丁、允许 RWX 页面的旧版越狱环境。(如果 V8 对你的使用场景很重要,可以这样构建:
make server-ios FRIDA_DIET=no) - iOS 软件包已拆分成两个:“Frida”面向 64 位设备,“Frida for 32-bit devices”面向旧设备。
- 去掉启动 iOS 应用时对 Substrate 的依赖,也让我们得以移除 FridaLoader.dylib。不过,这只是一个很小的改进。
好了,磁盘占用就是这样。内存用量如何呢?
不错。性能呢?来看看:
请注意,这些测量结果包含了通过 USB 在 macOS 主机与 iOS 设备之间通信所花费的时间。
用得开心!
1 除非该进程拥有相应 entitlement,不过即便如此也仅限一个内存区域。↩
2: 从技术上说,可以为每个线程设置一个辅助栈,并在调用 V8 前切换过去,从而绕开这个问题。过去我们其实已经实现了一部分。长远来看,或许应该重新推进这项工作。↩
oleavr