Stalker
简介
Stalker 是 Frida 的代码跟踪引擎。它允许跟踪线程,捕获每个函数、每个块,甚至每一条被执行的指令。 此处 提供了有关 Stalker 引擎的非常好的概述,我们建议您首先仔细阅读。显然,尽管它们之间有很多共同点,但实现在某种程度上是特定于体系结构的。 Stalker 目前支持运行 Android 或 iOS 的手机和slab电脑上常见的 AArch64 架构,以及台式机和笔记本电脑上常见的 Intel 64 和 IA-32 架构。本页旨在更详细地介绍 Stalker 的 ARM64 实现,并更详细地解释其工作原理。希望这有助于未来将 Stalker 移植到其他硬件架构上。
免责声明
虽然本文将介绍 Stalker 内部工作原理的许多细节,但它不会真正详细地介绍回填。它的目的是作为一个起点来帮助其他人理解这项技术,如果没有这个,Stalker 就已经足够复杂了!但公平地说,这种复杂性并不是没有原因的,它的存在是为了最大限度地减少本来就昂贵的操作的开销。最后,虽然本文将介绍实现的关键概念,并提取实现的一些关键部分进行逐行分析,但仍有一些实现的最后细节留给读者通过阅读源代码来发现。然而,我们希望这将被证明是一个非常有用的先机。
目录
- 简介
- 免责声明
- 使用场景
- 跟踪
- 基本操作
- 选项
- 术语
- Slab 内存块
- 代码块
- 对代码块进行插桩
- 辅助函数
- 上下文
- 上下文辅助函数
- 读取/写入上下文
- 控制流
- 门
- 虚拟化函数
- 发出事件
- 停止跟踪与清理
- 其他内容
使用场景
要开始了解 Stalker 的实现,我们必须首先详细了解它为用户提供的功能。虽然 Stalker 可以通过其原生 Gum 接口直接调用,但大多数用户将通过 JavaScript API 来调用它,该 API 将代表他们调用这些 Gum 方法。 Gum 的 TypeScript 类型定义 得到了很好的注释,并提供了更多细节。
JavaScript 到 Stalker 的主要 API 是:
Stalker.follow([threadId, options])开始跟踪
threadId(或当前线程,如果省略)
让我们考虑一下何时可以使用这些调用。当您有一个感兴趣的线程并且想知道它在做什么时,可能会在您提供线程 ID 的地方进行跟踪。也许它有一个有趣的名字?可以使用 cat /proc/PID/tasks/TID/comm 找到线程名称。或者,您可能使用 Frida JavaScript API Process.enumerateThreads() 遍历进程中的线程,然后使用 NativeFunction 调用:
int pthread_getname_np(pthread_t thread,
char *name, size_t len);将其与 Thread.backtrace() 一起使用来转储线程堆栈可以让您很好地了解进程正在执行的操作。
您可能调用 Stalker.follow() 的另一种情况可能来自已被拦截 或替换的函数。在这种情况下,您找到了一个感兴趣的函数,并且想要了解它的行为方式,您想要查看调用给定函数后线程执行哪些函数甚至代码块。也许您想比较不同输入的代码所采取的方向,或者您可能想修改输入以查看是否可以使代码采取特定路径。
在这两种情况下,尽管 Stalker 的工作原理略有不同,但它都是由用户使用相同的简单 API Stalker.follow() 进行管理。
跟踪
当用户调用 Stalker.follow() 时,JavaScript 引擎会调用 gum_stalker_follow_me() 来跟踪当前线程,或者调用 gum_stalker_follow(thread_id) 来跟踪进程中的另一个线程。
gum_stalker_follow_me
对于 gum_stalker_follow_me(),链接寄存器用于确定开始跟踪的指令。在AArch64架构中,链接寄存器(LR)被设置为函数调用返回后继续执行的指令的地址,BL和BLR等指令将其设置为下一条指令的地址。由于只有一个链接寄存器,如果被调用函数要调用另一个例程,则必须存储 LR 的值(通常位于堆栈上)。该值随后将从堆栈加载回寄存器,并且 RET 指令用于将控制权返回给调用者。
我们来看一下 gum_stalker_follow_me() 的代码。这是函数原型:
GUM_API void gum_stalker_follow_me (GumStalker * self,
GumStalkerTransformer * transformer, GumEventSink * sink);所以我们可以看到该函数由 QuickJS 或 V8 运行时传递 3 个参数来调用。第一个是 Stalker 实例本身。请注意,如果一次加载多个脚本,则可能存在多个脚本。第二个是转换器,它可用于在编写插桩代码时对其进行转换(稍后会详细介绍)。最后一个参数是事件接收器,这是在 Stalker 引擎运行时传递生成的事件的地方。
#ifdef __APPLE__
.globl _gum_stalker_follow_me
_gum_stalker_follow_me:
#else
.globl gum_stalker_follow_me
.type gum_stalker_follow_me, %function
gum_stalker_follow_me:
#endif
stp x29, x30, [sp, -16]!
mov x29, sp
mov x3, x30
#ifdef __APPLE__
bl __gum_stalker_do_follow_me
#else
bl _gum_stalker_do_follow_me
#endif
ldp x29, x30, [sp], 16
br x0我们可以看到第一条指令STP将一对寄存器存储到堆栈中。我们可以注意到表达式 [sp, -16]!。这是一个预减,这意味着堆栈首先前进16个字节,然后存储两个8字节寄存器值。我们可以在函数底部看到对应的指令ldp x29, x30, [sp], 16。这会将这两个寄存器值从堆栈恢复到寄存器中。但这两个寄存器是什么?
那么,X30 是链接寄存器,X29 是帧指针寄存器。回想一下,如果我们希望调用另一个函数,我们必须将链接寄存器存储到堆栈中,因为这将导致它被覆盖,并且我们需要这个值以便我们可以返回到调用者。
帧指针用于在调用函数时指向堆栈顶部,以便可以以距帧指针固定的偏移量访问所有堆栈传递的参数和基于堆栈的局部变量。同样,我们需要保存和恢复它,因为每个函数都会有该寄存器的值,因此我们需要存储调用者放入其中的值并在返回之前恢复它。事实上,您可以在下一条指令 mov x29, sp 中看到我们将帧指针设置为当前堆栈指针。
我们可以看到下一条指令mov x3, x30,将链接寄存器的值放入X3。 AArch64 上的前 8 个参数在寄存器 X0-X7 中传递。因此,这将被放入用于第四个参数的寄存器中。然后我们调用(带链接的分支)函数 _gum_stalker_do_follow_me()。因此我们可以看到,我们原封不动地传递了 X0-X2 中的前三个参数,以便 _gum_stalker_do_follow_me() 接收到与我们调用时相同的值。最后,我们可以看到该函数返回后,我们分支到我们收到的作为其返回值的地址。 (在 AArch64 中,函数的返回值在 X0 中返回)。
gpointer
_gum_stalker_do_follow_me (GumStalker * self,
GumStalkerTransformer * transformer,
GumEventSink * sink,
gpointer ret_addr)gum_stalker_follow
该例程具有与 gum_stalker_follow_me() 非常相似的原型,但具有附加的 thread_id 参数。事实上,如果要求跟踪当前线程,那么它将调用该函数。让我们看看指定另一个线程 ID 时的情况。
void
gum_stalker_follow (GumStalker * self,
GumThreadId thread_id,
GumStalkerTransformer * transformer,
GumEventSink * sink)
{
if (thread_id == gum_process_get_current_thread_id ())
{
gum_stalker_follow_me (self, transformer, sink);
}
else
{
GumInfectContext ctx;
ctx.stalker = self;
ctx.transformer = transformer;
ctx.sink = sink;
gum_process_modify_thread (thread_id, gum_stalker_infect, &ctx);
}
}我们可以看到这调用了函数gum_process_modify_thread()。这不是 Stalker 的一部分,而是 Gum 本身的一部分。该函数采用带有上下文参数的回调来调用传递线程上下文结构。然后,此回调可以修改 GumCpuContext 结构,然后 gum_process_modify_thread() 会将更改写回。我们可以看到下面的上下文结构,您可以看到它包含 AArch64 CPU 中所有寄存器的字段。我们还可以在下面看到回调的函数原型。
typedef GumArm64CpuContext GumCpuContext;
struct _GumArm64CpuContext
{
guint64 pc;
guint64 sp;
guint64 x[29];
guint64 fp;
guint64 lr;
guint8 q[128];
};static void
gum_stalker_infect (GumThreadId thread_id,
GumCpuContext * cpu_context,
gpointer user_data)那么,gum_process_modify_thread() 是如何工作的呢?嗯,这取决于平台。在 Linux(和 Android)上,它使用 ptrace API(与 GDB 使用的 API 相同)来附加到线程并读取和写入寄存器。但存在许多复杂性。在Linux上,您无法跟踪自己的进程(或者实际上是同一进程组中的任何进程),因此Frida在其自己的进程组中创建当前进程的克隆并共享相同的内存空间。它使用 UNIX 套接字与其进行通信。这个克隆的进程充当调试器,读取原始目标进程的寄存器并将它们存储在共享内存空间中,然后根据需要将它们写回进程。哦,还有 PR_SET_DUMPABLE 和 PR_SET_PTRACER 控制谁可以跟踪我们原始进程的权限。
现在您会发现gum_stalker_infect()的功能实际上与我们之前提到的_gum_stalker_do_follow_me()非常相似。两个函数本质上执行相同的工作,虽然 _gum_stalker_do_follow_me() 在目标线程上运行,但 gum_stalker_infect() 不是在目标线程上运行,因此必须使用 GumArm64Writer 编写一些代码供目标线程调用,而不是直接调用函数。
我们将很快更详细地介绍这些功能,但首先我们需要更多的背景知识。
基本操作
代码可以被认为是一系列指令块(也称为基本块)。每个块以一系列可选的指令开始(我们可能有两个连续的分支语句),这些指令按顺序运行,并在遇到一条指令时结束,该指令导致(或可能导致)继续执行内存中紧随其后的指令以外的指令。
Stalker 一次只处理一个区块。它从返回到调用 gum_stalker_follow_me() 后的代码块开始,或者从调用 gum_stalker_follow() 时目标线程的指令指针所指向的代码块开始。
Stalker 的工作原理是分配一些内存并向其中写入原始块的新插桩副本。可以添加指令来生成事件,或执行 Stalker 引擎提供的任何其他功能。Stalker 还必须根据需要重新定位指令。考虑以下指令:
ADR 位于 PC 相对偏移处的标签地址。
ADR Xd, label
Xd 是 64 位通用目标寄存器的名称,范围为 0 到 31。
label 是要计算其地址的程序标签。它表示相对于此指令地址的偏移量, 范围为 ±1 MB。
如果这条指令被复制到内存中的不同位置并执行,那么由于标号的地址是通过在当前指令指针上加上偏移量来计算的,所以值会不同。幸运的是,Gum 有一个 Relocator 专门用于此目的,它能够修改给定新位置的指令,以便计算出正确的地址。
回想一下,Stalker 一次只处理一个代码块。那么该如何为下一个代码块插桩呢?每个代码块都以分支指令结束;只要将这个分支改为返回 Stalker 引擎,同时保存原分支的目标地址,就能为下一个代码块插桩,并把执行重定向到插桩后的副本。之后可以逐块重复这一过程。
现在,这个过程可能有点慢,因此我们可以应用一些优化。首先,如果我们多次执行同一代码块(例如循环,或者可能只是多次调用的函数),我们不必再次重新插桩它。我们可以重新执行相同的插桩代码。因此,哈希表中保存了我们之前遇到过的所有块以及放置该块的插桩副本的位置。
其次,遇到调用指令时,我们会在发出插桩后的调用之后再发出一个着陆垫,以便函数返回时无需重新进入 Stalker。Stalker 使用 GumExecFrame 结构构建一个侧栈,其中记录真实返回地址 (real_address) 和着陆垫地址 (code_address)。函数返回时,生成的代码会将侧栈中的返回地址与 real_address 比较;若匹配,就可以直接返回 code_address,无需重新进入运行时。着陆垫最初包含进入 Stalker 引擎、为下一个代码块插桩的代码,之后还可通过回填改成直接分支到该代码块。这样,整个返回过程都可以避免进出 Stalker 的开销。
如果返回地址与 GumExecFrame 中保存的 real_address 不匹配,或者侧栈空间不足,我们就从头开始构建一个新的侧栈。应用程序代码执行期间必须保留 LR 的值,避免应用程序借此检测 Stalker(反调试),也避免破坏它对 LR 的其他用途(例如引用代码段中的内联数据)。此外,Stalker 应能随时停止跟踪,因此不能要求回溯整个栈去修正此前改写过的 LR 值。
最后,虽然我们总是用对 Stalker 的调用来替换分支以对下一个块进行插桩,但根据 Stalker.trustThreshold 的配置,我们可以 backpatch 这样的插桩代码,以将调用替换为直接分支到下一个插桩块。确定性分支(例如目的地是固定的并且分支没有条件)很简单,我们只需将到 Stalker 的分支替换为到下一个块的分支即可。但是,如果我们检测两个代码块(如果分支被采用则一个,如果没有则另一个),我们也可以处理条件分支。然后,我们可以用一个条件分支替换原始条件分支,该条件分支将控制流引导到采用分支时遇到的块的插桩版本,然后是无条件分支到另一插桩块。我们还可以部分处理目标不是静态的分支。假设我们的分支是这样的:
br x0这类指令常用于调用函数指针或类方法。X0 的值虽然可能变化,但很多时候始终相同。因此,可以用一段代码替换最终的分支指令:先将 X0 与已知函数地址比较;若匹配,就分支到该函数的插桩副本;若不匹配,则无条件分支回 Stalker 引擎。这样,即使函数指针发生变化,代码仍能正确工作,Stalker 会重新接管并为实际目标插桩;若它如预期保持不变,则可以完全绕过 Stalker 引擎,直接进入插桩后的函数。
选项
现在来看使用 Stalker 跟踪线程时可用的选项。被跟踪线程执行时,Stalker 会生成事件,并把它们放入队列,由用户定期或手动刷新。实际处理由 EventSink::process 虚函数完成,因为每产生一个事件就重新进入 JavaScript 运行时的开销高得难以接受。队列大小和刷新周期都可通过选项配置。事件可以按指令生成,包括调用、返回或全部指令;也可以按代码块生成,在代码块执行或被 Stalker 插桩时触发。
我们还可以提供两个回调 onReceive 或 onCallSummary 之一。前者将非常简单地传递一个包含 Stalker 生成的原始事件的二进制 blob,其中事件按照生成的顺序排列。(Stalker.parse() 可用于将其转换为表示事件的元组的 JS 数组。)。第二个聚合这些结果,只是返回每个函数被调用的次数。这比 onReceive 更高效,但数据粒度要低得多。
术语
在我们继续描述 Stalker 的详细实现之前,我们首先需要了解设计中使用的一些关键术语和概念。
探针
当线程在 Stalker 外部运行时,您可能熟悉使用 Interceptor.attach() 在调用给定函数时获取回调。然而,当线程在 Stalker 中运行时,这些拦截器可能不起作用。这些拦截器的工作原理是修补目标函数的前几条指令(序言),以将执行重新定向到 Frida 中。 Frida 将前几条指令复制并重新定位到其他地方,以便在 onEnter 回调完成后,它可以将控制流重新定向回原始函数。
这些在 Stalker 中不起作用的原因很简单,原始函数从未被调用。每个块在执行之前都会在内存中的其他位置进行检测,并且执行的就是这个副本。 Stalker 支持 API 函数 Stalker.addCallProbe(address, callback[, data]) 来提供此功能。如果我们的 Interceptor 在块被检测之前已被附加,或者 Stalker 的 trustThreshold 被配置以便我们的块将被重新插桩,那么我们的 Interceptor 将工作(因为修补的指令将被复制到新的插桩块)。不然不会。当然,当这些条件不满足时,我们希望能够支持挂钩函数。 API 的普通用户可能不熟悉这种设计细节级别,因此调用探针可以解决此问题。
可选的数据参数在注册探针回调时传递,并在执行时传递给回调例程。因此,该指针需要存储在 Stalker 引擎中。此外,还需要存储地址,以便当遇到调用该函数的指令时,可以对代码进行检测以首先调用该函数。由于多个函数可能会调用您添加探针的函数,因此许多插桩块可能包含调用探针函数的附加指令。因此,每当添加或删除探针时,缓存的插桩块都会被破坏,因此必须重新插桩所有代码。请注意,仅当 callback 是 C 回调时才使用此数据参数 – 例如使用 CModule 实现——就像使用 JavaScript 一样,使用闭包来捕获任何所需的状态会更简单。
信任阈值
回想一下,我们应用的简单优化之一是,如果我们尝试多次执行一个块,那么在随后的情况下,我们可以简单地调用我们上次创建的插桩块?嗯,只有当我们正在检测的代码没有改变时,这才有效。在自修改代码的情况下(通常用作反调试/反反汇编技术,试图阻止对安全关键代码的分析),代码可能会发生变化,因此无法重复使用已插桩的块。那么,我们如何检测一个块是否发生了变化呢?我们只需在数据结构中保留原始代码的副本以及插桩版本即可。然后,当我们再次遇到某个块时,我们可以将要检测的代码与上次检测的版本进行比较,如果匹配,则可以重新使用该块。但是每次块运行时执行比较可能会减慢速度。再说一次,这是一个可以定制 Stalker 的区域。
Stalker.trustThreshold:一个整数,指定一段代码执行了多少次 需要先执行,然后才能确信它不会发生变异。 指定 -1 表示不信任(慢),0 表示从一开始就信任代码,N 表示 执行N次后才信任代码。默认为 1。
事实上,N 的值是在我们停止执行比较之前该块需要重新执行并与之前检测的块匹配(例如保持不变)的次数。请注意,即使信任阈值设置为 -1 或 0,代码块的原始副本仍然会被存储。虽然这些值实际上并不需要它,但保留它是为了让事情变得简单。无论如何,这些都不是默认设置。
排除范围
Stalker 还提供 Stalker.exclude(range) API。传入范围的基址和上限后,Stalker 将不会为这些区域内的代码插桩。例如,线程可能会调用 libc 中的 malloc()。你通常并不关心堆分配器的内部实现;跟踪它既会降低性能,也会生成大量无关事件。但需要注意,一旦调用进入排除范围,对该线程的跟踪就会暂停,直至调用返回。因此,如果线程在排除范围内又调用了范围外的函数,例如某个回调,Stalker 也不会捕获它。排除机制既能忽略整个库,也能忽略某个函数及其被调用函数。对于静态链接的目标程序,这尤其有用:此时无法简单忽略对整个 libc 的调用,但可以通过 Module.enumerateSymbols() 找到 malloc() 的符号,并只忽略这个函数。
冻结/解冻
作为 DEP 的扩展,某些系统会阻止页面同时被标记为可写和可执行。因此,Frida 必须在可写和可执行之间切换页面权限才能编写插桩代码,并允许该代码分别执行。当页面可执行时,它们被称为冻结(因为它们无法更改),当它们再次可写时,它们被视为解冻。
调用指令
与 Intel 不同,AArch64 没有单个显式 CALL 指令,该指令具有不同的形式来应对所有支持的场景。相反,它使用许多不同的指令来提供对函数调用的支持。这些指令全部分支到给定位置并用返回地址更新链接寄存器 LR:
BLBLRBLRAABLRAAZBLRABBLRABZ
为简单起见,在本文的其余部分中,我们将把这组指令称为“调用指令”。
帧
每当 Stalker 遇到调用时,它都会将返回地址和检测到的返回块转发器的地址存储在一个结构中,并将这些添加到存储在其自己的数据结构中的堆栈中。它将其用作推测性优化,也用作发出调用和返回事件时近似调用深度的启发式方法。
typedef struct _GumExecFrame GumExecFrame;
struct _GumExecFrame
{
gpointer real_address;
gpointer code_address;
};转换器
GumStalkerTransformer 类型用于生成插桩代码。默认转换器的实现如下所示:
static void
gum_default_stalker_transformer_transform_block (
GumStalkerTransformer * transformer,
GumStalkerIterator * iterator,
GumStalkerOutput * output)
{
while (gum_stalker_iterator_next (iterator, NULL))
{
gum_stalker_iterator_keep (iterator);
}
}它由负责生成插桩代码的函数 gum_exec_ctx_obtain_block_for() 调用,其工作是生成插桩代码。我们可以看到它使用循环一次处理一条指令来完成此操作。首先从迭代器检索指令,然后告诉 Stalker 按原样插桩指令(不进行修改)。这两个功能是在 Stalker 内部实现的。第一个负责解析 cs_insn 并更新内部状态。此 cs_insn 类型是内部 Capstone 反汇编器用来表示指令的数据类型。第二个负责编写插桩指令(或指令集)。我们稍后将更详细地介绍这些内容。
用户可以提供一个可以随意替换和插入指令的自定义实现,而不是使用默认的转换器。 API 文档 中提供了一个很好的示例。
Callout 回调
转换器也可以添加 Callout 回调。也就是说,它们指示 Stalker 发出指令来调用 JavaScript 函数 - 或普通的 C 回调,例如使用 CModule 实现 – 传递 CPU 上下文和可选的上下文参数。然后,该函数可以随意修改或检查寄存器。该信息存储在 GumCallOutEntry 中。
typedef void (* GumStalkerCallout) (GumCpuContext * cpu_context,
gpointer user_data);
typedef struct _GumCalloutEntry GumCalloutEntry;
struct _GumCalloutEntry
{
GumStalkerCallout callout;
gpointer data;
GDestroyNotify data_destroy;
gpointer pc;
GumExecCtx * exec_context;
};EOB/EOI
回想一下,Relocator 大量参与生成插桩代码。它有两个控制其状态的重要属性。
块结束 (EOB) 表示已到达块的末尾。当我们遇到任何分支指令时就会发生这种情况。分支、调用或返回指令。
输入结束(EOI)表示我们不仅到达了块的末尾,而且还可能到达了输入的末尾,即该指令后面的内容可能不是另一条指令。虽然调用指令的情况并非如此,因为代码控制(通常)会在被调用者返回时传回,因此必须遵循更多指令。 (请注意,编译器通常会生成一条分支指令来调用 exit() 等非返回函数。)虽然不能保证调用指令后面的指令有效,但我们可以针对这种情况进行推测性优化。如果我们遇到非条件分支指令,或者返回指令,很可能后面就没有代码了。
序言/尾声
当控制流从程序重定向到 Stalker 引擎时,必须保存 CPU 的寄存器,以便 Stalker 可以运行并使用寄存器,并在控制权传回程序之前恢复它们,从而不会丢失任何状态。
AArch64 的 过程调用标准 规定某些寄存器(特别是 X19 到 X29)是被调用者保存的寄存器。这意味着当编译器生成使用这些寄存器的代码时,它必须首先存储它们。因此,并不严格需要将这些寄存器保存到上下文结构中,因为如果它们被 Stalker 引擎内的代码使用,它们将会被恢复。这个“最小”上下文足以满足大多数目的。
但是,如果 Stalker 引擎要调用由 Stalker.addCallProbe() 注册的探针,或由 iterator.putCallout() 创建的 Callout 回调(由 Transformer 调用),则这些回调将期望接收完整的 CPU 上下文作为参数。他们希望能够修改此上下文,并在控制权传递回应用程序代码时使更改生效。因此,对于这些实例,我们必须编写一个“完整”上下文,并且其布局必须与结构 GumArm64CpuContext 规定的预期格式匹配。
typedef struct _GumArm64CpuContext GumArm64CpuContext;
struct _GumArm64CpuContext
{
guint64 pc;
guint64 sp; /* X31 */
guint64 x[29];
guint64 fp; /* X29 - frame pointer */
guint64 lr; /* X30 */
guint8 q[128]; /* FPU, NEON (SIMD), CRYPTO regs */
};但请注意,无论哪种情况,写出必要的 CPU 寄存器(序言)所需的代码都相当长(数十条指令)。之后恢复它们的代码(尾声)的长度相似。我们不想在我们检测的每个块的开头和结尾写入这些内容。因此,我们将这些(以与写入插桩块相同的方式)写入公共内存位置,并简单地在每个插桩块的开头和结尾处发出调用指令来调用这些函数。这些常见的内存位置称为“助手”。以下函数创建这些序言和尾声。
static void gum_exec_ctx_write_minimal_prolog_helper (
GumExecCtx * ctx, GumArm64Writer * cw);
static void gum_exec_ctx_write_minimal_epilog_helper (
GumExecCtx * ctx, GumArm64Writer * cw);
static void gum_exec_ctx_write_full_prolog_helper (
GumExecCtx * ctx, GumArm64Writer * cw);
static void gum_exec_ctx_write_full_epilog_helper (
GumExecCtx * ctx, GumArm64Writer * cw);最后,请注意,在 AArch64 架构中,只能在调用者的 ±128 MB 范围内对代码进行直接分支,并且使用间接分支的成本更高(无论是在代码大小还是性能方面)。因此,随着我们编写越来越多的插桩块,我们将离共享的序言和尾声越来越远。如果我们获得的空间超过 128 MB,我们只需写出这些序言和尾声的另一个副本即可使用。这给了我们一个非常合理的权衡。
计数器
最后,有一系列计数器,您可以看到它们不断记录在插桩块末尾遇到的每种类型指令的数量。这些仅由测试套件用于在性能调整期间指导开发人员,指示哪些分支类型最常需要完全上下文切换到 Stalker 来解析目标。
Slab 内存块
现在来看 Stalker 如何在 slab 中存储插桩代码。下面是用于保存这些内容的数据结构:
typedef guint8 GumExecBlockFlags;
typedef struct _GumExecBlock GumExecBlock;
typedef struct _GumSlab GumSlab;
struct _GumExecBlock
{
GumExecCtx * ctx;
GumSlab * slab;
guint8 * real_begin;
guint8 * real_end;
guint8 * real_snapshot;
guint8 * code_begin;
guint8 * code_end;
GumExecBlockFlags flags;
gint recycle_count;
};
struct _GumSlab
{
guint8 * data;
guint offset;
guint size;
GumSlab * next;
guint num_blocks;
GumExecBlock blocks[];
};
enum _GumExecBlockFlags
{
GUM_EXEC_ACTIVATION_TARGET = (1 << 0),
};现在让我们看一下 Stalker 初始化时配置其大小的一些代码:
#define GUM_CODE_SLAB_MAX_SIZE (4 * 1024 * 1024)
#define GUM_EXEC_BLOCK_MIN_SIZE 1024
static void
gum_stalker_init (GumStalker * self)
{
...
self->page_size = gum_query_page_size ();
self->slab_size =
GUM_ALIGN_SIZE (GUM_CODE_SLAB_MAX_SIZE, self->page_size);
self->slab_header_size =
GUM_ALIGN_SIZE (GUM_CODE_SLAB_MAX_SIZE / 12, self->page_size);
self->slab_max_blocks = (self->slab_header_size -
G_STRUCT_OFFSET (GumSlab, blocks)) / sizeof (GumExecBlock);
...
}可以看到,每个 slab 的大小为 4 MB。其中十二分之一留给头部,也就是 GumSlab 结构本身及其 GumExecBlock 数组。这个数组在 GumSlab 结构末尾被定义为零长度数组;slab 头部实际能容纳的元素数量会计算出来,并存入 slab_max_blocks。
那么 slab 的剩余部分有什么用途?slab 头部保存所有管理信息,其余部分(下文称为尾部)则保存插桩指令本身,这些指令以内联形式存储在 slab 中。
为什么把 slab 的十二分之一分配给头部,其余空间分配给指令?待插桩代码块的长度差异很大,还会受编译器及其优化设置影响。粗略的经验测试表明,结合代码块的平均长度,这个比例较为合理:尾部的新插桩块空间与头部的新 GumExecBlock 条目空间大致会同时耗尽,不容易出现一侧先耗尽而另一侧仍大量空闲的情况。
现在让我们看看创建它们的代码:
static GumSlab *
gum_exec_ctx_add_slab (GumExecCtx * ctx)
{
GumSlab * slab;
GumStalker * stalker = ctx->stalker;
slab = gum_memory_allocate (NULL, stalker->slab_size,
stalker->page_size,
stalker->is_rwx_supported ? GUM_PAGE_RWX : GUM_PAGE_RW);
slab->data = (guint8 *) slab + stalker->slab_header_size;
slab->offset = 0;
slab->size = stalker->slab_size - stalker->slab_header_size;
slab->next = ctx->code_slab;
slab->num_blocks = 0;
ctx->code_slab = slab;
return slab;
}在这里,我们可以看到 data 字段指向尾部的开始,可以在标头之后写入指令。 offset 字段跟踪我们到尾部的偏移量。 size 字段跟踪尾部可用的字节总数。 num_blocks 字段跟踪已写入 slab 的插桩块数量。
请注意,在可能的情况下,我们会为板分配 RWX 权限,这样我们就不必一直冻结和解冻它。在支持 RWX 的系统上,冻结和解冻功能变为无操作。
最后,我们可以看到每个slab都包含一个next指针,可用于将slab链接在一起形成单链表。这是为了让我们可以在 Stalker 完成后带走它们并将它们全部处理掉。
代码块
现在我们了解了slab 的工作原理。让我们更详细地了解这些块。众所周知,我们可以在一个slab中存储多个块,并将它们的指令写入到尾部。让我们看一下分配新块的代码:
static GumExecBlock *
gum_exec_block_new (GumExecCtx * ctx)
{
GumStalker * stalker = ctx->stalker;
GumSlab * slab = ctx->code_slab;
gsize available;
available = (slab != NULL) ? slab->size - slab->offset : 0;
if (available >= GUM_EXEC_BLOCK_MIN_SIZE &&
slab->num_blocks != stalker->slab_max_blocks)
{
GumExecBlock * block = slab->blocks + slab->num_blocks;
block->ctx = ctx;
block->slab = slab;
block->code_begin = slab->data + slab->offset;
block->code_end = block->code_begin;
block->flags = 0;
block->recycle_count = 0;
gum_stalker_thaw (stalker, block->code_begin, available);
slab->num_blocks++;
return block;
}
if (stalker->trust_threshold < 0 && slab != NULL)
{
slab->offset = 0;
return gum_exec_block_new (ctx);
}
gum_exec_ctx_add_slab (ctx);
gum_exec_ctx_ensure_inline_helpers_reachable (ctx);
return gum_exec_block_new (ctx);
}该函数首先检查slab尾部是否有用于最小大小块的空间(1024字节),以及slab头中GumExecBlocks数组中是否有空间用于新条目。如果是,则在数组中创建一个新条目,并将其指针设置为引用 GumExecCtx(主 Stalker 会话上下文)和 GumSlab,code_begin 和 code_end 指针都设置为尾部的第一个空闲字节。信任阈值机制用来确定未修改的块被遇到多少次的 recycle_count 被重置为零,并且尾部的其余部分被解冻以允许向其写入代码。
接下来,如果信任阈值设置为小于零(回忆 -1 意味着块从不可信并且总是被重写),那么我们重置slab offset(指向尾部第一个空闲字节的指针)并重新开始。这意味着为slab内的任何块编写的任何插桩代码都将被覆盖。
最后,由于当前的 slab中没有剩余空间,并且我们无法覆盖它,因为信任阈值意味着块可以被重复使用,所以我们必须通过调用gum_exec_ctx_add_slab()来分配一个新的 slab,我们上面已经看到了。然后我们调用 gum_exec_ctx_ensure_inline_helpers_reachable(),稍后会详细介绍,然后我们从新的 slab中分配我们的块。
回想一下,我们使用 helpers(例如保存和恢复 CPU 上下文的序言和尾声)来防止必须在每个块的开头和结尾重复这些指令。由于我们需要能够从正在写入 slab 的插桩代码中调用这些内容,并且我们使用从调用站点只能达到 ±128 MB 的直接分支来执行此操作,因此我们需要确保可以访问它们。如果我们之前没有写过它们,那么我们将它们写入当前的 slab。请注意,这些辅助函数需要从写在slab 尾部的任何插桩指令中访问。因为我们的 slab 大小只有4MB,所以如果我们的助手写在当前的 slab中,那么它们就可以正常访问。如果我们要分配后续的 slab,并且它与前一个slab足够接近(我们只保留最后写入辅助函数的位置),那么我们可能不需要再次写出它们,而只需依赖附近slab中的先前副本。请注意,我们受到 mmap() 的支配,因为我们的 slab在虚拟内存中分配的位置,并且 ASLR 可能指示我们的 slab最终与前一个板相差甚远。
我们只能假设这不太可能成为问题,或者这已被考虑到板的大小中,以确保将助手写入每个板不会产生太大的开销,因为它不会占用很大比例的空间。另一种方法是在每次编写辅助函数时存储每个位置,以便我们有更多候选位置可供选择(也许我们的 slab没有分配在之前分配的位置附近,但也许它与其他位置足够接近)。否则,我们可以考虑使用 mmap() 创建一个自定义分配器来保留一大片(例如 128 MB)虚拟地址空间区域,然后再次使用 mmap() 根据需要一次提交一个内存块。但这些想法或许都有些矫枉过正。
对代码块进行插桩
插桩代码块的主要函数称为 gum_exec_ctx_obtain_block_for()。它首先在哈希表中查找现有块,该块在所检测的原始块的地址上进行索引。如果它找到一个并且满足上述关于信任阈值的约束,那么它可以简单地被返回。
GumExecBlock 的字段使用如下。 real_begin 设置为要检测的原始代码块的开头。 code_begin 字段指向尾部的第一个空闲字节(记住这是由上面讨论的 gum_exec_block_new() 函数设置的)。 GumArm64Relocator 被初始化为从 real_begin 处的原始代码读取代码,GumArm64Writer 被初始化为将其输出写入从 code_begin 开始的板。这些项目中的每一个都被打包到 GumGeneratorContext 中,最后用于构造 GumStalkerIterator。
然后将该迭代器传递给转换器。回想一下默认实现如下:
static void
gum_default_stalker_transformer_transform_block (
GumStalkerTransformer * transformer,
GumStalkerIterator * iterator,
GumStalkerOutput * output)
{
while (gum_stalker_iterator_next (iterator, NULL))
{
gum_stalker_iterator_keep (iterator);
}
}我们暂时忽略 gum_stalker_iterator_next() 和 gum_stalker_iterator_keep() 的细节。但本质上,这会导致迭代器从重定位器一次读取一条指令,并使用写入器写出重定位的指令。在此过程之后,可以更新 GumExecBlock 结构。其字段real_end可设置为重定位器读取到的地址,其字段code_end可设置为写入器写入到的地址。因此,real_begin 和 real_end 标记原始块的限制,而 code_begin 和 code_end 标记新插桩块的限制。最后,gum_exec_ctx_obtain_block_for() 调用 gum_exec_block_commit(),它获取原始块的副本并将其放置在检测后的副本之后。字段 real_snapshot 指向此(因此与 code_end 相同)。接下来,slab 的 offset 字段将被更新,以反映我们的插桩块和原始代码副本所使用的空间。最后,该块被冻结以允许其执行。
static void
gum_exec_block_commit (GumExecBlock * block)
{
gsize code_size, real_size;
code_size = block->code_end - block->code_begin;
block->slab->offset += code_size;
real_size = block->real_end - block->real_begin;
block->real_snapshot = block->code_end;
memcpy (block->real_snapshot, block->real_begin, real_size);
block->slab->offset += real_size;
gum_stalker_freeze (block->ctx->stalker, block->code_begin,
code_size);
}现在让我们回到函数 gum_exec_ctx_obtain_block_for() 的更多细节。首先我们应该注意到每个块都有一个前缀指令。
gum_arm64_writer_put_ldp_reg_reg_reg_offset (cw, ARM64_REG_X16,
ARM64_REG_X17, ARM64_REG_SP, 16 + GUM_RED_ZONE_SIZE,
GUM_INDEX_POST_ADJUST);该指令是恢复序言(记为GUM_RESTORATION_PROLOG_SIZE)。这在“引导”用法中被跳过 - 因此您会注意到,在返回插桩代码的地址时,_gum_stalker_do_follow_me() 和 gum_stalker_infect() 添加了该常量。然而,当检测返回指令时,如果返回的是已经检测过的块,那么我们可以简单地返回到该块,而不是返回到 Stalker 引擎。该代码由gum_exec_block_write_ret_transfer_code()编写。在最坏的情况下,我们可能需要使用寄存器来执行到插桩块的最终分支,该函数将它们存储到堆栈中,并且从堆栈恢复这些的代码在块本身中添加前缀。因此,如果我们可以直接返回到插桩块,我们将返回到第一条指令,而不是跳过 GUM_RESTORATION_PROLOG_SIZE 字节。
其次,我们可以看到 gum_exec_ctx_obtain_block_for() 在写入插桩块后执行以下操作:
gum_arm64_writer_put_brk_imm (cw, 14);这会插入一条中断指令,旨在简化调试。
最后,如果 Stalker 配置为,gum_exec_ctx_obtain_block_for() 将在编译块时生成 GUM_COMPILE 类型的事件。
辅助函数
从gum_exec_ctx_ensure_inline_helpers_reachable()中我们可以看到我们一共有6个助手。这些助手是我们的插桩块重复需要的常见代码片段。我们不是重复发出它们包含的代码,而是编写一次并放置调用或分支指令以使我们的插桩代码执行它。回想一下,助手被写入我们正在编写插桩代码的同一个slab中,如果可能的话,我们可以重复使用写入附近之前的 slab中的助手,而不是在每个slab中放置一个副本。
此函数为每个助手调用 gum_exec_ctx_ensure_helper_reachable(),然后调用 gum_exec_ctx_is_helper_reachable() 来检查助手是否在范围内,或者调用作为第二个参数传递的回调来写出新副本。
static void
gum_exec_ctx_ensure_inline_helpers_reachable (GumExecCtx * ctx)
{
gum_exec_ctx_ensure_helper_reachable (ctx,
&ctx->last_prolog_minimal,
gum_exec_ctx_write_minimal_prolog_helper);
gum_exec_ctx_ensure_helper_reachable (ctx,
&ctx->last_epilog_minimal,
gum_exec_ctx_write_minimal_epilog_helper);
gum_exec_ctx_ensure_helper_reachable (ctx,
&ctx->last_prolog_full,
gum_exec_ctx_write_full_prolog_helper);
gum_exec_ctx_ensure_helper_reachable (ctx,
&ctx->last_epilog_full,
gum_exec_ctx_write_full_epilog_helper);
gum_exec_ctx_ensure_helper_reachable (ctx,
&ctx->last_stack_push,
gum_exec_ctx_write_stack_push_helper);
gum_exec_ctx_ensure_helper_reachable (ctx,
&ctx->last_stack_pop_and_go,
gum_exec_ctx_write_stack_pop_and_go_helper);
}那么,我们的6个帮手是什么呢?我们有 2 个用于编写存储寄存器上下文的序言,一个用于完整上下文,一个用于最小上下文。我们稍后会介绍这些。我们还有 2 个对应的尾声用于恢复寄存器。另外两个 last_stack_push 和 last_stack_pop_and_go 在检测调用指令时使用。
在详细分析这两者之前,我们首先需要了解一下框架结构。从下面的代码片段中我们可以看到,我们分配了一个页面来包含GumExecFrame结构。这些结构像数组一样顺序存储在页面中,并从页面末尾的条目开始填充。每个帧都包含原始块的地址和我们生成的用于替换它的插桩块的地址:
typedef struct _GumExecFrame GumExecFrame;
typedef struct _GumExecCtx GumExecCtx;
struct _GumExecFrame
{
gpointer real_address;
gpointer code_address;
};
struct _GumExecCtx
{
...
GumExecFrame * current_frame;
GumExecFrame * first_frame;
GumExecFrame * frames;
...
};
static GumExecCtx *
gum_stalker_create_exec_ctx (GumStalker * self,
GumThreadId thread_id,
GumStalkerTransformer * transformer,
GumEventSink * sink)
{
...
ctx->frames = gum_memory_allocate (
NULL, self->page_size, self->page_size, GUM_PAGE_RW);
ctx->first_frame = (GumExecFrame *) ((guint8 *) ctx->frames +
self->page_size - sizeof (GumExecFrame));
ctx->current_frame = ctx->first_frame;
...
return ctx;
}last_stack_push
理解 Stalker 和助手的复杂性主要在于一些函数(我们称之为编写器)编写稍后执行的代码。这些编写者本身有分支,它们准确地确定要编写什么代码,并且编写的代码有时也可以有分支。因此,我对这两个助手采取的方法是显示程序集的伪代码,该伪代码被发送到将由插桩块调用的 slab 中。
该助手的伪代码如下所示:
void
last_stack_push_helper (gpointer x0,
gpointer x1)
{
GumExecFrame ** x16 = &ctx->current_frame
GumExecFrame * x17 = *x16
gpointer x2 = x17 & (ctx->stalker->page_size - 1)
if x2 != 0:
x17--
x17->real_address = x0
x17->code_address = x1
*x16 = x17
return
}正如我们所看到的,这个助手实际上是一个简单的函数,它接受两个参数,real_address 和 code_address 来存储在下一个 GumExecFrame 结构中。请注意,我们的堆栈是从存储它们的页面末尾向后写入的,并且 current_frame 指向最后使用的条目(因此我们的堆栈已满且降序)。另请注意,我们有一个条件检查,看看我们是否位于最后一个条目(页面最开头的条目将是页面对齐的),如果我们已经没有空间容纳更多条目(我们有空间容纳 512),那么我们什么也不做。如果有空间,我们将参数中的值写入条目并延迟 current_frame 指针指向它。
当虚拟化调用指令时使用此助手。虚拟化是指令替换的名称,通常是与使用一系列指令进行分支相关的指令,这些指令不是执行预期的块,而是允许 Stalker 管理控制流。回想一下,当我们的转换器使用迭代器遍历指令并调用 iterator.keep() 时,我们输出转换后的指令。当我们遇到分支时,我们需要发出代码来回调 Stalker 引擎,以便它可以检测该块,但如果分支语句是调用指令(BL、BLX 等),我们还需要发出对上述帮助程序的调用来存储堆栈帧信息。该信息在发出调用事件时以及稍后优化返回时使用。
last_stack_pop_and_go
现在让我们看看 last_stack_pop_and_go 帮助程序。为了理解这一点,我们还需要理解 gum_exec_block_write_ret_transfer_code() 编写的代码(调用它的代码),以及它调用的 gum_exec_block_write_exec_generated_code() 编写的代码。我们现在将跳过指针身份验证。
void
ret_transfer_code (arm64_reg ret_reg)
{
gpointer x16 = ret_reg
goto last_stack_pop_and_go_helper
}
void
last_stack_pop_and_go_helper (gpointer x16)
{
GumExecFrame ** x0 = &ctx->current_frame
GumExecFrame * x1 = *x0
gpointer x17 = x0.real_address
if x17 == x16:
x17 = x0->code_address
x1++
*x0 = x1
goto x17
else:
x1 = ctx->first_frame
*x0 = x1
gpointer * x0 = &ctx->return_at
*x0 = x16
last_prologue_minimal()
x0 = &ctx->return_at
x1 = *x0
gum_exec_ctx_replace_current_block_from_ret(ctx, x1)
last_epilogue_minimal()
goto exec_generated_code
}
void
exec_generated_code (void)
{
gpointer * x16 = &ctx->resume_at
gpointer x17 = *x16
goto x17
}所以这段代码有点难。它并不是真正的函数,并且由它编写的实际程序集因保存和恢复寄存器的需要而有些混乱。但其本质是这样的:当虚拟化返回指令时,该帮助器用于优化将控制权传递回调用者。 ret_reg 包含我们要返回的块的地址。
我们看一下返回指令的定义:
视网膜色素变性 从子程序返回,无条件分支到某个地址 在寄存器中,并提示这是一个子程序返回。
RET {Xn} 在哪里:
Xn 是保存通用寄存器的 64 位名称 要分支到的地址,范围为 0 到 31。默认为 如果不存在则 X30。
正如我们所看到的,我们将返回到寄存器中传递的地址。通常,我们可以预测寄存器值以及我们将返回到的位置,因为编译器将发出汇编代码,以便寄存器被设置为紧随调用后的指令地址。发出检测调用后,我们直接在一个小着陆垫后发出,它将回调到 Stalker 来对下一个块进行插桩。这个着陆垫稍后可以进行回填(如果条件合适)以避免完全重新进入Stalker。我们将调用后的原始块的地址和该着陆垫的地址存储在 GumExecFrame 结构中,因此我们可以通过将返回指令替换为简单分支到该着陆垫的指令来简单地虚拟化返回指令。我们不需要每次看到返回指令时都重新进入 Stalker 引擎并获得不错的性能提升。简单的!
但是,我们必须记住,并非所有调用都会导致返回。恶意代码或专用代码的常见技术是进行调用,以便使用 LR 来确定指令指针的当前位置。然后,该值可用于自省目的(例如,验证代码以检测修改、解密或解密代码等)。
另外,请记住,用户可以使用自定义转换来修改他们认为合适的指令,他们可以插入修改寄存器值的指令,或者可能是传递上下文结构的Callout 回调函数,该函数允许他们根据需要修改寄存器值。现在考虑一下如果他们修改了返回寄存器中的值会怎样!
因此我们可以看到,帮助程序根据 GumExecFrame 中存储的 real_address 的值检查返回寄存器的值。如果匹配,那么一切都很好,我们可以直接分支回着陆垫。回想一下第一个实例,这只是重新进入 Stalker 来对下一个块进行插桩并分支到它,但稍后可以使用回填来直接分支到这个检测的块并完全避免重新进入 Stalker。
否则,我们就会走另一条路。首先,GumExecFrame 的数组被清除,现在我们的控制流程已经偏离,我们将重新开始构建我们的堆栈。我们承认,如果我们返回到我们迄今为止记录的调用堆栈中的任何先前帧,我们将采用相同的较慢路径,但有可能对我们从现在开始遇到的新调用使用快速路径(直到下一次以非常规方式使用调用指令)。
我们制作一个最简单的序言(我们的插桩代码现在必须重新进入 Stalker),并且我们需要能够在将控制权返回给应用程序之前恢复应用程序的寄存器。我们将返回的入口称为 gum_exec_ctx_replace_current_block_from_ret()(稍后将详细介绍入口)。然后,我们在分支到 ctx->resume_at 指针之前执行相应的尾声,该指针在上述对 gum_exec_ctx_replace_current_block_from_ret() 的调用期间由 Stalker 设置以指向新的插桩块。
上下文
现在让我们看一下序言和尾声。
static void
gum_exec_ctx_write_prolog (GumExecCtx * ctx,
GumPrologType type,
GumArm64Writer * cw)
{
gpointer helper;
helper = (type == GUM_PROLOG_MINIMAL)
? ctx->last_prolog_minimal
: ctx->last_prolog_full;
gum_arm64_writer_put_stp_reg_reg_reg_offset (cw, ARM64_REG_X19,
ARM64_REG_LR, ARM64_REG_SP, -(16 + GUM_RED_ZONE_SIZE),
GUM_INDEX_PRE_ADJUST);
gum_arm64_writer_put_bl_imm (cw, GUM_ADDRESS (helper));
}
static void
gum_exec_ctx_write_epilog (GumExecCtx * ctx,
GumPrologType type,
GumArm64Writer * cw)
{
gpointer helper;
helper = (type == GUM_PROLOG_MINIMAL)
? ctx->last_epilog_minimal
: ctx->last_epilog_full;
gum_arm64_writer_put_bl_imm (cw, GUM_ADDRESS (helper));
gum_arm64_writer_put_ldp_reg_reg_reg_offset (cw, ARM64_REG_X19,
ARM64_REG_X20, ARM64_REG_SP, 16 + GUM_RED_ZONE_SIZE,
GUM_INDEX_POST_ADJUST);
}我们可以看到,除了调用相应的序言或尾声助手之外,它们几乎没有做任何事情。我们可以看到序言将 X19 和链接寄存器存储到堆栈中。然后在尾声末尾将它们恢复到 X19 和 X20 中。 这是因为需要 X19 作为临时空间来写入上下文块,并且需要保留链接寄存器,因为它将被对帮助程序的调用破坏。
LDP 和 STP 指令分别加载和存储一对寄存器,并可以选择递增或递减堆栈指针。该递增或递减可以在加载或存储值之前或之后执行。
另请注意这些寄存器放置的偏移量。它们存储在堆栈顶部之外的 16 字节 + GUM_RED_ZONE_SIZE 处。请注意,我们在 AArch64 上的堆栈已满并且是降序的。这意味着堆栈向低地址增长,堆栈指针指向最后压入的项目(而不是下一个空白空间)。因此,如果我们从堆栈指针中减去 16 个字节,那么这就为我们提供了足够的空间来存储两个 64 位寄存器。请注意,堆栈指针必须在存储之前递减(预递减)并在加载之后递增(后递增)。
那么GUM_RED_ZONE_SIZE是什么? redzone 是超出堆栈指针的 128 字节区域,函数可使用它来存储临时变量。这允许函数将数据存储在堆栈中,而无需一直调整堆栈指针。请注意,对序言的调用可能是在我们的插桩块中执行的第一件事,我们不知道应用程序代码在 redzone 中存储了哪些局部变量,因此我们必须确保在开始使用堆栈为 Stalker 引擎存储信息之前将堆栈指针推进到它之外。
上下文辅助函数
现在我们已经了解了如何调用这些助手,现在让我们看看助手本身。尽管有两个序言和两个尾声(完整的和简短的),但它们都是由相同的功能编写的,因为它们有很多共同点。编写的版本基于函数参数。呈现这些的最简单方法是使用带注释的代码:
static void
gum_exec_ctx_write_prolog_helper (GumExecCtx * ctx,
GumPrologType type,
GumArm64Writer * cw)
{
// Keep track of how much we are pushing onto the stack since we
// will want to store in the exec context where the original app
// stack was. At present the call to our helper already skipped
// the red zone and stored LR and X19.
gint immediate_for_sp = 16 + GUM_RED_ZONE_SIZE;
// This instruction is used to store the CPU flags into X15.
const guint32 mrs_x15_nzcv = 0xd53b420f;
// Note that only the full prolog has to look like the C struct
// definition, since this is the data structure passed to
// callouts and the like.
// Save Return address to our instrumented block in X19. We will
// preserve this throughout and branch back there at the end.
// This will take us back to the code written by
// gum_exec_ctx_write_prolog()
gum_arm64_writer_put_mov_reg_reg (cw, ARM64_REG_X19, ARM64_REG_LR);
// LR = SP[8] Save return address of previous block (or user-code)
// in LR. This was pushed there by the code written by
// gum_exec_ctx_write_prolog(). This is the one which will remain in
// LR once we have returned to our instrumented code block. Note
// the use of SP+8 is a little asymmetric on entry (prolog) as it is
// used to pass LR. On exit (epilog) it is used to pass X20
// and accordingly gum_exec_ctx_write_epilog() restores it there.
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
ARM64_REG_LR, ARM64_REG_SP, 8);
// Store SP[8] = X20. We have read the value of LR which was put
// there by gum_exec_ctx_write_prolog() and are writing X20 there
// so that it can be restored by code written by
// gum_exec_ctx_write_epilog()
gum_arm64_writer_put_str_reg_reg_offset (cw,
ARM64_REG_X20, ARM64_REG_SP, 8);
if (type == GUM_PROLOG_MINIMAL)
{
// Store all of the FP/NEON registers. NEON is the SIMD engine
// on the ARM core which allows operations to be carried out
// on multiple inputs at once.
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_Q6, ARM64_REG_Q7);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_Q4, ARM64_REG_Q5);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_Q2, ARM64_REG_Q3);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_Q0, ARM64_REG_Q1);
immediate_for_sp += 4 * 32;
// X29 is Frame Pointer
// X30 is the Link Register
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X29, ARM64_REG_X30);
// We are using STP here to push pairs of registers. We actually
// have an odd number to push, so we just push STALKER_REG_CTX
// as padding to make up the numbers
/* X19 - X28 are callee-saved registers */
// If we are only calling compiled C code, then the compiler
// will ensure that should a function use registers X19
// through X28 then their values will be preserved. Hence,
// we don't need to store them here as they will not be
// modified. If however, we make a callout, then we want
// the Stalker end user to have visibility of the full
// register set and to be able to make any modifications
// they see fit to them.
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X18, ARM64_REG_X30);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X16, ARM64_REG_X17);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X14, ARM64_REG_X15);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X12, ARM64_REG_X13);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X10, ARM64_REG_X11);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X8, ARM64_REG_X9);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X6, ARM64_REG_X7);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X4, ARM64_REG_X5);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X2, ARM64_REG_X3);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X0, ARM64_REG_X1);
immediate_for_sp += 11 * 16;
}
else if (type == GUM_PROLOG_FULL)
{
/* GumCpuContext.q[128] */
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_Q6, ARM64_REG_Q7);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_Q4, ARM64_REG_Q5);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_Q2, ARM64_REG_Q3);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_Q0, ARM64_REG_Q1);
/* GumCpuContext.x[29] + fp + lr + padding */
// X29 is Frame Pointer
// X30 is the Link Register
// X15 is pushed just for padding again
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X30, ARM64_REG_X15);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X28, ARM64_REG_X29);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X26, ARM64_REG_X27);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X24, ARM64_REG_X25);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X22, ARM64_REG_X23);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X20, ARM64_REG_X21);
// Store X19 (currently holding the LR value for this function
// to return to, the address of the caller written by
// gum_exec_ctx_write_prolog()) in X20 temporarily. We have
// already pushed X20 so we can use it freely, but we want to
// push the app's value of X19 into the context. This was
// pushed onto the stack by the code in
// gum_exec_ctx_write_prolog() so we can restore it from there
// before we push it.
gum_arm64_writer_put_mov_reg_reg (cw,
ARM64_REG_X20, ARM64_REG_X19);
// Restore X19 from the value pushed by the prolog before the
// call to the helper.
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
ARM64_REG_X19, ARM64_REG_SP,
(6 * 16) + (4 * 32));
// Push the app's values of X18 and X19. X18 was unmodified. We
// have corrected X19 above.
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X18, ARM64_REG_X19);
// Restore X19 from X20
gum_arm64_writer_put_mov_reg_reg (cw,
ARM64_REG_X19, ARM64_REG_X20);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X16, ARM64_REG_X17);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X14, ARM64_REG_X15);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X12, ARM64_REG_X13);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X10, ARM64_REG_X11);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X8, ARM64_REG_X9);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X6, ARM64_REG_X7);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X4, ARM64_REG_X5);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X2, ARM64_REG_X3);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X0, ARM64_REG_X1);
/* GumCpuContext.pc + sp */
// We are going to store the PC and SP here. The PC is set to
// zero, for the SP, we have to calculate the original SP
// before we stored all of this context information. Note we
// use the zero register here (a special register in AArch64
// which always has the value 0).
gum_arm64_writer_put_mov_reg_reg (cw,
ARM64_REG_X0, ARM64_REG_XZR);
gum_arm64_writer_put_add_reg_reg_imm (cw,
ARM64_REG_X1, ARM64_REG_SP,
(16 * 16) + (4 * 32) + 16 + GUM_RED_ZONE_SIZE);
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X0, ARM64_REG_X1);
immediate_for_sp += sizeof (GumCpuContext) + 8;
}
// Store the Arithmetic Logic Unit flags into X15. Whilst it might
// appear that the above add instruction used to calculate the
// original stack pointer may have changed the flags, AArch64 has
// an ADD instruction which doesn't modify the condition flags
// and an ADDS instruction which does.
gum_arm64_writer_put_instruction (cw, mrs_x15_nzcv);
/* conveniently point X20 at the beginning of the saved
registers */
// X20 is used later by functions such as
// gum_exec_ctx_load_real_register_from_full_frame_into() to emit
// code which references the saved frame.
gum_arm64_writer_put_mov_reg_reg (cw, ARM64_REG_X20, ARM64_REG_SP);
/* padding + status */
// This pushes the flags to ensure that they can be restored
// correctly after executing inside of Stalker.
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X14, ARM64_REG_X15);
immediate_for_sp += 1 * 16;
// We saved our LR into X19 on entry so that we could branch back
// to the instrumented code once this helper has run. Although
// the instrumented code called us, we restored LR to its previous
// value before the helper was called (the app code). Although the
// LR is not callee-saved (e.g. it is not our responsibility to
// save and restore it on return, but rather that of our caller),
// it is done here to minimize the code size of the inline stub in
// the instrumented block.
gum_arm64_writer_put_br_reg_no_auth (cw, ARM64_REG_X19);
}现在让我们看一下尾声:
static void
gum_exec_ctx_write_epilog_helper (GumExecCtx * ctx,
GumPrologType type,
GumArm64Writer * cw)
{
// This instruction is used to restore the value of X15 back into
// the ALU flags.
const guint32 msr_nzcv_x15 = 0xd51b420f;
/* padding + status */
// Note that we don't restore the flags yet, since we must wait
// until we have finished all operations (e.g. additions,
// subtractions etc) which may modify the flags. However, we
// must do so before we restore X15 back to its original value.
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X14, ARM64_REG_X15);
if (type == GUM_PROLOG_MINIMAL)
{
// Save the LR in X19 so we can return back to our caller in the
// instrumented block. Note that we must restore the link
// register X30 back to its original value (the block in the app
// code) before we return. This is carried out below. Recall our
// value of X19 is saved to the stack by the inline prolog
// itself and restored by the inline prolog to which we are
// returning. So we can continue to use it as scratch space
// here.
gum_arm64_writer_put_mov_reg_reg (cw,
ARM64_REG_X19, ARM64_REG_LR);
/* restore status */
// We have completed all of our instructions which may alter the
// flags.
gum_arm64_writer_put_instruction (cw, msr_nzcv_x15);
// Restore all of the registers we saved in the context. We
// pushed X30 earlier as padding, but we will
// pop it back there before we pop the actual pushed value
// of X30 immediately after.
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X0, ARM64_REG_X1);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X2, ARM64_REG_X3);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X4, ARM64_REG_X5);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X6, ARM64_REG_X7);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X8, ARM64_REG_X9);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X10, ARM64_REG_X11);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X12, ARM64_REG_X13);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X14, ARM64_REG_X15);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X16, ARM64_REG_X17);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X18, ARM64_REG_X30);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X29, ARM64_REG_X30);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_Q0, ARM64_REG_Q1);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_Q2, ARM64_REG_Q3);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_Q4, ARM64_REG_Q5);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_Q6, ARM64_REG_Q7);
}
else if (type == GUM_PROLOG_FULL)
{
/* GumCpuContext.pc + sp */
// We stored the stack pointer and PC in the stack, but we don't
// want to restore the PC back to the user code, and our stack
// pointer should be naturally restored as all of the data
// pushed onto it are popped back off.
gum_arm64_writer_put_add_reg_reg_imm (cw,
ARM64_REG_SP, ARM64_REG_SP, 16);
/* restore status */
// Again, we have finished any flag affecting operations now that the
// above addition has been completed.
gum_arm64_writer_put_instruction (cw, msr_nzcv_x15);
/* GumCpuContext.x[29] + fp + lr + padding */
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X0, ARM64_REG_X1);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X2, ARM64_REG_X3);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X4, ARM64_REG_X5);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X6, ARM64_REG_X7);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X8, ARM64_REG_X9);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X10, ARM64_REG_X11);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X12, ARM64_REG_X13);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X14, ARM64_REG_X15);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X16, ARM64_REG_X17);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X18, ARM64_REG_X19);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X20, ARM64_REG_X21);
// Recall that X19 and X20 are actually restored by the epilog
// itself since X19 is used as scratch space during the
// prolog/epilog helpers and X20 is repurposed by the prolog as
// a pointer to the context structure. If we have a full prolog
// then this means that it was so that we could enter a callout
// which allows the Stalker end user to inspect and modify all
// of the registers. This means that any changes to the
// registers in the context structure above must be reflected
// at runtime. Thus since these values are restored from
// higher up the stack by the epilog, we must overwrite their
// values there with those from the context structure.
gum_arm64_writer_put_stp_reg_reg_reg_offset (cw, ARM64_REG_X19,
ARM64_REG_X20, ARM64_REG_SP, (5 * 16) + (4 * 32),
GUM_INDEX_SIGNED_OFFSET);
// Save the LR in X19 so we can return back to our caller in the
// instrumented code. Note that we must restore the link
// register X30 back to its original value before we return.
// This is carried out below. Recall our value of X19 is saved
// to the stack by the inline prolog itself and restored by the
// inline epilogue to which we are returning.
gum_arm64_writer_put_mov_reg_reg (cw,
ARM64_REG_X19, ARM64_REG_LR);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X22, ARM64_REG_X23);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X24, ARM64_REG_X25);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X26, ARM64_REG_X27);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X28, ARM64_REG_X29);
// Recall that X15 was also pushed as padding alongside X30 when
// building the prolog. However, the Stalker end user can modify
// the context and hence the value of X15. However this would
// not affect the duplicate stashed here as padding and hence
// X15 would be clobbered. Therefore we copy the now restored
// value of X15 to the location where this copy was stored for
// padding before restoring both registers from the stack.
gum_arm64_writer_put_str_reg_reg_offset (cw,
ARM64_REG_X15, ARM64_REG_SP, 8);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X30, ARM64_REG_X15);
/* GumCpuContext.q[128] */
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_Q0, ARM64_REG_Q1);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_Q2, ARM64_REG_Q3);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_Q4, ARM64_REG_Q5);
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_Q6, ARM64_REG_Q7);
}
// Now we can return back to to our caller (the inline part of the
// epilogue) with the LR still set to the original value of the
// app code.
gum_arm64_writer_put_br_reg_no_auth (cw, ARM64_REG_X19)
}这一切都相当复杂。部分原因是我们只有一个寄存器用作暂存空间,部分原因是我们希望将插桩块中内联存储的序言和尾声代码保持在最低限度,部分原因是我们的上下文值可以通过 Callout 回调等进行更改。但希望现在这一切都有意义。
读取/写入上下文
现在我们已经保存了上下文,无论是完整的上下文还是最小的上下文,Stalker 可能需要从上下文中读取寄存器以查看应用程序代码的状态。例如,找到分支或返回指令要分支到的地址,以便我们可以检测该块。
当 Stalker 编写序言和尾声代码时,它是通过调用 gum_exec_block_open_prolog() 和 gum_exec_block_close_prolog() 来完成的。这些存储了gc->opened_prolog中写入的序言类型。
static void
gum_exec_block_open_prolog (GumExecBlock * block,
GumPrologType type,
GumGeneratorContext * gc)
{
if (gc->opened_prolog >= type)
return;
/* We don't want to handle this case for performance reasons */
g_assert (gc->opened_prolog == GUM_PROLOG_NONE);
gc->opened_prolog = type;
gum_exec_ctx_write_prolog (block->ctx, type, gc->code_writer);
}
static void
gum_exec_block_close_prolog (GumExecBlock * block,
GumGeneratorContext * gc)
{
if (gc->opened_prolog == GUM_PROLOG_NONE)
return;
gum_exec_ctx_write_epilog (block->ctx, gc->opened_prolog,
gc->code_writer);
gc->opened_prolog = GUM_PROLOG_NONE;
}因此,当我们想要读取寄存器时,可以通过单个函数gum_exec_ctx_load_real_register_into()来实现。这确定了正在使用哪种序言并相应地调用相关例程。请注意,这些例程实际上并不读取寄存器,它们发出读取寄存器的代码。
static void
gum_exec_ctx_load_real_register_into (GumExecCtx * ctx,
arm64_reg target_register,
arm64_reg source_register,
GumGeneratorContext * gc)
{
if (gc->opened_prolog == GUM_PROLOG_MINIMAL)
{
gum_exec_ctx_load_real_register_from_minimal_frame_into (ctx,
target_register, source_register, gc);
return;
}
else if (gc->opened_prolog == GUM_PROLOG_FULL)
{
gum_exec_ctx_load_real_register_from_full_frame_into (ctx,
target_register, source_register, gc);
return;
}
g_assert_not_reached ();
}从全帧读取寄存器实际上是最简单的。我们可以看到代码与用于将上下文传递给 Callout 回调等的结构紧密匹配。请记住,在每种情况下,寄存器 X20 都指向上下文结构的基址。
typedef GumArm64CpuContext GumCpuContext;
struct _GumArm64CpuContext
{
guint64 pc;
guint64 sp;
guint64 x[29];
guint64 fp;
guint64 lr;
guint8 q[128];
};
static void
gum_exec_ctx_load_real_register_from_full_frame_into (
GumExecCtx * ctx,
arm64_reg target_register,
arm64_reg source_register,
GumGeneratorContext * gc)
{
GumArm64Writer * cw;
cw = gc->code_writer;
if (source_register >= ARM64_REG_X0 &&
source_register <= ARM64_REG_X28)
{
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
target_register, ARM64_REG_X20,
G_STRUCT_OFFSET (GumCpuContext, x) +
((source_register - ARM64_REG_X0) * 8));
}
else if (source_register == ARM64_REG_X29)
{
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
target_register, ARM64_REG_X20,
G_STRUCT_OFFSET (GumCpuContext, fp));
}
else if (source_register == ARM64_REG_X30)
{
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
target_register, ARM64_REG_X20,
G_STRUCT_OFFSET (GumCpuContext, lr));
}
else
{
gum_arm64_writer_put_mov_reg_reg (cw,
target_register, source_register);
}
}从最小的上下文中阅读实际上有点困难。 X0 到 X18 很简单,它们存储在上下文块中。 X18 之后是 8 个字节填充(总共 10 对寄存器),后面是 X29 和 X30。这样总共有 11 对寄存器。紧接着是 NEON/浮点寄存器(总共 128 字节)。最后,X19 和 X20 存储在上面,因为它们是由 gum_exec_ctx_write_epilog() 编写的内联尾声代码恢复的。
static void
gum_exec_ctx_load_real_register_from_minimal_frame_into (
GumExecCtx * ctx,
arm64_reg target_register,
arm64_reg source_register,
GumGeneratorContext * gc)
{
GumArm64Writer * cw;
cw = gc->code_writer;
if (source_register >= ARM64_REG_X0 &&
source_register <= ARM64_REG_X18)
{
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
target_register, ARM64_REG_X20,
(source_register - ARM64_REG_X0) * 8);
}
else if (source_register == ARM64_REG_X19 ||
source_register == ARM64_REG_X20)
{
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
target_register, ARM64_REG_X20,
(11 * 16) + (4 * 32) +
((source_register - ARM64_REG_X19) * 8));
}
else if (source_register == ARM64_REG_X29 ||
source_register == ARM64_REG_X30)
{
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
target_register, ARM64_REG_X20,
(10 * 16) + ((source_register - ARM64_REG_X29) * 8));
}
else
{
gum_arm64_writer_put_mov_reg_reg (cw,
target_register, source_register);
}
}控制流
Stalker 的执行从 3 个入口点之一开始:
_gum_stalker_do_follow_me()gum_stalker_infect()gum_exec_ctx_replace_current_block_with()
前两个我们已经介绍过,它们初始化 Stalker 引擎并开始检测第一个执行块。 gum_exec_ctx_replace_current_block_with() 用于检测后续块。事实上,这个函数与前两个函数的主要区别在于Stalker引擎已经被初始化,因此不需要重复这项工作。所有三个都调用 gum_exec_ctx_obtain_block_for() 来生成插桩块。
我们之前在转换器部分介绍过 gum_exec_ctx_obtain_block_for()。它调用正在使用的转换实现,默认情况下调用 gum_stalker_iterator_next(),后者使用 gum_arm64_relocator_read_one() 调用重定位器来读取下一个重定位指令。然后它调用 gum_stalker_iterator_keep() 来生成已插桩的副本。它循环执行此操作,直到 gum_stalker_iterator_next() 返回 FALSE(因为它已到达块的末尾)。
大多数时候,gum_stalker_iterator_keep() 将简单地调用 gum_arm64_relocator_write_one() 来按原样发出重定位指令。但是,如果指令是分支或返回指令,它将分别调用 gum_exec_block_virtualize_branch_insn() 或 gum_exec_block_virtualize_ret_insn()。我们稍后将更详细地介绍这两个虚拟化函数,它们发出代码,通过入口门将控制权转移回 gum_exec_ctx_replace_current_block_with(),准备处理下一个块(除非有一个优化,我们可以绕过 Stalker 并直接进入下一个插桩块,或者我们正在进入排除范围)。
门
入口门由宏生成,一个入口门对应于块末尾的每种不同指令类型。当我们虚拟化每种类型的指令时,我们通过这些门之一将控制流引导回 gum_exec_ctx_replace_current_block_with() 函数。我们可以看到门的实现非常简单,它更新一个已调用次数的计数器,并将控制传递给 gum_exec_ctx_replace_current_block_with(),传递调用它的参数,即下一个待插桩代码块的 GumExecCtx 和 start_address。
static gboolean counters_enabled = FALSE;
static guint total_transitions = 0;
#define GUM_ENTRYGATE(name) \
gum_exec_ctx_replace_current_block_from_##name
#define GUM_DEFINE_ENTRYGATE(name) \
static guint total_##name##s = 0; \
\
static gpointer GUM_THUNK \
GUM_ENTRYGATE (name) ( \
GumExecCtx * ctx, \
gpointer start_address) \
{ \
if (counters_enabled) \
total_##name##s++; \
\
return gum_exec_ctx_replace_current_block_with (ctx, \
start_address); \
}
#define GUM_PRINT_ENTRYGATE_COUNTER(name) \
g_printerr ("\t" G_STRINGIFY (name) "s: %u\n", total_##name##s)这些计数器可以通过以下例程显示。它们仅供测试套件使用,而不是通过 API 暴露给用户。
#define GUM_PRINT_ENTRYGATE_COUNTER(name) \
g_printerr ("\t" G_STRINGIFY (name) "s: %u\n", total_##name##s)
void
gum_stalker_dump_counters (void)
{
g_printerr ("\n\ntotal_transitions: %u\n", total_transitions);
GUM_PRINT_ENTRYGATE_COUNTER (call_imm);
GUM_PRINT_ENTRYGATE_COUNTER (call_reg);
GUM_PRINT_ENTRYGATE_COUNTER (post_call_invoke);
GUM_PRINT_ENTRYGATE_COUNTER (excluded_call_imm);
GUM_PRINT_ENTRYGATE_COUNTER (excluded_call_reg);
GUM_PRINT_ENTRYGATE_COUNTER (ret);
GUM_PRINT_ENTRYGATE_COUNTER (jmp_imm);
GUM_PRINT_ENTRYGATE_COUNTER (jmp_reg);
GUM_PRINT_ENTRYGATE_COUNTER (jmp_cond_cc);
GUM_PRINT_ENTRYGATE_COUNTER (jmp_cond_cbz);
GUM_PRINT_ENTRYGATE_COUNTER (jmp_cond_cbnz);
GUM_PRINT_ENTRYGATE_COUNTER (jmp_cond_tbz);
GUM_PRINT_ENTRYGATE_COUNTER (jmp_cond_tbnz);
GUM_PRINT_ENTRYGATE_COUNTER (jmp_continuation);
}虚拟化函数
现在让我们更详细地看一下“虚拟化”,我们用于替换在每个块末尾找到的分支指令。我们有四个这样的函数:
gum_exec_block_virtualize_branch_insn()gum_exec_block_virtualize_ret_insn()gum_exec_block_virtualize_sysenter_insn()gum_exec_block_virtualize_linux_sysenter()
我们可以看到其中两个与系统调用相关(事实上,一个调用另一个),我们稍后将介绍这些。让我们看看分支和返回的内容。
gum_exec_block_virtualize_branch_insn
该例程首先确定分支的目的地是来自指令中的立即偏移量还是来自寄存器。对于后者,我们还没有提取值,我们只确定哪个寄存器。这称为 target。该函数的下一部分涉及分支指令。这包括条件分支和非条件分支。对于条件目标,如果不采取分支,则目标称为 cond_target,这被设置为原始块中下一条指令的地址。
同样,regular_entry_func 和 cond_entry_func 用于容纳用于处理分支的入口门。前者用于保存用于非条件分支的门,而 cond_entry_func 保存用于条件分支的门(无论是否采用)。
函数gum_exec_block_write_jmp_transfer_code()用于编写分支到入口门所需的代码。对于非条件分支,这很简单,我们调用传递 target 和 regular_entry_func 的函数。对于条件分支,事情稍微复杂一些。我们的输出类似于以下伪代码:
INVERSE_OF_ORIGINAL_BRANCH(is_false)
jmp_transfer_code(target, cond_entry_func)
is_false:
jmp_transfer_code(cond_target, cond_entry_func)在这里,我们可以看到我们首先将分支指令写入我们的插桩块中,因为在我们的插桩块中,我们还需要确定是否应该进行分支。但与直接分支到目标不同,就像非条件分支一样,我们使用 gum_exec_block_write_jmp_transfer_code() 编写代码,通过传递我们本来分支到的真实地址的相关入口门跳回 Stalker。但请注意,该分支与原始分支相反(例如,CBZ 将替换为 CBNZ)。
现在,我们看看gum_exec_block_virtualize_branch_insn()是如何处理调用的。首先,如果我们配置为的话,我们会发出代码来生成调用事件。接下来我们检查是否有正在使用的探针。如果有,那么我们调用 gum_exec_block_write_call_probe_code() 来发出调用任何已注册的探针回调所需的代码。接下来,我们检查调用是否是排除范围(请注意,只有当调用是立即地址时我们才能执行此操作),如果是,则我们按原样发出指令。但我们通过使用 gum_exec_block_write_jmp_transfer_code() 来遵循这一点,就像我们在处理分支时所做的那样,发出代码以在返回地址处插桩块后立即回调到 Stalker。请注意,这里我们使用 excluded_call_imm 入口门。
最后,如果它只是一个普通的调用表达式,那么我们使用函数 gum_exec_block_write_call_invoke_code() 发出处理调用的代码。由于所有回填优化的结果,这个函数非常复杂,所以我们只看基础知识。
还记得之前在 gum_exec_block_virtualize_branch_insn() 中,如果目标是立即指定的,我们只能检查我们的调用是否是排除范围吗?如果目标是在寄存器中指定的,那么这里我们发出代码来检查目标是否在排除范围内。这是通过使用 gum_exec_ctx_write_push_branch_target_address() 加载目标寄存器(进而调用我们之前介绍过的 gum_exec_ctx_load_real_register_into() 来读取上下文)并发出代码来调用 gum_exec_block_check_address_for_exclusion() 来完成的,gum_exec_block_check_address_for_exclusion() 的实现是非常不言自明的。如果它被排除,则采取一个分支,并使用与处理上面讨论的排除的立即调用时描述的代码类似的代码。
接下来,我们发出代码来调用入口门并生成被调用者的插桩块。然后调用助手 last_stack_push 将 GumExecFrame 添加到包含原始和插桩块地址的上下文中。分别从 GeneratorContext 和 CodeWriter 的当前光标位置读取真实代码地址和插桩代码地址,然后生成返回地址所需的着陆垫(这是我们之前介绍的优化,我们可以在执行虚拟化 return 语句时直接跳到此块,而不是重新进入 Stalker)。最后,我们使用 gum_exec_block_write_exec_generated_code() 发出代码以分支到已插桩的被调用方。
gum_exec_block_virtualize_ret_insn
看完调用指令的虚拟化,你会很高兴地发现这个相对简单!如果配置,该函数调用 gum_exec_block_write_ret_event_code() 为 return 语句生成事件。然后它调用 gum_exec_block_write_ret_transfer_code() 生成处理返回指令所需的代码。这个也很简单,它发出代码来调用我们之前介绍的 last_stack_pop_and_go 帮助程序。
发出事件
事件是 Stalker 引擎的关键输出之一。它们由以下函数发出。他们的实现也是不言自明的:
gum_exec_ctx_emit_call_event()gum_exec_ctx_emit_ret_event()gum_exec_ctx_emit_exec_event()gum_exec_ctx_emit_block_event()
然而,每个函数需要注意的一件事是,它们都调用 gum_exec_block_write_unfollow_check_code() 来生成代码以检查 Stalker 是否停止跟踪线程。接下来我们将更详细地了解这一点。
停止跟踪与清理
如果我们查看生成插桩代码的函数来检查我们是否被要求停止跟踪,我们可以看到它导致线程调用 gum_exec_ctx_maybe_unfollow() 并传递下一个要检测的指令的地址。我们可以看到,如果状态已设置为停止跟踪,那么我们只需分支回原始代码即可。
static void
gum_exec_block_write_unfollow_check_code (GumExecBlock * block,
GumGeneratorContext * gc,
GumCodeContext cc)
{
GumExecCtx * ctx = block->ctx;
GumArm64Writer * cw = gc->code_writer;
gconstpointer beach = cw->code + 1;
GumPrologType opened_prolog;
if (cc != GUM_CODE_INTERRUPTIBLE)
return;
gum_arm64_writer_put_call_address_with_arguments (cw,
GUM_ADDRESS (gum_exec_ctx_maybe_unfollow), 2,
GUM_ARG_ADDRESS, GUM_ADDRESS (ctx),
GUM_ARG_ADDRESS, GUM_ADDRESS (gc->instruction->begin));
gum_arm64_writer_put_cbz_reg_label (cw, ARM64_REG_X0, beach);
opened_prolog = gc->opened_prolog;
gum_exec_block_close_prolog (block, gc);
gc->opened_prolog = opened_prolog;
gum_arm64_writer_put_ldr_reg_address (cw, ARM64_REG_X16,
GUM_ADDRESS (&ctx->resume_at));
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
ARM64_REG_X17, ARM64_REG_X16, 0);
gum_arm64_writer_put_br_reg_no_auth (cw, ARM64_REG_X17);
gum_arm64_writer_put_label (cw, beach);
}
static gboolean
gum_exec_ctx_maybe_unfollow (GumExecCtx * ctx,
gpointer resume_at)
{
if (g_atomic_int_get (&ctx->state) !=
GUM_EXEC_CTX_UNFOLLOW_PENDING)
return FALSE;
if (ctx->pending_calls > 0)
return FALSE;
gum_exec_ctx_unfollow (ctx, resume_at);
return TRUE;
}
static void
gum_exec_ctx_unfollow (GumExecCtx * ctx,
gpointer resume_at)
{
ctx->current_block = NULL;
ctx->resume_at = resume_at;
gum_tls_key_set_value (ctx->stalker->exec_ctx, NULL);
ctx->destroy_pending_since = g_get_monotonic_time ();
g_atomic_int_set (&ctx->state, GUM_EXEC_CTX_DESTROY_PENDING);
}关于待处理调用的快速说明。如果我们调用了排除的范围,那么我们会在插桩代码中发出原始调用,然后回调 Stalker。然而,当线程在排除范围内运行时,我们无法控制指令指针,直到它返回。因此,我们需要简单地跟踪这些并等待线程退出排除范围。
现在我们可以看到正在运行的线程如何优雅地返回到运行正常的未插桩代码,让我们看看我们如何首先停止跟踪。我们有两种可能的方法来阻止跟踪:
gum_stalker_unfollow_me()gum_stalker_unfollow()
第一个很简单,我们设置状态为停止跟踪。然后调用gum_exec_ctx_maybe_unfollow()尝试停止当前线程被跟踪,然后处置Stalker上下文。
void
gum_stalker_unfollow_me (GumStalker * self)
{
GumExecCtx * ctx;
ctx = gum_stalker_get_exec_ctx (self);
if (ctx == NULL)
return;
g_atomic_int_set (&ctx->state, GUM_EXEC_CTX_UNFOLLOW_PENDING);
if (!gum_exec_ctx_maybe_unfollow (ctx, NULL))
return;
g_assert (ctx->unfollow_called_while_still_following);
gum_stalker_destroy_exec_ctx (self, ctx);
}我们在这里注意到,我们将 NULL 作为地址传递给 gum_exec_ctx_maybe_unfollow(),这可能看起来很奇怪,但我们可以看到,在这种情况下,它不被用作当我们为代码块插桩时(记住 gum_exec_ctx_replace_current_block_with() 是入口门引导我们检测后续块的地方),我们检查是否要调用 gum_unfollow_me(),如果是,那么我们从函数返回原始块而不是 gum_exec_ctx_obtain_block_for() 生成的插桩块的地址。因此我们可以看到这是一个特殊情况并且这个函数没有被跟踪。我们只是跳转到真正的函数,所以此时我们已经永远停止跟踪线程了。这种处理与排除范围不同,因为我们在插桩块中保留原始调用指令,但随后回调到 Stalker。在这种情况下,我们只是向量回到原始的未插桩块:
static gpointer gum_unfollow_me_address;
static void
gum_stalker_class_init (GumStalkerClass * klass)
{
...
gum_unfollow_me_address = gum_strip_code_pointer (
gum_stalker_unfollow_me);
...
}
static gpointer
gum_exec_ctx_replace_current_block_with (GumExecCtx * ctx,
gpointer start_address)
{
...
if (start_address == gum_unfollow_me_address ||
start_address == gum_deactivate_address)
{
ctx->unfollow_called_while_still_following = TRUE;
ctx->current_block = NULL;
ctx->resume_at = start_address;
}
...
else
{
ctx->current_block = gum_exec_ctx_obtain_block_for (ctx,
start_address, &ctx->resume_at);
...
}
return ctx->resume_at;
...
}现在让我们看看gum_stalker_unfollow():
void
gum_stalker_unfollow (GumStalker * self,
GumThreadId thread_id)
{
if (thread_id == gum_process_get_current_thread_id ())
{
gum_stalker_unfollow_me (self);
}
else
{
GSList * cur;
GUM_STALKER_LOCK (self);
for (cur = self->contexts; cur != NULL; cur = cur->next)
{
GumExecCtx * ctx = (GumExecCtx *) cur->data;
if (ctx->thread_id == thread_id &&
g_atomic_int_compare_and_exchange (&ctx->state,
GUM_EXEC_CTX_ACTIVE,
GUM_EXEC_CTX_UNFOLLOW_PENDING))
{
GUM_STALKER_UNLOCK (self);
if (!gum_exec_ctx_has_executed (ctx))
{
GumDisinfectContext dc;
dc.exec_ctx = ctx;
dc.success = FALSE;
gum_process_modify_thread (thread_id,
gum_stalker_disinfect, &dc);
if (dc.success)
gum_stalker_destroy_exec_ctx (self, ctx);
}
return;
}
}
GUM_STALKER_UNLOCK (self);
}
}该函数查看上下文列表,寻找所请求线程的上下文。它再次将上下文的状态设置为 GUM_EXEC_CTX_UNFOLLOW_PENDING。如果线程已经运行,我们必须等待它检查此标志并返回正常执行。然而,如果它没有运行(当我们要求跟踪它时,它可能处于阻塞系统调用中,并且在第一个实例中从未被注入),那么我们可以通过调用 gum_process_modify_thread() 来修改线程上下文(此函数之前已详细描述)并使用 gum_stalker_disinfect() 作为我们的回调来执行更改,从而自行解除注入。这只是检查程序计数器是否设置为指向 infect_thunk 并将程序指针重置回其原始值。 infect_thunk 由 gum_stalker_infect() 创建,它是 gum_stalker_follow() 用于修改上下文的回调。回想一下,虽然某些设置可以代表目标线程执行,但有些设置必须在目标线程本身的上下文中完成(特别是在线程本地存储中设置变量)。嗯,包含该代码的是 infect_thunk。
其他内容
希望我们现在已经涵盖了 Stalker 最重要的方面,并提供了有关其工作原理的良好背景。不过,我们确实还有一些其他可能有趣的观察结果。
独占存储
AArch64 架构支持专有加载/存储指令。这些指令旨在用于同步。如果从给定地址执行独占加载,然后尝试对同一位置进行独占存储,则 CPU 能够在干预期间检测到同一位置的任何其他存储(独占或其他),并且存储失败。
显然,这些类型的原语可能用于互斥体和信号量等构造。多个线程可能会尝试加载信号量的当前计数,测试它是否已满,然后递增并将新值存储回以获取信号量。这些独占操作非常适合这种场景。考虑一下如果多个线程竞争同一资源会发生什么。如果其中一条线索被潜猎者追踪,它总是会输掉比赛。此外,这些指令很容易受到其他类型的 CPU 操作的干扰,因此,如果我们做一些复杂的事情,例如在加载和存储之间发出事件,我们将导致它每次都失败,并最终无限循环。然而,Stalker 处理的是这样的场景:
gboolean
gum_stalker_iterator_next (GumStalkerIterator * self,
const cs_insn ** insn)
{
...
switch (instruction->ci->id)
{
case ARM64_INS_STXR:
case ARM64_INS_STXP:
case ARM64_INS_STXRB:
case ARM64_INS_STXRH:
case ARM64_INS_STLXR:
case ARM64_INS_STLXP:
case ARM64_INS_STLXRB:
case ARM64_INS_STLXRH:
gc->exclusive_load_offset = GUM_INSTRUCTION_OFFSET_NONE;
break;
default:
break;
}
if (gc->exclusive_load_offset != GUM_INSTRUCTION_OFFSET_NONE)
{
gc->exclusive_load_offset++;
if (gc->exclusive_load_offset == 4)
gc->exclusive_load_offset = GUM_INSTRUCTION_OFFSET_NONE;
}
}
...
...
}
void
gum_stalker_iterator_keep (GumStalkerIterator * self)
{
...
switch (insn->id)
{
case ARM64_INS_LDAXR:
case ARM64_INS_LDAXP:
case ARM64_INS_LDAXRB:
case ARM64_INS_LDAXRH:
case ARM64_INS_LDXR:
case ARM64_INS_LDXP:
case ARM64_INS_LDXRB:
case ARM64_INS_LDXRH:
gc->exclusive_load_offset = 0;
break;
default:
break;
}
...
}在这里,我们可以看到迭代器记录何时看到独占加载并跟踪此后传递了多少条指令。这种情况最多持续四条指令——因为这是根据加载、测试、修改和存储值需要多少条指令通过经验测试确定的。然后,这用于防止发出任何并非严格必要的检测:
if ((ec->sink_mask & GUM_EXEC) != 0 &&
gc->exclusive_load_offset == GUM_INSTRUCTION_OFFSET_NONE)
{
gum_exec_block_write_exec_event_code (block, gc,
GUM_CODE_INTERRUPTIBLE);
}耗尽的代码块
虽然我们在开始之前检查以确保当前插桩块在slab中保留了最小空间(如果低于此最小值,则分配一个新空间),但我们无法预测在输入块中可能遇到的指令序列有多长。准确确定输出中需要多少指令来编写必要的检测也不容易(我们有可能的代码用于发出不同类型的事件、检查排除的范围、虚拟化在块末尾找到的指令等)。此外,尝试允许插桩代码是非顺序的也是充满困难的。因此,采取的方法是确保每次从迭代器读取新指令时,slab 中至少有 1024 字节的空间用于输出。如果不是,那么我们将当前地址存储在continuation_real_address中并返回FALSE,这样迭代器就结束了。
#define GUM_EXEC_BLOCK_MIN_SIZE 1024
static gboolean
gum_exec_block_is_full (GumExecBlock * block)
{
guint8 * slab_end = block->slab->data + block->slab->size;
return slab_end - block->code_end < GUM_EXEC_BLOCK_MIN_SIZE;
}
gboolean
gum_stalker_iterator_next (GumStalkerIterator * self,
const cs_insn ** insn)
{
...
if (gum_exec_block_is_full (block))
{
gc->continuation_real_address = instruction->end;
return FALSE;
}
...
}我们的调用者 gum_exec_ctx_obtain_block_for() 正在遍历迭代器以生成块,然后其行为就像存在到下一条指令的分支指令一样,本质上终止当前块并开始下一个块。
static GumExecBlock *
gum_exec_ctx_obtain_block_for (GumExecCtx * ctx,
gpointer real_address,
gpointer * code_address_ptr)
{
...
if (gc.continuation_real_address != NULL)
{
GumBranchTarget continue_target = { 0, };
continue_target.absolute_address = gc.continuation_real_address;
continue_target.reg = ARM64_REG_INVALID;
gum_exec_block_write_jmp_transfer_code (block, &continue_target,
GUM_ENTRYGATE (jmp_continuation), &gc);
}
...
}就好像在没有足够空间的指令之前的输入中遇到了以下指令:
B label
label:系统调用虚拟化
系统调用是从用户模式到内核模式的入口点。这就是应用程序要求内核代表其执行操作的方式,无论是打开文件还是读取网络套接字。在 AArch64 系统上,这是使用 SVC 指令执行的,而在 Intel 上,指令是 sysenter。因此,术语 syscall 和 sysenter 在这里作为同义词使用。
系统调用虚拟化通过以下例程进行。我们可以看到我们只在 Linux 系统上做任何事情:
static GumVirtualizationRequirements
gum_exec_block_virtualize_sysenter_insn (GumExecBlock * block,
GumGeneratorContext * gc)
{
#ifdef HAVE_LINUX
return gum_exec_block_virtualize_linux_sysenter (block, gc);
#else
return GUM_REQUIRE_RELOCATION;
#endif
}这是必需的,因为 clone 系统调用。此系统调用创建一个与父进程共享执行上下文的新进程,例如文件句柄、虚拟地址空间和信号处理程序。本质上,这有效地创建了一个新线程。但是当前线程正在被 Stalker 跟踪,并且克隆将创建它的精确副本。鉴于 Stalker 上下文是基于每个线程的,我们不应该跟踪这个新子进程。
请注意,对于 AArch64 中的系统调用,前 8 个参数在寄存器 X0 到 X7 中传递,系统调用号在 X8 中传递,其他参数在堆栈上传递。系统调用的返回值在 X0 中返回。函数 gum_exec_block_virtualize_linux_sysenter() 生成处理此类系统调用所需的插桩代码。我们看下面的伪代码:
if x8 == __NR_clone:
x0 = do_original_syscall()
if x0 == 0:
goto gc->instruction->begin
return x0
else:
return do_original_syscall()我们可以看到它首先检查我们是否正在处理 clone 系统调用,否则它只是执行原始系统调用,仅此而已(原始系统调用指令是从原始块复制的)。否则,如果它是克隆系统调用,则我们再次执行原始系统调用。此时,我们有两个执行线程,系统调用确定每个线程将返回不同的值。原始线程将接收子线程的 PID 作为其返回值,而子线程将接收值 0。
如果我们收到一个非零值,我们可以简单地继续。我们希望继续跟踪线程并允许继续执行下一条指令。然而,如果我们收到返回值 0,那么我们就处于子线程中。因此,我们执行分支到原始块中的下一条指令,确保子进程继续运行,而不会受到 Stalker 的任何中断。
指针认证
最后,我们应该注意到,较新版本的 iOS 已引入 指针身份验证代码。指针身份验证代码 (PAC) 利用指针中未使用的位(虚拟地址的高位通常未使用,因为大多数系统的虚拟地址空间最多为 48 位)来存储身份验证值。这些值是通过使用原始指针、上下文参数(通常是另一个寄存器的内容)和加密密钥来计算的。这个想法是,无法从用户模式读取或写入密钥,并且在无法访问密钥的情况下无法猜测生成的指针身份验证代码。
我们来看下面的代码片段:
pacia lr, sp
stp fp, lr, [sp, #-FRAME_SIZE]!
mov fp, sp
...
ldp fp, lr, [sp], #FRAME_SIZE
autia lr, sp
ret lrpacia 指令将 LR、SP 的值和密钥组合起来,生成带有验证码 LR' 的 LR 版本,并将其存储回 LR 寄存器。该值存储在堆栈中,并在函数结束时恢复。 autia 指令使 LR' 的值有效。这是可能的,因为 LR 的高位中的 PAC 可以被剥离以给出原始的 LR 值,并且指针认证码可以像使用 SP 和密钥之前一样重新生成。结果根据 LR' 进行检查。如果该值不匹配,则指令将生成错误。因此,如果存储在堆栈中的 LR 的值被修改,或者堆栈指针本身被损坏,则验证将失败。这对于防止构建需要将返回地址存储在堆栈中的 ROP 链很有用。由于 LR' 现在存储在堆栈中而不是 LR,因此没有密钥就无法伪造有效的返回地址。
Frida 在生成代码时也需要考虑到这一点。当从应用程序使用的寄存器中读取指针时(例如,确定间接分支或返回的目的地),有必要在使用之前从地址中剥离这些指针验证代码。这是使用函数 gum_arm64_writer_put_xpaci_reg() 来实现的。