Stalker是frida的代码追踪引擎。它允许跟踪线程,捕获每个函数,每个块,甚至执行的每条指令。这里提供了一个关于跟踪者引擎的非常好的概述,我们建议您首先仔细阅读它。显然,实现在某种程度上是特定于体系结构的,尽管它们之间有很多共同之处。Stalker目前支持运行Android或iOS的手机和平板电脑上常见的AArch64体系结构,以及台式机和笔记本电脑上常见的Intel 64和IA-32体系结构。本页打算将事情带到下一个细节层面,它剖析了Stalker的ARM64实现,并更详细地解释了它是如何工作的。希望这可以帮助未来将Stalker移植到其他硬件架构。
虽然本文将介绍Stalker内部工作的很多细节,但不会详细介绍反向修补。它的目的是作为一个起点,帮助其他人了解这项技术,如果没有它,Stalker已经够复杂的了!公平地说,这种复杂性并不是没有原因的,它是为了最小化固有的昂贵操作的开销。最后,虽然本文将涵盖实现的关键概念,并将提取实现的一些关键部分以逐行分析,但还将留下一些实现的最后细节,供读者通过阅读源代码来发现。然而,人们希望它将被证明是一个非常有用的开端。
要开始理解Stalker的实现,我们必须首先详细了解它为用户提供了什么。虽然Stalker可以直接通过其本地Gum接口调用,但大多数用户会通过JavaScript API作为代表来调用这些Gum方法。Gum的TypeScript类型定义有很好的注释,并提供了更多的细节。
从JavaScript到Stalker的主要API是:
//开始跟踪threadId(如果省略则跟踪当前线程)
Stalker.follow([threadId, options])
让我们考虑一下什么时候可以使用这些调用。如果您有一个感兴趣的线程,并且想知道它在做什么,那么您提供线程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()
,链接寄存器用于确定开始跟踪的指令。在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_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上,您不能ptrace自己的进程(或同一进程组中的任何进程),因此Frida在其自己的进程组中创建当前进程的克隆,并共享相同的内存空间。它使用UNIX套接字与它通信。这个克隆进程充当调试器,读取原始目标进程的寄存器并将它们存储在共享内存空间中,然后根据需要将它们写回进程。哦,还有PR_SET_DUMPABLE和PR_SET_PTRACER,它们控制谁可以跟踪原始进程的权限。
现在您将看到gum_stalker_infected()
的功能实际上与我们前面提到的_gum_stalker_do_follow_me()
非常相似。这两个函数在本质上执行相同的任务,尽管_gum_stalker_do_follow_me()
在目标线程上运行,但gum_stalker_infected()
不是,因此它必须编写一些代码,以便由目标线程使用GumArm64Writer调用,而不是直接调用函数。
稍后我们将更详细地介绍这些函数,但首先我们需要了解一些背景知识。
代码可以看作是一系列指令块(也称为基本块)。每个块开始于一系列可选的指令(我们可能有两个连续的分支语句),这些指令依次运行,我们会遇到一条导致(或可能导致)继续执行内存中紧随其后的指令结束的指令。
Stalker 一次只在一个块中工作。它从返回到gum_stalker_follow_me()
调用之后的代码块开始,或者从调用gum_stalker_follow()
时目标线程的指令指针所指向的代码块开始。
Stalker的工作原理是分配一些内存并向其写入原始块的一个新的检测副本。可以添加指令来生成事件,或执行Stalker引擎提供的任何其他功能。Stalker也必须在必要时重新定位指令。考虑下面的指令:
ADR Address of label at a PC-relative offset. 相对pc偏移处的标签地址。
ADR Xd, label Xd是通用目标寄存器的64位名称,范围是0到31。
Xd Is the 64-bit name of the general-purpose destination register, in the range 0 to 31.
label Is the program label whose address is to be calculated. It is an offset from the address of this instruction, in the range ±1MB. label要计算地址的程序标签。它是与此指令地址的偏移量,范围为±1MB。
如果将这条指令复制到内存中的不同位置并执行,那么由于标签的地址是通过向当前指令指针添加偏移量来计算的,因此值将不同。幸运的是,Gum有一个用于此目的的重定位器,它能够修改给定新位置的指令,从而计算出正确的地址。
现在,回想一下我们说过Stalker 一次只在一个块中工作。那么,我们如何测量下一个块呢?我们还记得,每个块也以一个分支指令结束,如果我们修改这个分支,而不是让分支回到Stalker引擎,但要确保我们存储了分支打算结束的目的地,我们可以检测下一个块,并重新定向执行那里。同样的简单过程可以在一个块之后继续进行。
现在,这个过程可能有点慢,所以我们可以进行一些优化。首先,如果我们多次执行同一个代码块(例如,一个循环,或者可能只是一个函数被多次调用),我们就不需要重新调试它。我们可以重新执行相同的仪器代码。出于这个原因,我们保存了一个哈希表,其中包含我们之前遇到的所有块以及我们放置该块的仪器副本的地方。
其次,当遇到调用指令时,在发出指令调用后,我们会发出一个着陆垫,我们可以返回而不必重新进入Stalker。跟踪者构建一个侧栈,使用GumExecFrame结构,记录真实的返回地址(real_address)和这个着陆垫(code_address)。当函数返回时,我们发出代码,检查侧堆栈中的返回地址与real_address是否匹配,如果匹配,它可以简单地返回code_address,而无需重新进入运行时。这个着陆垫最初将包含进入Stalker引擎中检测下一个块的代码,但它稍后可以直接回补丁到这个块的分支。这意味着可以处理整个返回序列,而不需要进入和离开Stalker。
如果返回地址与存储在GumExecFrame的real_address的地址不匹配,或者我们用完了侧栈中的空间,我们只需从头开始重新构建一个新的。我们需要在应用程序代码执行时保留LR的值,这样应用程序就不能使用它来检测跟踪器(反调试)的存在,或者如果它用于任何其他目的,而不仅仅是返回(例如,在代码部分引用内联数据)。此外,我们希望跟踪者能够在任何时候取消跟踪,所以我们不希望不得不回到我们的堆栈纠正LR值,我们已经修改了一路。
最后,虽然我们总是用调用Stalker来替换分支来检测下一个块,这取决于Stalker的配置。trustThreshold时,我们可能会对这样的检测代码进行回补,以将调用替换为直接到下一个检测块的分支。确定性分支(例如,目的地是固定的,分支是没有条件的)很简单,我们可以用一个到下一个块的分支替换到Stalker的分支。但是我们也可以处理条件分支,如果我们同时检测两个代码块(一个如果分支被占用,一个如果没有)。然后,我们可以用一个条件分支替换原来的条件分支,这个条件分支将控制流引导到在获取分支时遇到的块的检测版本,然后用一个无条件分支引导到另一个检测块。 我们还可以部分处理目标不是静态的分支。假设我们的分支是这样的:
br x0
这类指令在调用函数指针或类方法时很常见。虽然X0的值可以改变,但它通常都是相同的。在这种情况下,我们可以用代码替换最后的分支指令,该代码将X0的值与我们已知的函数进行比较,如果它匹配分支与代码的检测副本的地址。然后,如果它不匹配,可以随后返回到Stalker引擎的无条件分支。所以如果函数指针的值被改变了,那么代码仍然可以工作,我们将重新进入Stalker和instrument。然而,如果我们期望它保持不变,那么我们可以完全绕过Stalker引擎,直接进入仪器功能。