██████╗ ███████╗ █████╗ ████████╗██╗ ██╗███████╗██╗ ███████╗███████╗██████╗
██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██║ ██║██╔════╝██║ ██╔════╝██╔════╝██╔══██╗
██║ ██║█████╗ ███████║ ██║ ███████║███████╗██║ █████╗ █████╗ ██████╔╝
██║ ██║██╔══╝ ██╔══██║ ██║ ██╔══██║╚════██║██║ ██╔══╝ ██╔══╝ ██╔═══╝
██████╔╝███████╗██║ ██║ ██║ ██║ ██║███████║███████╗███████╗███████╗██║
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝╚═╝
一种逃避技术的概念验证实现,用于终止当前线程并在恢复执行前重新创建它,同时在无执行期间实施页面保护更改。

休眠与混淆技术在恶意软件开发社区中广为人知,不同的实现方式旨在隐藏shellcode以躲避内存扫描器,通常的做法包括更改页面保护,甚至加入加密shellcode等酷炫功能。但还有一个重要方面需要隐藏我们的shellcode,那就是隐藏当前执行线程。 栈欺骗很酷,但经过深思熟虑后,我认为既然没有栈,就没有必要欺骗栈 :)
该技术的实用性留给读者自行评估,但无论如何,我认为这是一种复习某些主题的好方法,并且对于像我这样初入此领域的人来说,也能学习一些恶意软件开发。
这里展示的主要实现将所有需要从栈中移出的内容保存在数据段中(作为全局变量),但不久后将发布一个将所有内容移至堆的实现。该实现旨在展示为使代码成为位置无关代码(PIC)且可注入所需的一些关键修改。
这里所述的一切均源于我对所涉及主题的理解,无论是通过阅读还是开发过程中的经验。我深知自己并非专家,最不愿意做的就是传播错误信息,因此如果您认为某些内容不正确,我非常希望您能告知我。您可以通过 Twitter 联系我,或在本仓库中开 issue。非常感谢您的理解 :)
该技术的主要目标是明确的:终止当前线程,并在恢复执行前重新创建它。但这究竟意味着什么?又带来了哪些新的限制?
为了能够恢复执行,我们需要在终止线程前保存两件事:首先是 CPU 状态,其次是栈,并在新线程启动后有效地重新设置它们。
我提到了这项技术将出现的新限制,主要有两个:第一,从线程终止到栈恢复期间,我们需要将任何必需的内容存储在栈之外,正如您将看到的,这带来了一些新的挑战。
第二,我们始终需要至少另一个线程在进程中运行,因为我们正在终止当前线程,如果没有其他线程,进程将会结束。我认为这不是一个大问题,因为大多数代理都是注入到其他进程中的,我们可以假设该进程至少会保持一个线程在运行。
在这个 PoC 中,我们可以观察到 4 个核心功能:
当我们即将保存栈时,出现了一个问题:需要保存多少栈?
首先,让我们回顾一下在调用 DeathSleep 函数后栈中有什么(这个函数负责保存上下文、栈,并为混淆和恢复做好准备)。

如我们所见,每个函数由三部分组成:
我们显然需要保存的最小栈部分是我们主程序中的所有内容,即其影子空间、返回地址以及直到 DeathSleep 函数的所有内容。 之后的内容实际上并不需要(保存入口函数使用的栈有其好处,我们稍后会讨论),因为那是 Windows 例程用于启动新线程的栈空间。 除此之外,我决定也存储 DeathSleep 函数的影子空间(并非必需,但使唤醒时的 Rsp 计算更容易)。
因此,最终我们保存了这些内容:

