Stalker
简介
Stalker 是 Frida 的代码追踪引擎。它允许跟踪线程,捕获每一个执行的函数、代码块,甚至每一条指令。关于 Stalker 引擎的详细概述可以在这里找到,我们建议您先仔细阅读。显然,Stalker 的实现与具体的架构有关,尽管它们之间有许多共同点。Stalker 目前支持在运行 Android 或 iOS 的移动设备和平板电脑上常见的 AArch64 架构,以及桌面和笔记本电脑上常见的 Intel 64 和 IA-32 架构。本文旨在深入探讨 Stalker 的 ARM64 实现,并详细解释其工作原理。希望这能帮助未来的开发者将 Stalker 移植到其他硬件架构上。
免责声明
虽然本文将涵盖 Stalker 内部工作原理的许多细节,但不会详细讨论回填(back-patching)。本文旨在帮助其他人理解这项技术,而 Stalker 本身已经足够复杂了!不过,公平地说,这种复杂性并非没有原因,它是为了最小化这种本质上开销较大的操作的开销。最后,虽然本文将涵盖实现的关键概念,并提取实现中的一些关键部分进行逐行分析,但仍有一些实现的最后细节留给读者通过阅读源代码来发现。不过,希望本文能为您提供一个非常有用的起点。
目录
跟踪
术语
辅助函数
虚拟化函数
杂项
使用场景
要开始理解 Stalker 的实现,我们首先需要详细了解它为用户提供了什么。虽然 Stalker 可以通过其原生 Gum 接口直接调用,但大多数用户会通过 JavaScript API 调用它,JavaScript 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()
的情况下,使用链接寄存器(Link Register)来确定开始跟踪的指令地址。在 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 实例本身。请注意,如果同时加载了多个脚本,可能会有多个这样的实例。第二个是转换器(transformer),它可以在写入时转换被插桩的代码(稍后会详细介绍)。最后一个参数是事件接收器(event sink),这是 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 使用的相同)来附加到线程并读取和写入寄存器。但这里有很多复杂性。在 Linux 上,您不能 ptrace 自己的进程(或同一进程组中的任何进程),因此 Frida 会创建一个当前进程的克隆,并将其放在自己的进程组中,并共享相同的内存空间。它使用 UNIX 套接字与克隆进程通信。这个克隆进程充当调试器,读取原始目标进程的寄存器并将其存储在共享内存空间中,然后根据需求将其写回进程。哦,还有 PR_SET_DUMPABLE
和 PR_SET_PTRACER
,它们控制谁可以 ptrace 我们的原始进程的权限。
现在您会看到 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 是要计算地址的程序标签。它是从该指令的地址开始的偏移量,范围为 ±1MB。
如果将此指令复制到内存中的不同位置并执行,则由于标签的地址是通过将偏移量添加到当前指令指针来计算的,因此该值将不同。幸运的是,Gum 有一个 Relocator 专门用于此目的,它能够根据新位置修改指令,以便计算出正确的地址。
现在,回想一下我们说过 Stalker 一次处理一个块。那么,我们如何插桩下一个块呢?我们还记得每个块也以分支指令结束,如果我们修改此分支以使其分支回 Stalker 引擎,但确保我们存储分支的目标位置,我们可以插桩下一个块并将执行重定向到那里。这个简单的过程可以继续处理一个接一个的块。
现在,这个过程可能有点慢,所以我们可以应用一些优化。首先,如果我们多次执行相同的代码块(例如循环,或者可能只是一个被多次调用的函数),我们不必每次都重新插桩它。我们可以直接重新执行相同的插桩代码。因此,我们保留了一个哈希表,记录我们之前遇到的所有块以及我们放置插桩副本的位置。
其次,当遇到调用指令时,在发出插桩调用后,我们发出一个着陆垫(landing pad),我们可以返回到该着陆垫而无需重新进入 Stalker。Stalker 使用 GumExecFrame
结构构建一个辅助堆栈,该结构记录真实的返回地址(real_address
)和此着陆垫(code_address
)。当函数返回时,我们发出代码,该代码将检查辅助堆栈中的返回地址是否与 real_address
匹配,如果匹配,则可以简单地返回到 code_address
而无需重新进入运行时。此着陆垫最初将包含进入 Stalker 引擎以插桩下一个块的代码,但稍后可以回填以直接分支到该块。这意味着整个返回序列可以在不进入和离开 Stalker 的情况下处理。
如果返回地址与 GumExecFrame
的 real_address
不匹配,或者我们用完了辅助堆栈中的空间,我们只需从头开始重新构建一个新的辅助堆栈。我们需要在应用程序代码执行时保留 LR 的值,以便应用程序无法使用它来检测 Stalker 的存在(反调试),或者以防它将其用于除简单返回之外的任何其他目的(例如引用代码部分中的内联数据)。此外,我们希望 Stalker 能够随时取消跟踪,因此我们不希望必须回溯堆栈以纠正我们沿途修改的 LR 值。
最后,虽然我们总是用调用回 Stalker 的分支替换分支以插桩下一个块,但根据 Stalker.trustThreshold
的配置,我们可能会回填此类插桩代码,以将调用替换为直接分支到下一个插桩块。确定性分支(例如目标是固定的且分支不是条件性的)很简单,我们可以将分支到 Stalker 的分支替换为分支到下一个块的分支。但我们也可以处理条件分支,如果我们插桩两个代码块(分支被采取时的块和未被采取时的块)。然后我们可以将原始条件分支替换为一个条件分支,该分支将控制流定向到分支被采取时的插桩块,然后是一个无条件分支到另一个插桩块。我们还可以部分处理目标不是静态的分支。假设我们的分支是这样的:
br x0
这种指令在调用函数指针或类方法时很常见。虽然 X0 的值可以改变,但很多时候它实际上总是相同的。在这种情况下,我们可以将最终的分支指令替换为将 X0 的值与已知函数进行比较的代码,如果匹配,则分支到代码的插桩副本的地址。然后可以跟随一个无条件分支回 Stalker 引擎,如果不匹配。因此,如果函数指针的值发生变化,代码仍然可以工作,我们将重新进入 Stalker 并插桩我们最终到达的任何地方。然而,如果如我们所期望的那样它保持不变,那么我们可以完全绕过 Stalker 引擎,直接进入插桩函数。
选项
现在让我们看看使用 Stalker 跟踪线程时的选项。Stalker 在被跟踪的线程执行时生成事件,这些事件被放入队列中,并定期或由用户手动刷新。这不是由 Stalker 本身完成的,而是由 EventSink::process
虚函数完成的,因为重新进入 JavaScript 运行时以逐个处理事件的开销将非常大。大小和时间段可以通过选项进行配置。事件可以在每条指令的基础上生成,无论是调用、返回还是所有指令。或者它们可以在块的基础上生成,无论是在块执行时,还是在 Stalker 引擎插桩块时。
我们还可以提供两个回调之一 onReceive
或 onCallSummary
。前者将简单地传递一个包含 Stalker 生成的原始事件的二进制 blob,事件按生成的顺序排列。(Stalker.parse()
可以用于将其转换为表示事件的元组的 JS 数组。)第二个聚合这些结果,简单地返回每个函数被调用的次数。这比 onReceive
更高效,但数据的粒度要小得多。
术语
在我们继续描述 Stalker 的详细实现之前,我们首先需要了解设计中使用的一些关键术语和概念。
探针(Probes)
当一个线程在 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 时,使用闭包捕获任何所需状态更简单。
信任阈值(Trust Threshold)
回想一下,我们应用的一个简单优化是,如果我们尝试多次执行一个块(例如循环,或者可能只是一个被多次调用的函数),在后续的情况下,我们可以简单地调用上次创建的插桩块。好吧,这只有在我们要插桩的代码没有改变的情况下才有效。在自修改代码的情况下(这通常用作反调试/反反汇编技术,以试图阻碍对安全关键代码的分析),代码可能会改变,因此不能重新使用插桩块。那么,我们如何检测一个块是否已更改?我们只需在数据结构中保留原始代码的副本以及插桩版本。然后,当我们再次遇到一个块时,我们可以将要插桩的代码与上次插桩的版本进行比较,如果它们匹配,我们可以重新使用该块。但是,每次块运行时执行比较可能会减慢速度。因此,这也是 Stalker 可以定制的领域。
Stalker.trustThreshold
:一个整数,指定一段代码需要执行多少次才能假定它可以被信任而不发生突变。指定 -1 表示不信任(慢),0 表示从一开始就信任代码,N 表示在代码执行 N 次后信任它。默认为 1。
实际上,N 的值是块需要重新执行并与之前插桩的块匹配(例如未更改)的次数,然后我们停止执行比较。请注意,即使信任阈值设置为 -1
或 0
,原始代码块的副本仍然保留。虽然实际上不需要这些值,但为了保持简单,它被保留了下来。无论如何,这些都不是默认设置。
排除范围(Excluded Ranges)
Stalker 还具有 API Stalker.exclude(range)
,它传递一个基址和限制,用于防止 Stalker 插桩这些区域内的代码。例如,考虑您的线程在 libc
中调用 malloc()
。您很可能不关心堆的内部工作原理,这不仅会降低性能,还会生成大量您不关心的无关事件。然而,需要考虑的一件事是,一旦调用被排除的范围,对该线程的跟踪将停止,直到它返回。这意味着,如果该线程调用不在限制范围内的函数,例如回调,那么这将不会被 Stalker 捕获。正如这可以用于停止跟踪整个库一样,它也可以用于停止跟踪给定函数(及其被调用者)。这在您的目标应用程序是静态链接时特别有用。在这里,我们不能简单地忽略所有对 libc
的调用,但我们可以使用 Module.enumerateSymbols()
找到 malloc()
的符号并忽略该单个函数。
冻结/解冻(Freeze/Thaw)
作为 DEP 的扩展,某些系统防止页面同时被标记为可写和可执行。因此,Frida 必须在写入插桩代码和允许该代码执行之间切换页面权限。当页面可执行时,它们被称为冻结(因为它们不能被更改),当它们再次被标记为可写时,它们被认为是解冻的。
调用指令(Call Instructions)
与 Intel 不同,AArch64 没有单一的显式 CALL
指令,它具有不同的形式以应对所有支持的场景。相反,它使用许多不同的指令来支持函数调用。这些指令都分支到给定位置并更新链接寄存器 LR
以保存返回地址:
BL
BLR
BLRAA
BLRAAZ
BLRAB
BLRABZ
为简单起见,在本文的其余部分,我们将这组指令称为“调用指令”。
帧(Frames)
每当 Stalker 遇到调用时,它都会将返回地址和插桩返回块转发器的地址存储在结构中,并将这些添加到存储在自己的数据结构中的堆栈中。它使用此作为推测优化,并作为在发出调用和返回事件时近似调用深度的启发式方法。
typedef struct _GumExecFrame GumExecFrame;
struct _GumExecFrame
{
gpointer real_address;
gpointer code_address;
};
转换器(Transformer)
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 文档中提供了一个很好的示例。
回调(Callouts)
转换器还可以进行回调。也就是说,它们指示 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 在生成插桩代码时起着重要作用。它有两个重要的属性来控制其状态。
块结束(End of Block, EOB)表示已到达块的末尾。这发生在我们遇到任何分支指令时。分支、调用或返回指令。
输入结束(End of Input, EOI)表示我们不仅到达了块的末尾,而且可能到达了输入的末尾,即此指令之后可能没有其他指令。虽然对于调用指令来说并非如此,因为代码控制将(通常)在调用者返回时传递回来,因此必须跟随更多指令。(请注意,编译器通常会为调用不返回函数(如 exit()
)生成分支指令。)虽然不能保证调用指令之后有有效指令,但我们可以推测性地优化这种情况。如果我们遇到非条件分支指令或返回指令,则很可能之后没有代码。
序言/尾声(Prologues/Epilogues)
当控制流从程序重定向到 Stalker 引擎时,必须保存 CPU 的寄存器,以便 Stalker 可以运行并使用寄存器,并在控制传递回程序之前恢复它们,以便不会丢失任何状态。
AArch64 的过程调用标准规定,某些寄存器(特别是 X19 到 X29)是被调用者保存的寄存器。这意味着当编译器生成使用这些寄存器的代码时,它必须首先存储它们。因此,不必将这些寄存器保存到上下文结构中,因为如果它们被 Stalker 引擎中的代码使用,它们将被恢复。这种“最小”上下文对于大多数目的来说已经足够。
然而,如果 Stalker 引擎要调用由 Stalker.addCallProbe()
注册的探针,或由 iterator.putCallout()
创建的回调(由转换器调用),那么这些回调将期望接收完整的 CPU 上下文作为参数。并且它们将期望能够修改此上下文,并在控制传递回应用程序代码时使更改生效。因此,对于这些实例,我们必须写入“完整”上下文,并且其布局必须符合结构 GumArm64CpuContext
所期望的格式。
typedef struct _GumArm64CpuContext GumArm64CpuContext;
struct _GumArm64CpuContext
{
guint64 pc;
guint64 sp; /* X31 */
guint64 x[29];
guint64 fp; /* X29 - 帧指针 */
guint64 lr; /* X30 */
guint8 q[128]; /* FPU, NEON (SIMD), CRYPTO 寄存器 */
};
然而,请注意,写出必要的 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,我们只需写出这些序言和尾声的另一个副本以供使用。这给了我们一个非常合理的权衡。
计数器(Counters)
最后,有一系列计数器,您可以看到它们在插桩块末尾记录了遇到的每种类型指令的数量。这些仅由测试套件用于在性能调优期间指导开发人员,指示哪些分支类型最常需要完全上下文切换到 Stalker 以解析目标。
Slabs
现在让我们看看 Stalker 存储其插桩代码的地方,即 slabs。以下是用于保存所有内容的数据结构:
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。slab 的 1/12 保留用于其头部,即 GumSlab
结构本身,包括其 GumExecBlock
数组。请注意,这被定义为 GumSlab
结构末尾的零长度数组,但实际可以适合 slab 头部的这些数量是计算并存储在 slab_max_blocks
中的。
那么 slab 的其余部分用于什么呢?虽然 slab 的头部用于所有会计信息,但 slab 的其余部分(以下称为尾部)用于插桩指令本身(它们内联存储在 slab 中)。
那么为什么将 slab 的 1/12 分配给头部,其余部分用于指令呢?好吧,每个要插桩的块的长度会有很大差异,并且可能会受到所使用的编译器及其优化设置的影响。一些粗略的经验测试表明,考虑到每个块的平均长度,这可能是确保我们在尾部用完新 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 权限的 slab,这样我们就不必一直冻结和解冻它。在支持 RWX 的系统上,冻结和解冻函数变为无操作。
最后,我们可以看到每个 slab 包含一个 next
指针,可用于将 slab 链接在一起以形成单链表。这用于在我们完成 Stalker 时遍历它们并处理它们。
块(Blocks)
现在我们了解了 slabs 的工作原理。让我们更详细地看看块。我们知道,我们可以在一个 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 中分配我们的块。
回想一下,我们使用辅助函数(例如保存和恢复 CPU 上下文的序言和尾声)来避免在每个块的开头和结尾重复这些指令。由于我们需要能够从我们写入 slab 的插桩代码中调用这些辅助函数,并且我们使用直接分支来调用它们,该分支只能到达调用者 ±128 MB 范围内的代码,因此我们需要确保我们可以到达它们。如果我们之前没有编写它们,那么我们将它们写入当前的 slab。请注意,这些辅助函数需要可以从 slab 尾部写入的任何插桩指令中访问。因为我们的 slab 只有 4 MB 大小,所以如果我们的辅助函数写入当前的 slab,那么它们将完全可以访问。如果我们分配后续的 slab 并且它离前一个 slab 足够近(我们只保留上次写入辅助函数的位置),那么我们可能不需要再次写出它们,而只需依赖附近 slab 中的先前副本。请注意,我们受 mmap()
的支配,slab 在虚拟内存中的分配位置由 ASLR 决定,我们的 slab 可能最终与前一个 slab 相距甚远。
我们只能假设这不太可能成为问题,或者这已被考虑到 slab 的大小中,以确保将辅助函数写入每个 slab 不会占用太多空间,因为它不会占用其空间的很大一部分。另一种选择可能是每次我们写出辅助函数时存储每个位置,以便我们有更多的候选者可供选择(也许我们的 slab 没有分配在之前分配的 slab 附近,但可能离其他 slab 足够近)。否则,我们可以考虑使用 mmap()
制作一个自定义分配器,以保留一个大的(例如 128 MB)虚拟地址空间区域,然后根据需要一次提交一个 slab 的内存。但这些想法可能都过于复杂。
插桩块(Instrumenting Blocks)
插桩代码块的主要函数称为 gum_exec_ctx_obtain_block_for()
。它首先在哈希表中查找现有的块,该哈希表索引在插桩的原始块的地址上。如果找到一个并且满足上述信任阈值的约束,则可以简单地返回它。
GumExecBlock
的字段使用如下。real_begin
设置为要插桩的原始代码块的开始。code_begin
字段指向尾部的第一个空闲字节(记住这是由上面讨论的 gum_exec_block_new()
函数设置的)。初始化一个 GumArm64Relocator
以从 real_begin
的原始代码中读取代码,并初始化一个 GumArm64Writer
以将其输出写入从 code_begin
开始的 slab。这些项目中的每一个都打包到一个 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()
的细节。但本质上,这会导致迭代器从 relocator 中一次读取一条指令,并使用 writer 写出重定位的指令。在此过程之后,可以更新 GumExecBlock
结构。其字段 real_end
可以设置为 relocator 读取到的地址,其字段 code_end
可以设置为 writer 写入到的地址。因此,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
的事件。
辅助函数(Helpers)
我们可以从 gum_exec_ctx_ensure_inline_helpers_reachable()
中看到,我们总共有 6 个辅助函数。这些辅助函数是我们的插桩块重复需要的常见代码片段。与其重复发出它们包含的代码,我们不如编写一次并放置一个调用或分支指令以让我们的插桩代码执行它。回想一下,辅助函数被写入与我们写入插桩代码相同的 slabs 中,并且如果可能,我们可以重用写入先前附近 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 和辅助函数的许多复杂性在于某些函数——让我们称它们为 writer——编写稍后执行的代码。这些 writer 本身有分支,这些分支决定了究竟要编写什么代码,编写的代码有时也有分支。因此,我将为这两个辅助函数采取的方法是显示写入 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
}
正如我们所看到的,这个辅助函数实际上是一个简单的函数,它接受两个参数,即要存储在下一个 GumExecFrame
结构中的 real_address
和 code_address
。请注意,我们的堆栈是从存储它们的页面末尾向后写入的,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 从子程序返回,无条件分支到寄存器中的地址,并提示这是子程序返回。
RET {Xn} 其中:
Xn 是保存要分支到的地址的通用寄存器的 64 位名称,范围为 0 到 31。如果省略,则默认为 X30。
正如我们所看到的,我们将返回到寄存器中传递的地址。通常,我们可以预测寄存器值以及我们将返回到哪里,因为编译器将发出汇编代码,以便寄存器设置为紧随调用我们的指令之后的指令地址。在发出插桩调用后,我们直接在后面发出一个小着陆垫,它将回调到 Stalker 以插桩下一个块。此着陆垫稍后可以回填(如果条件正确)以避免完全重新进入 Stalker。我们将调用后的原始块和此着陆垫的地址存储在 GumExecFrame
结构中,因此我们可以通过将返回指令替换为简单地分支到此着陆垫的指令来虚拟化我们的返回指令。我们不需要每次看到返回指令时都重新进入 Stalker 引擎,并获得不错的性能提升。简单!
然而,我们必须记住,并非所有调用都会导致返回。敌对或专用代码的常见技术是进行调用以使用 LR
来确定指令指针的当前位置。然后,此值可用于自省目的(例如验证代码以检测修改,解密或解扰代码等)。
此外,请记住,用户可以使用自定义转换来随意修改指令,他们可以插入修改寄存器值的指令,或者可能是传递上下文结构的回调函数,该函数允许他们随意修改寄存器值。现在考虑如果他们修改返回寄存器中的值会发生什么!
因此,我们可以看到辅助函数检查返回寄存器的值与存储在 GumExecFrame
中的 real_address
的值。如果匹配,那么一切都很好,我们可以简单地直接分支回着陆垫。回想一下,在第一次实例中,这只是重新进入 Stalker 以插桩下一个块并分支到它,但在稍后的点,回填可用于直接分支到此插桩块并避免完全重新进入 Stalker。
否则,我们遵循不同的路径。首先清除 GumExecFrame
数组,现在我们的控制流已经偏离,我们将重新开始构建我们的堆栈。我们接受,如果我们曾经返回到它们,我们将为我们在调用堆栈中记录的任何先前帧采取相同的较慢路径,但将有可能为我们从这里开始遇到的新调用使用快速路径(直到下一次以非常规方式使用调用指令)。
我们进行最小序言(我们的插桩代码现在必须重新进入 Stalker),并且我们需要能够在将控制返回给应用程序之前恢复应用程序的寄存器。我们调用返回的入口门,gum_exec_ctx_replace_current_block_from_ret()
(稍后再讨论入口门)。然后我们执行相应的尾声,然后分支到 ctx->resume_at
指针,该指针由 Stalker 在上述调用 gum_exec_ctx_replace_current_block_from_ret()
期间设置为指向新的插桩块。
上下文(Context)
现在让我们看看序言和尾声。
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 引擎的信息之前,将堆栈指针推进到它之外。
上下文辅助函数(Context Helpers)
现在我们已经了解了这些辅助函数的调用方式,现在让我们看看辅助函数本身。虽然有两个序言和两个尾声(完整和最小),但它们都是由同一个函数编写的,因为它们有很多共同点。编写的版本基于函数参数。呈现这些的最简单方法是使用带注释的代码:
static void
gum_exec_ctx_write_prolog_helper (GumExecCtx * ctx,
GumPrologType type,
GumArm64Writer * cw)
{
// 跟踪我们推送到堆栈上的内容,因为我们
// 将希望在执行上下文中存储原始应用程序
// 堆栈的位置。目前对辅助函数的调用已经跳过了
// red zone 并存储了 LR 和 X19。
gint immediate_for_sp = 16 + GUM_RED_ZONE_SIZE;
// 此指令用于将 CPU 标志存储到 X15 中。
const guint32 mrs_x15_nzcv = 0xd53b420f;
// 请注意,只有完整序言必须看起来像 C 结构
// 定义,因为这是传递给
// 回调等的数据结构。
// 将返回到我们插桩块的地址保存到 X19 中。我们将
// 在整个过程中保留此值,并在最后分支回那里。
// 这将带我们回到由
// gum_exec_ctx_write_prolog() 编写的代码
gum_arm64_writer_put_mov_reg_reg (cw, ARM64_REG_X19, ARM64_REG_LR);
// LR = SP[8] 将前一个块(或用户代码)的返回地址
// 保存到 LR 中。这是由 gum_exec_ctx_write_prolog() 编写的代码
// 推送到那里的。这是在我们返回到我们的插桩代码块时
// 将保留在 LR 中的那个。请注意
// 使用 SP+8 在入口(序言)上有点不对称,因为它
// 用于传递 LR。在出口(尾声)上,它用于传递 X20
// 因此 gum_exec_ctx_write_epilog() 将其恢复在那里。
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
ARM64_REG_LR, ARM64_REG_SP, 8);
// 存储 SP[8] = X20。我们已经读取了由
// gum_exec_ctx_write_prolog() 放在那里的 LR 的值,并正在写入 X20
// 以便它可以由 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)
{
// 存储所有 FP/NEON 寄存器。NEON 是 ARM 内核上的 SIMD 引擎,
// 允许在多个输入上同时执行操作。
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 是帧指针
// X30 是链接寄存器
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X29, ARM64_REG_X30);
// 我们在这里使用 STP 来推送寄存器对。我们实际上
// 有奇数个要推送,所以我们只是推送 STALKER_REG_CTX
// 作为填充以凑数
/* X19 - X28 是被调用者保存的寄存器 */
// 如果我们只调用编译的 C 代码,那么编译器
// 将确保如果一个函数使用寄存器 X19
// 到 X28,那么它们的值将被保留。因此,
// 我们不需要在这里存储它们,因为它们不会被
// 修改。然而,如果我们进行回调,那么我们希望
// Stalker 最终用户能够看到完整的
// 寄存器集,并能够对它们进行任何他们认为合适的修改。
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 是帧指针
// X30 是链接寄存器
// X15 再次作为填充推送
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);
// 将 X19(当前保存此函数的 LR 值,
// 由 gum_exec_ctx_write_prolog() 编写的调用者的地址)临时存储在 X20 中。我们
// 已经推送了 X20,因此我们可以自由使用它,但我们希望
// 将应用程序的 X19 值推送到上下文中。这是由
// gum_exec_ctx_write_prolog() 编写的代码推送到堆栈中的,因此我们可以从那里
// 恢复它,然后再推送它。
gum_arm64_writer_put_mov_reg_reg (cw,
ARM64_REG_X20, ARM64_REG_X19);
// 从序言调用辅助函数之前推送的值中恢复 X19。
gum_arm64_writer_put_ldr_reg_reg_offset (cw,
ARM64_REG_X19, ARM64_REG_SP,
(6 * 16) + (4 * 32));
// 推送应用程序的 X18 和 X19 值。X18 未修改。我们
// 上面已经纠正了 X19。
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X18, ARM64_REG_X19);
// 从 X20 恢复 X19
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 */
// 我们将在这里存储 PC 和 SP。PC 设置为
// 零,对于 SP,我们必须计算原始 SP
// 在我们存储所有这些上下文信息之前。请注意我们
// 在这里使用零寄存器(AArch64 中的一个特殊寄存器,其值始终为 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;
}
// 将算术逻辑单元标志存储到 X15 中。虽然看起来
// 上面用于计算原始堆栈指针的加法指令可能已经更改了标志,但 AArch64 有一个
// ADD 指令不会修改条件标志
// 而 ADDS 指令会。
gum_arm64_writer_put_instruction (cw, mrs_x15_nzcv);
/* 方便地指向 X20 到保存的
寄存器的开头 */
// X20 稍后由诸如
// gum_exec_ctx_load_real_register_from_full_frame_into() 之类的函数使用,以发出
// 引用保存帧的代码。
gum_arm64_writer_put_mov_reg_reg (cw, ARM64_REG_X20, ARM64_REG_SP);
/* 填充 + 状态 */
// 这将推送标志以确保它们可以在执行 Stalker 内部后正确恢复。
gum_arm64_writer_put_push_reg_reg (cw,
ARM64_REG_X14, ARM64_REG_X15);
immediate_for_sp += 1 * 16;
// 我们在入口时将 LR 保存到 X19 中,以便我们可以分支回
// 插桩代码一旦此辅助函数运行。虽然
// 插桩代码调用了我们,但我们在调用辅助函数之前将 LR 恢复为其先前的
// 值(应用程序代码)。虽然 LR
// 不是被调用者保存的(例如,保存和恢复它不是我们的责任,
// 而是我们的调用者的责任),
// 但在这里这样做是为了最小化插桩块中内联存根的代码大小。
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)
{
// 此指令用于将 X15 的值恢复回
// ALU 标志。
const guint32 msr_nzcv_x15 = 0xd51b420f;
/* 填充 + 状态 */
// 请注意,我们还没有恢复标志,因为我们必须等待
// 直到我们完成所有可能修改标志的操作(例如加法,
// 减法等)。然而,我们
// 必须在我们将 X15 恢复为其原始值之前这样做。
gum_arm64_writer_put_pop_reg_reg (cw,
ARM64_REG_X14, ARM64_REG_X15);
if (type == GUM_PROLOG_MINIMAL)
{
// 将 LR 保存到 X19 中,以便我们可以返回到我们的调用者
// 插桩块中。请注意,我们必须恢复链接
// 寄存器 X30 回其原始值(应用程序代码中的块)
// 在我们返回之前。这是在下面完成的。回想我们的
// X19 的值由内联序言本身保存到堆栈中,并由我们返回的内联尾声恢复。因此我们可以继续使用它作为暂存空间
// 这里。
gum_arm64_writer_put_mov_reg_reg (cw,
ARM64_REG_X19, ARM64_REG_LR);
/* 恢复状态 */
// 我们已经完成了所有可能改变标志的指令。
gum_arm64_writer_put_instruction (cw, msr_nzcv_x15);
// 恢复我们在上下文中保存的所有寄存器。我们
// 之前将 X30 作为填充推送,但我们将
// 在弹出实际推送的 X30 值之前将其弹出回那里。
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 */
// 我们将堆栈指针和 PC 存储在堆栈中,但我们不
// 希望将 PC 恢复回用户代码,我们的堆栈
// 指针应该自然地恢复,因为所有推送到它上的数据都被弹回。
gum_arm64_writer_put_add_reg_reg_imm (cw,
ARM64_REG_SP, ARM64_REG_SP, 16);
/* 恢复状态 */
// 再次,我们已经完成了任何标志影响操作,现在
// 上面的加法已经完成。
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);
// 回想一下,X19 和 X20 实际上由尾声本身恢复,因为 X19 用作暂存空间
// 在序言/尾声辅助函数期间,X20 被序言重新用作
// 上下文结构的指针。如果我们有一个完整序言
// 那么这意味着它是为了我们可以进入一个回调
// 允许 Stalker 最终用户检查和修改所有
// 寄存器。这意味着必须反映上下文结构中的任何寄存器更改
// 在运行时。因此,由于这些值由
// 尾声从堆栈中较高位置恢复,我们必须用上下文结构中的值覆盖它们的值。
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);
// 将 LR 保存到 X19 中,以便我们可以返回到我们的调用者
// 插桩代码中。请注意,我们必须恢复链接
// 寄存器 X30 回其原始值,然后才能返回。
// 这是在下面完成的。回想我们的 X19 的值由
// 内联序言本身保存到堆栈中,并由我们返回的内联尾声恢复。
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);
// 回想一下,X15 也作为填充与 X30 一起推送
// 在构建序言时。然而,Stalker 最终用户可以修改
// 上下文,因此可以修改 X15 的值。然而,这不会影响存储在这里的副本作为填充,因此
// X15 将被破坏。因此,我们将现在恢复的 X15 值复制到存储此副本作为填充的位置,然后从堆栈中恢复两个寄存器。
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);
}
// 现在我们可以返回到我们的调用者(尾声的内联部分),LR 仍然设置为应用程序代码的原始值。
gum_arm64_writer_put_br_reg_no_auth (cw, ARM64_REG_X19)
}
这一切都相当复杂。部分原因是我们只有一个寄存器可以用作暂存空间,部分原因是我们希望将序言和尾声代码内联存储在插桩块中,以保持最小化,部分原因是我们的上下文值可以被回调等更改。但希望现在一切都清楚了。
读取/写入上下文(Reading/Writing Context)
现在我们已经保存了上下文,无论是完整上下文还是最小上下文,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;
/* 出于性能原因,我们不希望处理这种情况 */
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 ();
}
从完整帧中读取寄存器实际上是最简单的。我们可以看到代码与用于将上下文传递给回调等的结构非常匹配。请记住,在每种情况下,寄存器 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);
}
}
控制流(Control Flow)
Stalker 的执行从以下三个入口点之一开始:
_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()
,后者调用 relocator 使用 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 并直接进入下一个插桩块,或者我们正在进入排除范围)。
门(Gates)
入口门由宏生成,每个块末尾找到的不同指令类型都有一个。当我们虚拟化这些类型的指令时,我们通过其中一个门将控制流重定向回 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);
}
虚拟化函数(Virtualize Functions)
现在让我们更详细地看看我们为替换我们在每个块末尾找到的分支指令而进行的虚拟化。我们有四个这样的函数:
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()
,其实现非常自解释。如果它被排除,则采取分支,并使用与上述处理排除立即调用时讨论的类似代码。
接下来,我们发出代码以调用入口门并生成被调用者的插桩块。然后调用辅助函数 last_stack_push
以将我们的 GumExecFrame
添加到我们的上下文中,其中包含原始和插桩块地址。从 GeneratorContext 和 CodeWriter 的当前光标位置读取真实和插桩代码地址,然后我们生成返回地址所需的着陆垫(这是我们之前介绍的优化,我们可以在执行虚拟化返回语句时直接跳转到此块,而不是重新进入 Stalker)。最后,我们使用 gum_exec_block_write_exec_generated_code()
发出代码以分支到插桩的被调用者。
gum_exec_block_virtualize_ret_insn
在了解了调用指令的虚拟化之后,您会很高兴知道这个相对简单!如果配置为这样做,此函数调用 gum_exec_block_write_ret_event_code()
以生成返回语句的事件。然后它调用 gum_exec_block_write_ret_transfer_code()
以生成处理返回指令所需的代码。这个也很简单,它发出代码以调用我们之前介绍的 last_stack_pop_and_go
辅助函数。
发出事件(Emitting Events)
事件是 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 是否要停止跟踪线程的代码。我们接下来将更详细地看看这个。
取消跟踪并清理(Unfollow and Tidy Up)
如果我们看看生成检查我们是否被要求取消跟踪的插桩代码的函数,我们可以看到它导致线程调用 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
包含该代码。
杂项(Miscellaneous)
希望我们现在已经涵盖了 Stalker 的最重要方面,并提供了关于其工作原理的良好背景。我们还有一些其他观察结果,可能会引起兴趣。
独占存储(Exclusive Store)
AArch64 架构支持独占加载/存储指令。这些指令旨在用于同步。如果从给定地址执行独占加载,然后稍后尝试对同一位置进行独占存储,则 CPU 能够检测到在此期间对同一位置的任何其他存储(独占或其他),并且存储失败。
显然,这些类型的原语可能用于诸如互斥锁和信号量之类的构造。多个线程可能尝试加载信号量的当前计数,测试它是否已经满,然后递增并将新值存储回以获取信号量。这些独占操作非常适合这种情况。然而,考虑如果多个线程竞争同一资源会发生什么。如果其中一个线程被 Stalker 跟踪,它将总是输掉比赛。此外,这些指令很容易被其他类型的 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);
}
耗尽块(Exhausted Blocks)
虽然我们检查以确保在开始之前我们的当前插桩块在 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:
系统调用虚拟化(Syscall Virtualization)
系统调用是从用户模式进入内核模式的入口点。这是应用程序请求内核代表其执行操作的方式,无论是打开文件还是读取网络套接字。在 AArch64 系统上,这是使用 SVC
指令完成的,而在 Intel 上,指令是 sysenter
。因此,术语系统调用和 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 跟踪,而 clone 将创建它的精确副本。鉴于 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
系统调用,否则它只是执行原始系统调用,仅此而已(原始系统调用指令从原始块中复制)。否则,如果它是 clone 系统调用,那么我们再次执行原始系统调用。此时,我们有两个执行线程,系统调用确定每个线程将返回不同的值。原始线程将接收子进程的 PID 作为其返回值,而子进程将接收值 0。
如果我们收到一个非零值,我们可以简单地继续。我们希望继续跟踪线程并允许执行继续执行下一条指令。然而,如果我们收到返回值 0,那么我们就在子线程中。因此,我们执行一个分支到原始块中的下一条指令,确保子线程继续运行而不受 Stalker 的任何干扰。
指针认证(Pointer Authentication)
最后,我们应该注意,较新版本的 iOS 已经引入了指针认证代码。指针认证代码(PACs)利用指针中未使用的位(虚拟地址的高位通常未使用,因为大多数系统最多有 48 位虚拟地址空间)来存储认证值。这些值是使用原始指针、上下文参数(通常是另一个寄存器的内容)和加密密钥计算的。其思想是密钥不能从用户模式读取或写入,并且生成的指针认证代码在没有访问它的情况下无法猜测。
让我们看看以下代码片段:
pacia lr, sp
stp fp, lr, [sp, #-FRAME_SIZE]!
mov fp, sp
...
ldp fp, lr, [sp], #FRAME_SIZE
autia lr, sp
ret lr
pacia
指令将 LR
、SP
和密钥的值组合起来生成带有认证代码 LR'
的 LR
版本,并将其存储回 LR
寄存器中。此值存储在堆栈中,稍后在函数末尾恢复。autia
指令验证 LR'
的值。这是可能的,因为 LR
的高位中的 PAC 可以被剥离以给出原始 LR
值,并且可以使用 SP
和密钥重新生成指针认证代码。结果与 LR'
进行检查。如果值不匹配,则指令生成故障。因此,如果存储在堆栈中的 LR
值被修改,或者堆栈指针本身被破坏,则验证将失败。这对于防止构建需要将返回地址存储在堆栈中的 ROP 链非常有用。由于 LR'
现在存储在堆栈中而不是 LR
,因此无法在没有密钥的情况下伪造有效的返回地址。
Frida 在生成代码时也需要考虑这一点。当从应用程序使用的寄存器中读取指针时(例如确定间接分支或返回的目标),有必要在地址使用之前从地址中剥离这些指针认证代码。这是通过函数 gum_arm64_writer_put_xpaci_reg()
实现的。
原文链接:Stalker | Frida • A world-class dynamic instrumentation toolkit
翻译来源:DeepSeek