对软件进行插桩时,一个具有挑战性的方面是处理各种事物的动态特性:线程会启动和终止, 模块会加载和卸载。

例如,如果使用 Stalker 跟踪正在执行的线程,那么在考虑插桩本身之前,就会遇到几个基本 挑战。

哪些线程?

你可以使用 Interceptor 在某处放置内联挂钩,当线程执行某项有趣的操作时,再使用 Stalker 跟踪其执行。不过,有时你会更愿意调用 Process.enumerateThreads(),并跟踪 自己认为有趣的线程。

每个线程都可能有可供使用的 name;如果没有,通常只能采用较模糊的办法。你可以查看 context 属性提供的 CPU 寄存器,也可以将它传给 Thread.backtrace() 来生成线程“指纹”, 或者观察在某项操作期间哪些线程占用了最多 CPU 时间。

但如果能够确定线程的入口点例程及其参数呢?现在可以了:

$ frida -p 163431
Local::PID::163431 ]-> Process.enumerateThreads()
[
    …
    {
        "id": 163560,
        "name": "SDLAudioP1",
        "state": "waiting",
        "context": { … },
        "entrypoint": {
            "parameter": "0x561210844900",
            "routine": "0x7fc7781237c0"
        }
    }
]

未来的线程

接下来的挑战是跟踪尚未启动的线程。在此之前,这需要挂钩操作系统专用的内部机制,而维护 跨平台 Agent 中的这类代码相当复杂。

我很高兴地宣布,现在我们提供了专门解决这个问题的 API:

const observer = Process.attachThreadObserver({
  onAdded(thread) {
    …
  },
  onRemoved(thread) {
    …
  },
  onRenamed(thread, previousName) {
    …
  }
});

onAdded 回调会立即接收所有现有线程,因此无需担心竞态条件,就能轻松处理初始状态与 后续更新。如果参数是刚创建的新线程,此调用会从该新线程同步发生。因此,这正是对它调用 Stalker.follow() 的理想位置,不会错过早期执行的任何指令。

相应地,onRemoved 回调会在线程即将终止时通知你。该调用从相应线程同步发生,因此仍有 机会在线程上下文中执行一些最后的代码。

最后,onRenamed 回调会在线程的 name 刚刚变化时通知你,同时提供旧名称;如果之前 没有名称,则为 null。

所有回调都是可选的,但必须至少提供一个。

之后如果想停止观察,只需执行:

observer.detach();

未来的模块

线程会来去,模块/共享库也一样。你可能会很早应用插桩,以免错过初期活动。然而,应用插桩 越早,应用其他部分尚未加载的可能性就越大。

卸载也许根本不会发生,可能是因为应用不会这样做,也可能是动态加载器不支持;但它仍是你 可能需要处理的一个方面。

过去,处理这一切需要挂钩操作系统专用的内部机制,而维护用于跨平台 Agent 的这类代码会 带来所有相关复杂性。

我非常高兴地告诉大家,现在我们也为此提供了 API:

const observer = Process.attachModuleObserver({
  onAdded(module) {
    …
  },
  onRemoved(module) {
    …
  }
});

与 Process.attachThreadObserver() 一样,onAdded 回调会立即接收所有现有模块,因此无需 担心竞态条件,就能轻松处理初始状态与后续更新。如果参数是一个全新模块,此调用会在模块 加载完成后、应用有机会使用它之前同步发生。这是使用 Interceptor 等工具应用插桩的好时机。

相应地,onRemoved 回调会在模块消失时通知你。

两个回调都是可选的,但必须至少提供一个。

之后如果想停止观察,与线程观察器 API 一样,只需执行:

observer.detach();

分析代码性能

Frida 核心 C 库 Gum 中有一项鲜为人知的功能:名为 gum-prof 的库。它为代码性能分析提供 了一些轻量构件。从本次发布开始,我们终于将其开放给 JavaScript。

