注意:这是关于堆栈跟踪规避的第一部分博客。第二部分可以在这里找到。
自从我实际上写过任何关于Dark Vortex的博客以来已经有一段时间了(不包括Brute Ratel的博客,只是原始研究),因此我决定在这里添加这篇文章。此博客简要概述了堆栈跟踪、EDR/AV 如何使用它进行检测、ETWTI 遥测的用法以及如何规避它。去年,我在Brute Ratel上发布了一篇博客,这是第一个提供DLL加载内置代理以避免检测的命令和控制,后来被其他C2采用,如夜鹰,具有一组不同的API()来避免检测。因此,在讨论规避之前,让我们首先了解为什么堆栈跟踪对 EDR 很重要。RtlQueueWorkItem
原文链接:https://0xdarkvortex.dev/proxying-dll-loads-for-hiding-etwti-stack-tracing/
什么是堆栈?
在计算机科学中描述“堆栈”的最简单方法是临时内存空间,其中局部变量和函数参数以不可执行的权限存储。此堆栈可以包含有关线程和执行线程的函数的多个信息。每当进程执行新线程时,都会创建一个新堆栈。堆栈从下到上增长并以线性方式工作,这意味着它遵循“后进先出”原则。“RSP”(x64)或“ESP”(x86)存储线程的当前堆栈指针。Windows 中线程的每个新默认堆栈大小为 1 MB,除非开发人员在创建线程期间明确更改。这意味着,如果开发人员在编码时不计算和增加堆栈大小,堆栈最终可能会达到堆栈边界(称为堆栈金丝雀的替代方法)并引发异常。通常,msvcrt 中的例程的任务是探测堆栈.dll并在需要更多堆栈时引发异常。因此,如果您编写一个需要大堆栈的位置无关的shellcode(因为PIC中的所有内容都存储在堆栈上),则shellcode将崩溃引发异常,因为您的PIC不会链接到msvcrt.dll中的例程。当线程启动时,线程可能包含多个函数的执行和各种不同类型的变量的使用。与需要手动分配和释放的堆不同,我们不必手动计算堆栈。当编译器(mingw gcc 或 clang)编译 C/C++ 代码时,它会自动计算所需的堆栈并在代码中添加所需的指令。因此,当您的线程运行时,它将首先从 1 MB 的保留堆栈中分配堆栈上的“x”大小。以以下示例为例:_chkstk
_chkstk
1 2 3 |
void samplefunction() { char test[8192]; } |
在上面的函数中,我们只是创建一个 8192 字节的变量,但这不会存储在 PE 中,因为它会不必要地最终占用磁盘上的空间。因此,这些变量由编译器优化并转换为指令,例如:
1 |
sub rsp, 0x2000 |
上面的汇编代码从堆栈中减去 0x2000 个字节(8192 十进制),函数将在运行时使用这些字节。简而言之,如果你的代码需要清理一些堆栈空间,它会向堆栈添加字节,而如果它需要一些堆栈空间,它将从堆栈中减去。线程中每个函数的堆栈将被转换为一个块,称为堆栈帧。堆栈帧提供了清晰简洁的视图,包括上次调用哪个函数、从内存中的哪个区域调用、该帧使用了多少堆栈、帧中存储的变量是什么以及当前函数需要返回到何处。每次您的函数调用另一个函数时,您当前函数的地址都会被推送到堆栈,因此当下一个函数调用 ‘ret’ 或 return 时,它会返回到当前函数的地址以继续执行。一旦当前函数返回到上一个函数,当前函数的堆栈帧就会被销毁,虽然不是完全,但它仍然可以访问,但大多数情况下最终会被调用的下一个函数覆盖。像我对一个 5 岁的孩子一样解释它,它会是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
void func3() { char test[2048]; // do something return; } void func2() { char test[4096]; func3(); } void func1() { char test[8192]; func2(); } |
上面的代码被转换为汇编,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func3: sub rsp, 0x800 ; do something add rsp, 0x800 ret func2: sub rsp, 0x1000 call func3 add rsp, 0x1000 ret func1: sub rsp, 0x2000 call func2 add rsp, 0x2000 ret |
好吧,一个 5 岁的孩子不会理解它,但是你什么时候发现一个 5 岁的孩子在写恶意软件对吗?XD!因此,每个堆栈帧将包含要为变量分配的字节数、由前一个函数推送到堆栈的返回地址以及有关当前函数的局部变量的信息(简而言之)。
EDR 中的“D”在哪里?
这里的检测技术非常聪明。某些 EDR 使用用户空间挂钩,而某些 EDR 使用 ETW 来捕获堆栈遥测数据。例如,假设您想在不踩踏模块的情况下执行shellcode。因此,您可以通过VirtualAlloc或相对NTAPI NtAllocateVirtualMemory分配一些内存,然后复制shellcode并执行它。现在,您的shellcode可能有自己的依赖项,它可能会调用或将dll从磁盘加载到内存中。如果您的 EDR 使用用户空间钩子,则它们可能已经挂钩,在这种情况下,他们可以检查由 RX 外壳代码区域推送到堆栈的返回地址。这是特定于一些EDR,如Sentinel One,Crowdstrike等。这将立即杀死您的有效载荷。其他EDR,如Microsoft Defender ATP(MDATP),Elastic,FortiEDR将使用ETW或内核回调来检查调用的来源。堆栈跟踪将提供返回地址的完整堆栈帧以及从何处开始调用的所有函数。简而言之,如果你执行一个DLL旁加载来执行调用的shellcode,它看起来像这样:LoadLibraryA
LdrLoadDll
LoadLibrary
LdrLoadDll
LoadLibrary
LoadLibrary
LoadLibrary
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|-----------Top Of The Stack-----------| | | | | |--------------------------------------| |------Stack Frame of LoadLibrary------| | Return address of RX on disk | | | |----------Stack Frame of RX-----------| <- Detection (An unbacked RX region should never call LoadLibraryA) | Return address of PE on disk | | | |-----------Stack Frame of PE----------| | Return address of RtlUserThreadStart | | | |---------Bottom Of The Stack----------| |
这意味着在用户模式下或通过内核回调/ETW 挂钩的任何 EDR 都可以检查上次返回地址区域或调用来自何处。在 BRc1 的 v1.4 版本中,我开始使用 API,它可以请求线程池中的工作线程在单独的线程中执行以加载库。加载库后,我们可以通过简单地遍历 PEB(进程环境块)来提取其基址。夜鹰后来将这种技术应用于 API,API 是主要的 NTAPI,它后面还可以将请求排队到工作线程,以加载具有干净堆栈的库。然而,Proofpoint去年的某个时候在他们的博客中对此进行了研究,最近来自Elastic的Joe Desimone也发布了一条关于BRc4正在使用API的推文。这意味着迟早会有检测,并且需要更多可用于进一步规避的此类 API。因此,我决定花一些时间从 ntdll 中反转一些未记录的 API,并发现至少可以通过一些调整和黑客攻击来利用这些 API 来加载我们的 DLL 一个干净的堆栈。LoadLibrary
RtlRegisterWait
LoadLibraryA
RtlQueueWorkItem
QueueUserWorkItem
RtlRegisterWait
27 different callbacks
窗口回调:允许我们自我介绍
回调函数是指向函数的指针,该函数可以传递给要在其中执行的其他函数。微软为软件开发人员提供了大量的回调,以通过其他函数执行代码。在这个github存储库中可以找到很多这些功能,这些功能在过去两年中得到了广泛的利用。但是,所有这些回调都存在一个主要问题。执行回调时,不希望回调与调用方线程位于同一线程中。这意味着,您不希望堆栈跟踪遵循如下跟踪:。为了拥有一个干净的堆栈,我们需要确保我们的 LoadLibrary 在独立于 RX 区域的单独线程中执行,如果我们使用回调,我们需要回调才能将正确的参数传递给 。Windows中的大多数回调要么没有参数,要么没有将参数“按原样”转发到我们的目标函数“LoadLibrary”。以以下代码为例:LoadLibrary returns to -> Callback Function returns to -> RX region
LoadLibraryA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <windows.h> #include <stdio.h> int main() { CHAR *libName = "wininet.dll"; PTP_WORK WorkReturn = NULL; TpAllocWork(&WorkReturn, LoadLibraryA, libName, NULL); // pass `LoadLibraryA` as a callback to TpAllocWork TpPostWork(WorkReturn); // request Allocated Worker Thread Execution TpReleaseWork(WorkReturn); // worker thread cleanup WaitForSingleObject((HANDLE)-1, 1000); printf("hWininet: %p\n", GetModuleHandleA(libName)); //check if library is loaded return 0; } |
如果编译并运行上面的代码,它将崩溃。TpAllocWork的定义是:
1 2 3 4 5 6 |
NTSTATUS NTAPI TpAllocWork( PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment ); |
这意味着我们的回调函数应该是 PTP_WORK_CALLBACK 类型。此类型扩展为:LoadLibraryA
1 2 3 4 5 |
VOID CALLBACK WorkCallback( PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work ); |
如上图所示,我们的 from API 作为辅助参数转发到我们的回调 ()。因此,如果我们的假设是正确的,我们传递给的参数最终将成为我们的第二个参数。但没有第二个论点。在调试器中检查这一点会导致下图:PVOID OptionalArg
TpAllocWork
PVOID Context
libName (wininet.dll)
TpAllocWork
LoadLibraryA
LoadLibraryA
因此,这确实创建了一个干净的堆栈,例如: ,但是我们对 LoadLibrary 的参数作为第二个参数发送,而第一个参数是指向 API 发送的结构的指针。经过更多的反转,我发现这个结构是由 (NOT) 动态生成的,正如预期的那样,它是 ntdll 的内部函数.dll如果没有此 API 的调试符号,就无能为力。LoadLibraryA returns to -> TpPostWork returns to -> RtlUserThreadStart
TP_CALLBACK_INSTANCE
TpPostWork
TppWorkPost
TpPostWork
然而,所有的希望还没有消失。我们可以尝试的肮脏技巧之一是将 Callback 函数替换为自定义函数,然后在自定义函数中通过我们的回调进行调用。像这样:LoadLibrary
TpAllocWork
LoadLibraryA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#include <windows.h> #include <stdio.h> VOID CALLBACK WorkCallback( _Inout_ PTP_CALLBACK_INSTANCE Instance, _Inout_opt_ PVOID Context, _Inout_ PTP_WORK Work ) { LoadLibraryA(Context); } int main() { CHAR *libName = "wininet.dll"; PTP_WORK WorkReturn = NULL; TpAllocWork(&WorkReturn, WorkerCallback, libName, NULL); // pass `LoadLibraryA` as a callback to TpAllocWork TpPostWork(WorkReturn); // request Allocated Worker Thread Execution TpReleaseWork(WorkReturn); // worker thread cleanup WaitForSingleObject((HANDLE)-1, 1000); printf("hWininet: %p\n", GetModuleHandleA(libName)); //check if library is loaded return 0; } |
然而,这意味着,回调将位于我们的 RX 区域中,堆栈将变为:这并不好,因为我们最终做了我们试图避免的同样的事情。其原因是堆栈帧。因为当我们从 调用时,我们最终会推送堆栈的返回地址,最终成为堆栈帧的一部分。但是,如果我们操纵堆栈不推送返回地址怎么办?当然,我们将不得不在汇编中写几行,但这应该完全解决我们的问题,我们可以直接调用 from to,而中间没有错综复杂的问题。LoadLibraryA returns to -> Callback in RX Region returns to -> RtlUserThreadStart -> TpPostWork
LoadLibraryA
Callback in RX Region
Callback in RX Region
TpPostWork
LoadLibrary
最后的技巧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#include <windows.h> #include <stdio.h> typedef NTSTATUS (NTAPI* TPALLOCWORK)(PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment); typedef VOID (NTAPI* TPPOSTWORK)(PTP_WORK); typedef VOID (NTAPI* TPRELEASEWORK)(PTP_WORK); FARPROC pLoadLibraryA; UINT_PTR getLoadLibraryA() { return (UINT_PTR)pLoadLibraryA; } extern VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work); int main() { pLoadLibraryA = GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA"); FARPROC pTpAllocWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpAllocWork"); FARPROC pTpPostWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpPostWork"); FARPROC pTpReleaseWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpReleaseWork"); CHAR *libName = "wininet.dll"; PTP_WORK WorkReturn = NULL; ((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)WorkCallback, libName, NULL); ((TPPOSTWORK)pTpPostWork)(WorkReturn); ((TPRELEASEWORK)pTpReleaseWork)(WorkReturn); WaitForSingleObject((HANDLE)-1, 0x1000); printf("hWininet: %p\n", GetModuleHandleA(libName)); return 0; } |
ASM 代码,用于通过操作堆栈帧将工作回调重新路由到 LoadLibrary
1 2 3 4 5 6 7 8 9 10 11 |
section .text extern getLoadLibraryA global WorkCallback WorkCallback: mov rcx, rdx xor rdx, rdx call getLoadLibraryA jmp rax |
现在,如果您将它们编译在一起,我们的调用,但不调用,而是跳转到其指针。 只需将寄存器中的库名称移动到 , erases ,从 adhoc 函数获取地址,然后跳转到最终重新排列整个堆栈帧而不添加我们的返回地址。这最终使堆栈帧如下所示:TpPostWork
WorkCallback
WorkCallback
LoadLibraryA
WorkCallback
RDX
RCX
RDX
LoadLibraryA
LoadLibraryA
烟囱像水晶一样清晰,没有任何恶意的迹象。在找到这种技术后,我开始寻找类似的其他可以操作的API,并发现只需一点点类似的调整,您实际上就可以在kernel32,kernelbase和ntdll中实现代理DLL加载。我将把它作为一个练习,让这个博客的读者弄清楚这一点。对于Brute Ratel的用户,您将在下一个版本v1.5中找到这些更新。这就是这个博客的全部内容,完整的代码可以在我的github存储库中找到。27 other Callbacks