在标准编译中,每个函数应由三部分组成:序言、函数代码和尾声。
Rsp(栈指针)只能在函数序言和尾声中修改。序言增加栈指针(记住增加栈意味着地址减小,因为它们方向相反),以保存寄存器、容纳所有局部变量,然后容纳影子空间;尾声则正好相反。
这意味着函数代码内部的栈指针应始终指向影子空间的末尾(上图中的紫色部分),而函数栈大小与影子空间之和可以通过计算序言增加了多少栈指针来得出。该值可以轻松地使用 unwind 表中的信息计算出来,关于其使用的解释我们将不在此详述,但简单来说,这些表用于允许任何其他线程或进程正确地遍历栈以查看其内容、处理异常或进行分析。
捕获上下文可能是最简单的部分之一,我们只需在 DeathSleep 的第一行调用 RtlCaptureContext(),在任何非易失性寄存器被修改之前完成。 我们仍需要对将恢复执行的上下文进行两次修改。
第一次修改是修改其 Rip,如您所知,这是保存下一条要执行的指令的寄存器,如果直接保留不变,执行将在 DeathSleep 函数内部恢复。我们要做的是将 Rip 改为指向 DeathSleep 的返回地址,即当前 Rsp 指向的地址加上尾声增加的大小(上图中的绿色 + 紫色区域)。
第二次修改将在线程恢复时进行,涉及设置 Rsp 指向已恢复栈的顶部;这将在恢复阶段完成,因为我们不知道新栈将放置在哪里。该值将简单地是我们的已恢复栈的结束地址,因为正如我们稍后讨论的,我们也复制了 DeathSleep 的调用者预留的影子空间,这正是调用 DeathSleep 之前的 RSP 值。
一旦我们到达唤醒点,就在恢复执行之前,我们需要将保存的栈放回原位。 由于我们已经知道保存的栈从唤醒函数捕获的地址开始,因此新捕获的地址将是我们放置已保存栈的起点,但这会带来一个问题:在我们放置旧栈之后对函数的任何调用都会修改它并破坏它,而在这里进行清理非常方便,特别是释放用于保存栈备份的堆。 这意味着我们需要将当前的 Rsp 以及我们当前使用的栈部分移动到一个不会与我们将放置的已恢复栈冲突的地方。 尝试使其更清晰,问题如下:

以下是我的解决方案,只需将所有内容移开:

在完成所有艰苦工作后,最后一步是使用 NtContinue。该函数允许我们用之前捕获并修改的上下文更改当前上下文,将 RIP 设置为 DeathSleep 调用之后的指令,所有寄存器应具有调用 DeathSleep 时的相同值,RSP 应指向栈顶。
好的,我们了解了存储和恢复当前线程所需的基础知识,但我们需要某种方式能够在没有线程时运行所有这些。这时我们遇到了可爱的线程池 API,Windows 提供的工具,允许我们将任务(最多带一个参数的函数)排队到一组由操作系统完全管理的线程(池)中。 如果您看过 Ekko,您可以看到它使用了这个 API,所以……让我们以相同的方式实现它。
一切运行良好,但有一个问题:即使在执行完排队任务后,工作线程仍然存在。这是一个问题,因为我希望销毁我们的程序可能生成的所有线程,所以这不是最佳方案。
在深入挖掘后,我发现 Ekko 中使用的线程池 API 是旧版本,有一个具有更多功能的新版本,其中有一个函数可以有效解决我们的问题:CloseThreadPool()。这个新 API 允许我们创建自己的池,并在使用后销毁它,终止所有使用过的工作线程。它还提供了另外两个优势:设置最大线程数以及清理组。 设置最大线程数将允许我们顺序执行所有任务,只要它们以任何时间差排队即可。 清理组对于在所有完成后更容易地清理很有用。
那么……是否一切都完成了?嗯,此时线程已终止,我们正在排队重生函数,该函数创建新线程,以唤醒函数为入口点,恢复之前的状态,并关闭池,到目前为止一切顺利!
当我完成我们之前讨论的所有内容时,我认为困难的部分已经解决,因为这部分已经被以前的技术解决了,但哦,天哪,我不知道接下来会发生什么。
主要问题是我们需要将其卸载到我们的代码之外,因为我们正在将内存保护更改为 RW(读写),如果我们调用 VirtualProtect(),当函数返回时,我们的进程将崩溃(我们无法在 RW 页面上执行指令),所以我们需要找到一种方法从其他地方执行此操作,并使其也返回到 RX(读取执行)页面(返回时也是如此)。 显然,我们将再次使用线程池 API,但有一个问题:我们只能给任务传递一个参数,而 VirtualProtect() 需要 4 个参数。
为此,我们将再次使用 NtContinue(),我第一次看到将此函数用于此目的是在 Foliage 中,但 Ekko 也使用了它。 正如我们之前所见,NtContinue() 允许我们为调用它的线程设置某个上下文,通过一些巧妙的调整,它可以通过仅使用一个参数(对于线程池 API 非常方便)来“调用”一个带有多个参数的函数。 主要思想是将 RIP 设置为函数的起始地址,并且由于 Windows x64 调用约定将前四个参数传递给寄存器(rcx、rdx、r8、r9,按此顺序),只需将您的参数放入您将传递给 NtContinue 的上下文结构中,它就会有效地模拟对函数的调用。 使用 NtContinue 时,我们最后要处理的是 Rsp,因为正如我们之前所见,该地址应持有函数被调用时的返回地址。
因此 NtContinue 工作所需的第一件事是获取一个上下文,我们可以手动构建它,但会遇到一个问题:找到 Rsp 的值,当传递给我们的函数时,该地址将指向 RET 用于返回的地址。 我们的任务将在不同的线程中工作,所以我们不知道其栈会放在哪里。 解决方案(小心地从 Ekko 窃取,非常感谢 :P)是在工作线程中使用 RtlCaptureContext() 获取上下文的副本,并将获取的上下文的栈指针增加 8,这样它将指向由 CALL RtlCaptureContext() 在栈中引入的地址,这正好是后者的返回地址,我们可以将其用作我们所有函数的返回地址。
好吧,这很不错,但是当我们无法对 Rsp 进行此修改时会发生什么? 这就是当我们去混淆时发生的情况,我们将在一个新线程中,因此旧上下文的 Rsp 是无用的。我们需要一个新的上下文,从新线程获取,但我们不能使用修改 Rsp 使其指向正确地址的旧技巧。
所以我们无法修改获取的上下文,但这并不意味着它无用,实际上我们将使用它,但以不同的方式。如果我们仅使用 NtContinue() 恢复该上下文而不修改其 Rip,它将简单地将执行重定向到调用 RtlCaptureContext() 之后的下一条指令,并且具有正确的 Rsp,因此我们可以在使用修改过的上下文调用 NtContinue() 后使用它,以正确结束我们的任务执行。 为此,我们将使用 Rop 链,将第一个上下文的 Rsp 设置为指向手动构建的栈,该栈将持有我们需要的一切,以将执行重定向到第二个 NtContinue() 调用,该调用将设置正确的上下文以结束。
这是我们构建的栈应如下所示:

