这是一个用于演示回避技术的 PoC 实现,可以终止当前线程并在恢复执行之前还原它,同时在无执行期间实现页面保护更改。
作者:Sec-Labs | 发布时间:
项目地址
https://github.com/janoglezcampos/DeathSleep
一个绕过技术的 PoC 实现,可以在不执行代码的情况下更改页面保护并终止当前线程,然后在恢复执行之前恢复它。
简介
睡眠和混淆方法在恶意开发社区中是众所周知的,有不同的实现方式,它们的目的是在睡眠时隐藏内存扫描器,通常更改页面保护甚至添加一些酷炫的功能,如加密 shellcode,但隐藏我们的 shellcode 的另一个重要点是隐藏当前执行线程。虚假的堆栈很酷,但是经过一番思考,我认为没有必要伪造堆栈... 如果没有堆栈 :)
该技术的可用性由读者自行评估,但在任何情况下,我认为这是一个复习一些主题并学习一些恶意开发的酷方法,对于像我这样正在开始进入这个世界的人来说。
这里展示的主要实现将我们需要从堆栈中取出的所有内容存储在数据段中,作为全局变量,但是一个将所有内容移动到堆中的实现很快就会发布。它旨在展示一些关键修改,以使此代码成为可注入的代码。
该存储库在 GitHub 和 GitLab 之间进行镜像。
究竟是怎么回事
首先
这里所述的一切都来自我对所涵盖的不同主题的理解,无论是从阅读还是从开发经验中获得的。我知道我不是专家,我最不想做的就是传播错误信息,因此,如果您认为有什么不正确的地方,请让我知道,您可以通过 Twitter 或在此存储库中开启问题。非常感谢您的理解。 :)
基础知识
该技术的主要目标很明确,即终止当前线程并在恢复执行之前恢复它,但这究竟意味着什么,它又会提出哪些新的限制?
为了能够恢复执行,我们需要在终止线程之前保存两个东西,第一是 CPU 状态,第二是堆栈,并在启动新线程后有效地设置它们。
我提到了这种技术将出现的新限制,有两个很大的限制:首先,我们需要在堆栈之外存储从线程终止到堆栈恢复所需的任何内容,正如您将看到的,这会产生一些新的挑战。
其次,我们始终需要至少另一个线程在我们的进程中运行,因为我们正在终止我们的线程,如果没有其他线程,进程将结束。我认为这不是一个大问题,因为大多数代理都是注入到其他进程中的,我们可以假设该进程将保持至少一个线程在运行。
DeathSleep 的组件:
我们可以在这个 PoC 中看到 4 个核心函数:
- 主程序:这是您将编写代理代码的地方,也是代码的一部分,它将使用 DeathSleep。
- Awake 函数: 这是所有线程的入口点,负责保存我们将要恢复的堆栈的起始点。还负责在需要时恢复堆栈和 CPU 上下文,或者只是启动我们的主程序。
- DeathSleep: 这是这种技术的主要功能,负责备份线程上下文和堆栈,并设置一切以进行魔法。
- Rebirth: 一个简单的函数,只负责启动我们的新线程。
保存堆栈
当我们即将保存堆栈时,会出现一个问题:需要保存多少堆栈?
首先让我们回顾一下在我们调用 DeathSleep 函数后堆栈中的内容(这是保存上下文、堆栈并准备所有混淆和恢复的函数):

正如我们看到的,每个函数都有三个部分:
- Shadow space:这是调用者分配的32字节空间,但由被调用者使用。据我所知,它的主要功能是保存被传递给被调用函数的寄存器中的参数,但可以用于被调用函数决定的任何内容。
- Return address: 这是下一个要在调用函数中执行的地址,由 CALL 指示推入,因此 callee 中的 RET 指令将取此地址并在结束时“跳转”到它。
- 函数堆栈空间: 这是由 callee 保留的空间,用于存储需要恢复的寄存器值和其本地变量的值。
我们显然需要保存的堆栈的最小部分是我们主程序中的所有内容,也就是它的 shadow space、return address 以及 DeathSleep 函数之前的所有内容。任何在此之前的内容都不是真正必需的(保存 entry 函数使用的堆栈具有其优点,但我们将在稍后讨论它),因为那是 Windows 例程用于启动新线程的堆栈。除此之外,我决定还要存储 DeathSleep 函数的阴影空间(不是真正需要的,但它使在唤醒时计算 Rsp 更容易)。
所以最终,我们保存了这些:

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

而这是我的解决方案,只需将所有东西移动到一边:

恢复上下文
经过我们所有艰苦的工作,最后要做的是使用 NtContinue,这个函数允许我们将当前上下文更改为先前捕获并修改的上下文,将 RIP 设置为在 DeathSleep 调用后恢复执行的位置。所有寄存器应该与调用 DeathSleep 时的值相同,RSP 应该指向堆栈的顶部。
改变内存权限和重定向执行
在结束之前,我们需要将所有重要数据离线处理,因为我们需要在代码外部进行内存保护修改,而VirtualProtect()函数只能在进程内修改内存保护,否则进程将崩溃。我们需要找到一种方法来执行此操作并使其返回到RX(读取-执行)内存页。我们将再次使用线程池API,但问题是我们只能将一个参数传递给我们的任务,而VirtualProtect()需要四个参数。
为此,我们将再次使用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的旧技巧来指向正确的地址。
Rop链
因此,我们无法修改获得的上下文,但这并不意味着它无用,事实上,我们将以不同的方式使用它。如果我们只是使用NtContinue()恢复该上下文,而不修改它的Rip,它将仅将执行重定向到RtlCaptureContext()调用之后的下一个指令,并且具有正确的Rsp,因此我们可以在使用NtContinue()修改上下文后,使用它来正确结束任务的执行。为此,我们将使用Rop链,将我们的第一个上下文的Rsp设置为指向手动创建的堆栈,该堆栈将保存我们需要重定向执行的所有内容,直到第二个NtContinue()调用设置正确的上下文以结束执行。
这是我们创建的堆栈应该看起来的样子:

我们利用了两个ROP小工具,一个用于修复或“跳过”函数的影子空间,第二个则负责将NtContinue的参数放置在rcx中,然后返回到它。
找到这两个ROP小工具相当容易,用于修复影子空间的是几乎任何函数的追踪调试(我在Ntdll中发现了500多个追踪调试),因为正如我们之前看到的,追踪调试主要设计用于减少Rsp,第二个小工具只是一个pop rcx; ret;,只有2个字节,我在Ntdll和Kernel32 dll之间也找到了几个这样的小工具。
对线程池API的简单反向工程
正如我们所看到的,只需要填充NtContinue的第一个参数就可以使用它,这与旧线程池API非常匹配,但在新的线程池API中,参数在第二个位置传递,所以单独使用NtContinue不能实现这个目的。
在经过几个小时的不知如何解决这个问题后,我突然想到两个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时我脑海中出现的核心思想,以及为什么要以我这样的方式完成所有事情。