先从主要组件 Profiler API 开始。它是一个基于 Interceptor 构建的简单最坏情况分析器:

const profiler = new Profiler();
const sampler = new BusyCycleSampler();
for (const e of Process.getModuleByName('app-core.so')
      .enumerateExports()
      .filter(e => e.type === 'function')) {
  profiler.instrument(e.address, sampler);
}

传统分析器会按一定频率对调用栈采样,而在这里,你可以精确决定希望分析哪些函数。这正是 事情变得有趣的地方。

这些函数中的任何一个被调用时,分析器都会在进入时获取一个样本,在返回时再获取一个。 然后两者相减,计算本次调用的开销。如果结果大于此前为该函数看到的值,它就会成为新的 最坏情况。

发现新的最坏情况时,只知道大部分时间/周期等花在某个函数上不一定足够。例如,该函数 可能只在接收某些输入参数时才变慢。

在这种情况下,调用 instrument() 时可以为特定函数传入 describe() 回调。回调应从 参数列表和/或其他状态中捕获相关上下文,并返回描述刚刚发现的新最坏情况的字符串。

之后调用 generateReport() 时,会发现计算出的描述嵌入在每条最坏情况记录中。

Sampler

你可能已经在刚刚介绍的 Profiler 示例代码中注意到,我们现在还引入了“sampler”的概念。 实际上共有六种不同实现。它们都实现同一个 sample() 方法,返回表示最新测量值的 bigint。 具体表示什么取决于 sampler,但 Profiler 并不关心,因为它只关注两个时间点之间的变化量。

不过,这些 sampler 也可以直接用于其他目的。

以下是全新的 sampler:

  • CycleSampler:测量 CPU 周期,例如在 x86 上使用 RDTSC 指令
  • BusyCycleSampler:只测量当前线程消耗的 CPU 周期,例如在 Windows 上使用 QueryThreadCycleTime()
  • WallClockSampler:测量经过的时间
  • UserTimeSampler:测量特定线程在用户空间中花费的时间
  • MallocCountSampler:统计 malloc()、calloc() 和 realloc() 的调用次数
  • CallCountSampler:统计你所选函数的调用次数

UserTimeSampler 的一个有趣用法是使用线程 ID 构造它,让它测量该特定线程在用户空间中 花费的时间。为每个线程构造一个这样的 sampler,并分别采集一个样本后,可以用某种特定 方式操作应用,例如确保它收到某个特定网络数据包。然后从每个 sampler 采集第二个样本, 减去之前的样本,计算变化量/增量。由此可知哪个线程在用户空间中花费的时间最多,也就知道 接下来可能应该对哪个线程调用 Stalker.follow() 进行深入研究。

结语

此外还有一系列令人兴奋的变更,请务必查看下面的变更日志。

感谢 @hsorbo 与我一起对线程和模块观察功能的各个随机部分进行了愉快而高效的结对 编程!🙌 也感谢 @mrmacete 和 @as0ler 帮助测试和排查错误 🥳

尽情使用吧!

变更日志

  • 引入 Process.attachThreadObserver() 和 ThreadRegistry,用于监视线程创建、终止和重命名。
  • 引入 Process.attachModuleObserver() 和 ModuleRegistry,用于监视模块加载和卸载。
  • gumjs:向 JavaScript 公开 Gum 的 Profiler 和 Sampler API。
  • gumjs:添加 NativePointer#writeVolatile() API。感谢 @DoranekoSystems!
  • fruity:修复 Linux getifaddrs() 逻辑中的崩溃,该逻辑未正确处理没有地址的接口。
  • memory-access-monitor:提供对线程 ID 和寄存器的访问。
  • darwin:修复拆卸期间存在竞态的内存泄漏。
  • linux:避免注入期间出现虚假的 .so 范围。
  • linux:处理注入期间的兼容范围。
  • server:添加 –device,用于提供指定设备。
  • compiler:将 @types/frida-gum 升级到 18.8.0。