我们使用 2 个 Rop gadget,一个用于修复或“跳过”我们函数的影子空间,第二个负责将 NtContinue 的参数放入 rcx,然后返回到它。
找到这两个 Rop gadget 很容易,修复影子空间的那个几乎是任何函数的尾声(我仅在 Ntdll 中就找到了超过 500 个匹配项),因为正如我们之前所见,尾声主要设计用于减少 Rsp;第二个是简单的 pop rcx; ret;,只有 2 个字节,并且在 Ntdll 和 Kernel32 之间也找到了几个。
正如我们所见,使用 NtContinue 只需要填充其第一个参数即可工作,这对于旧线程池 API 来说非常完美,但在新线程池 API 中,参数在第二个位置传递,所以是的,仅此一项是无法工作的。
在花了几个小时不知道如何解决这最后一个问题后,我突然想到两个 API 在某些情况下使用了相同的函数,这让我觉得它们可能比看起来更相似,所以我决定研究它们之间的关系。
对于旧 API,我们使用 CreateTimerQueueTimer() 来排队任务,而在新 API 中,我们需要两个函数来完成相同的工作: CreateThreadpoolTimer(),它将接受回调函数和要传递给它的参数,并返回一个指向描述任务的 TP_TIMER 结构的指针,以及第二个函数来排队任务: SetThreadpoolTimer(),它将接受前一个指针和一个指向 FILETIME 结构的指针,该结构描述任务何时执行。
如果逆向这些函数,我们将发现:

因此,正如我们所看到的,CreateThreadpoolTimer() 只是 TpAllocTimer() 的一个花哨包装器,而 SetThreadpoolTimer() 只是 TpSetTimer() 的一个转发器。
现在让我们检查 CreateTimerQueueTimer() 的内部。 起初,它只是 Ntdll 中 RtlCreateTimer() 函数的另一个花哨包装器,这里是奇迹发生的地方。这是一个更大的函数,但这是我们正在寻找的金矿:

正如您所见,在此函数内部,实际上调用了 TpAllocTimer() 和 TpSetTimer(),这类似于说它在内部调用了 CreateThreadpoolTimer() 和 SetThreadpoolTimer()。如我们所见,我们正在排队的函数并非直接是我们提供给函数的回调,而是将 RtlpTpTimerCallback() 设置为回调。 如果您还没有意识到这意味着什么,那就是我们正在使用 CreateThreadpoolTimer() 来排队一个在第二个位置接收参数的回调函数 RtlpTpTimerCallback(),该函数将执行另一个在第一个位置接收参数的函数。
因此,我们仍然需要了解的是回调信息如何传递给 RtlpTpTimerCallback(),经过一些逆向后,我得到了以下结构,惊喜的是,它有效!

现在,我们可以调用在第一个位置接收参数的函数,同时能够关闭我们的池,并且不留下任何线程运行,双赢。 值得注意的是,此函数在 Ntdll 中并未导出,因此我决定通过其在 DLL 中的字节形式来找到它。
这就是结束,并且回顾了所有内容,我认为我给出了在开发此 PoC 时浮现在我脑海中的核心思想,以及为什么一切按我这样做的方式进行。
此代码仅使用 MSCV 编译器和链接器进行测试,由于此 PoC 严重依赖于编译方式,我建议使用相同的工具,并且不保证它能在其他编译器开箱即用。