注意:这是关于堆栈跟踪规避的第二部分博客。第一部分可以在这里找到。
这是我 3 天前写的关于代理 DLL 加载以隐藏导致用户分配的 RX 区域的可疑堆栈跟踪的博客的第二部分。我不会深入讨论堆栈的工作原理,因为我已经在之前的博客中介绍了这一点,可以从上面的链接访问。我们之前看到,我们可以操纵 和 指令来请求窗口回调以调用 API 调用。但是,堆栈跟踪检测远远超出了搜寻 DLL 加载的范围。将反射 DLL 注入本地或远程进程时,必须调用 API 调用,例如 /,它间接调用 /。但是,当您检查合法 API 调用的调用堆栈时,您会注意到像 WINAPI 这样的主要由非 Windows DLL 函数调用。大多数Windows DLL将直接调用/。下面是调用 时的调用堆栈的快速示例。
.calljmpLoadLibraryVirtualAllocExVirtualProtectExNtAllocateVirtualMemoryNtProtectVirtualMemoryVirtualAlloc/VirtualProtectNtAllocateVirtualMemoryNtProtectVirtualMemoryNtProtectVirtualMemoryRtlAllocateHeap
原文链接:https://0xdarkvortex.dev/hiding-in-plainsight/
这意味着,由于 ntdll.dll 不依赖于任何其他 DLL,因此 ntdll 中所有需要对内存区域使用权限的函数都将直接调用 NTAPI。因此,这意味着如果我们能够通过 ntdll 本身的干净堆栈重新路由我们的调用.dll我们根本不用担心检测。大多数红队依靠间接系统调用来避免检测。对于间接系统调用,您只需在仔细创建堆栈后跳转到指令地址,但这里的问题是间接系统调用只会更改 ntdll.dll 中的指令。 在这种情况下,是系统调用指令在系统调用完成后需要返回到的位置。但是,返回地址下方的其余堆栈在从 RX 区域出现时仍然会受到怀疑。如果 EDR 检查 NTAPI 的完整堆栈,则可以轻松识别返回地址最终会返回到用户分配的 RX 区域。这意味着,返回地址到 ntdll.dll 区域,但源自 RX 区域的堆栈是 100% 异常,误报的可能性为零。对于在内核中使用 ETW 进行系统调用跟踪的 EDR 来说,这是一个轻松的胜利。NtAllocateVirtualMemory
syscall
return address
syscall
Return Address
因此,为了避免这种情况,我花了一些时间反转了几个 ntdll.dll 函数,发现只要有一点汇编知识和 Windows 回调的工作原理,我们应该能够操纵回调来调用任何 NTAPI 函数。对于此博客,我们将以其中为例,我们将从我们的部分 I 博客中选择代码并对其进行修改。我们将以可以执行回调函数的相同 API 为例。但是,这次我们将传递指向结构的指针,而不是像在 Dll 代理的情况下那样传递指向字符串的指针。这次我们还将通过确保所有必要的信息都在结构中来避免任何全局变量,因为我们在编写shellcode时不能有全局变量。根据 msdn 的定义是:NtAllocateVirtualMemory
TpAllocWork
NtAllocateVirtualMemory
1 2 3 4 5 6 7 8 |
__kernel_entry NTSYSCALLAPI NTSTATUS NtAllocateVirtualMemory( [in] HANDLE ProcessHandle, [in, out] PVOID *BaseAddress, [in] ULONG_PTR ZeroBits, [in, out] PSIZE_T RegionSize, [in] ULONG AllocationType, [in] ULONG Protect ); |
这意味着,我们需要将结构内的指针及其参数传递给回调,以便我们的回调可以从结构中提取这些信息并执行它。我们将忽略保持静态的参数,例如哪个总是零,哪个总是十六进制中的哪个是。因此,添加其余参数,结构将如下所示:NtAllocateVirtualMemory
ULONG_PTR ZeroBits
ULONG AllocationType
MEM_RESERVE|MEM_COMMIT
0x3000
1 2 3 4 5 6 7 |
typedef struct _NTALLOCATEVIRTUALMEMORY_ARGS { UINT_PTR pNtAllocateVirtualMemory; // pointer to NtAllocateVirtualMemory - rax HANDLE hProcess; // HANDLE ProcessHandle - rcx PVOID* address; // PVOID *BaseAddress - rdx; ULONG_PTR ZeroBits - 0 - r8 PSIZE_T size; // PSIZE_T RegionSize - r9; ULONG AllocationType - MEM_RESERVE|MEM_COMMIT = 3000 - stack pointer ULONG permissions; // ULONG Protect - PAGE_EXECUTE_READ - 0x20 - stack pointer } NTALLOCATEVIRTUALMEMORY_ARGS, *PNTALLOCATEVIRTUALMEMORY_ARGS; |
然后,我们将使用所需的参数初始化结构,并将其作为指针传递给并调用用汇编编写的函数。TpAllocWork
WorkCallback
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 33 34 35 36 37 38 39 40 41 42 43 |
#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); typedef struct _NTALLOCATEVIRTUALMEMORY_ARGS { UINT_PTR pNtAllocateVirtualMemory; // pointer to NtAllocateVirtualMemory - rax HANDLE hProcess; // HANDLE ProcessHandle - rcx PVOID* address; // PVOID *BaseAddress - rdx; ULONG_PTR ZeroBits - 0 - r8 PSIZE_T size; // PSIZE_T RegionSize - r9; ULONG AllocationType - MEM_RESERVE|MEM_COMMIT = 3000 - stack pointer ULONG permissions; // ULONG Protect - PAGE_EXECUTE_READ - 0x20 - stack pointer } NTALLOCATEVIRTUALMEMORY_ARGS, *PNTALLOCATEVIRTUALMEMORY_ARGS; extern VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work); int main() { LPVOID allocatedAddress = NULL; SIZE_T allocatedsize = 0x1000; NTALLOCATEVIRTUALMEMORY_ARGS ntAllocateVirtualMemoryArgs = { 0 }; ntAllocateVirtualMemoryArgs.pNtAllocateVirtualMemory = (UINT_PTR) GetProcAddress(GetModuleHandleA("ntdll"), "NtAllocateVirtualMemory"); ntAllocateVirtualMemoryArgs.hProcess = (HANDLE)-1; ntAllocateVirtualMemoryArgs.address = &allocatedAddress; ntAllocateVirtualMemoryArgs.size = &allocatedsize; ntAllocateVirtualMemoryArgs.permissions = PAGE_EXECUTE_READ; FARPROC pTpAllocWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpAllocWork"); FARPROC pTpPostWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpPostWork"); FARPROC pTpReleaseWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpReleaseWork"); PTP_WORK WorkReturn = NULL; ((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)WorkCallback, &ntAllocateVirtualMemoryArgs, NULL); ((TPPOSTWORK)pTpPostWork)(WorkReturn); ((TPRELEASEWORK)pTpReleaseWork)(WorkReturn); WaitForSingleObject((HANDLE)-1, 0x1000); printf("allocatedAddress: %p\n", allocatedAddress); getchar(); return 0; } |
现在这就是事情变得有趣的地方。在DLL代理的情况下,我们只用一个参数执行,即要加载的DLL的名称,该名称被传递到寄存器。但在 的情况下,我们总共有 6 个参数。这意味着前四个参数进入快速调用寄存器,即 和。但是,在为我们的 4 个寄存器分配一些归位空间后,必须将剩余的两个参数推送到堆栈。请注意,我们的堆栈顶部当前包含 0ffset 0x130 处的内部 NTAPI 函数的返回值。这就是调用回调函数时调用堆栈的样子。LoadLibrary
RCX
NtAllocateVirtualMemory
RCX, RDX, R8
R9
TppWorkpExecuteCallback
WorkCallback
现在问题来了。如果你修改返回地址所在的堆栈顶部,为 4 个寄存器添加归位空间并向其添加参数,整个堆栈帧将陷入折腾和混乱堆栈展开。因此,我们必须在不更改堆栈帧本身的情况下修改堆栈,而只需更改堆栈帧中的值。每个都从上图所示的蓝线开始和结束。我们的堆栈框架本身有足够的空间来容纳 6 个参数。因此,我们的下一步是从我们的结构中提取数据,并将其移动到相应的寄存器和堆栈中。当我们调用时,我们将指向结构的指针传递给函数,这意味着我们指向结构的指针现在应该在寄存器中。我们结构中的每个值为 8 个字节(对于 x64,对于 x86,它将是 4 个字节)。因此,我们将从结构中提取这些 QWORD 值,并在调整归位空间后将其移动到堆栈上的剩余值。根据 msdn 文档,窗口中 x64 函数的调用约定为:stack frame
TppWorkpExecuteCallback
NTALLOCATEVIRTUALMEMORY_ARGS
TpAllocWork
NTALLOCATEVIRTUALMEMORY_ARGS
WorkCallback
RDX
RCX, RDX, R8, R9
1 2 3 4 5 6 7 8 |
__kernel_entry NTSYSCALLAPI NTSTATUS NtAllocateVirtualMemory( [in] HANDLE ProcessHandle, // goes into rcx [in, out] PVOID *BaseAddress, // goes into rdx [in] ULONG_PTR ZeroBits, // goes into r8 [in, out] PSIZE_T RegionSize, // goes into r9 [in] ULONG AllocationType, // goes to stack after adjusting homing space for 4 arguments [in] ULONG Protect // goes to stack below the 5th argument after adjusting homing space for 4 arguments ); |
将此逻辑融合到汇编将如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
section .text global WorkCallback WorkCallback: mov rbx, rdx ; backing up the struct as we are going to stomp rdx mov rax, [rbx] ; NtAllocateVirtualMemory mov rcx, [rbx + 0x8] ; HANDLE ProcessHandle mov rdx, [rbx + 0x10] ; PVOID *BaseAddress xor r8, r8 ; ULONG_PTR ZeroBits mov r9, [rbx + 0x18] ; PSIZE_T RegionSize mov r10, [rbx + 0x20] ; ULONG Protect mov [rsp+0x30], r10 ; stack pointer for 6th arg mov r10, 0x3000 ; ULONG AllocationType mov [rsp+0x28], r10 ; stack pointer for 5th arg jmp rax |
要解释上面的代码:
- 我们首先将指向寄存器中结构的指针备份到寄存器中。我们这样做是因为我们将用第二个参数来踩踏RDX寄存器,当我们调用它时
RDX
RBX
NtAllocateVirtualMemory
- 我们将前 8 个字节从寄存器中的地址(即)移动到 rax 寄存器,在调整参数后我们将跳转到
RBX
struct NTALLOCATEVIRTUALMEMORY_ARGS
UINT_PTR pNtAllocateVirtualMemory
- 我们将第二组 8 字节 () 从结构移动到
HANDLE hProcess
RCX
- 我们将第三组 8 个字节,即指向存储在结构中的 NULL 指针 () 的指针移动到 .这是我们分配的地址将写入的地方
PVOID* address
RDX
NtAllocateVirtualMemory
- 我们将参数的寄存器清零
R8
ULONG_PTR ZeroBits
- 我们将第 6 个参数,即最后一个参数移动到所有参数 () 的底部到 r10,然后将其移动到堆栈指针顶部的偏移量。
ULONG Protect i.e. PAGE permissions
0x30
- 堆栈顶部指针 = RSP = 返回地址为 8 个字节
TppWorkpExecuteCallback
- 4 个参数的归位空间大小 = 4×8 = 32 字节
- 第 5 个参数的空间 = 8 个字节
- 因此 32+8 = 40 = 0x28(这是倒数第二个第 5 个参数将出现的地方)
- 因此 32+8+8 = 48 = 0x30(这是最后一个第 6 个参数将要去的地方)
- 堆栈顶部指针 = RSP = 返回地址为 8 个字节
- 我们最后移动第 5 个参数值 (),即 到寄存器,然后将其推送到RSP偏移
ULONG AllocationType
0x3000 - MEM_COMMIT|MEM_RESERVE
R10
0x28
将它们全部编译在一起,这是跳转到之前的样子:NtAllocateVirtualMemory
- 反汇编的代码显示了我们编写的 asm 指令。当前指令指针刚好在调整堆栈之后,跳转到
NtAllocateVirtualMemory
- 寄存器显示参数
NtAllocateVirtualMemory
- 转储显示内存中的结构。每个 8 字节的内存块都是一个与结构内容相关的对象
NTALLOCATEVIRTUALMEMORY_ARGS
- 堆栈显示调整后的堆栈
NtAllocateVirtualMemory
在执行 后快速查看堆栈会显示一个可以完美展开的有效调用堆栈。您还可以看到系统调用返回零,这意味着调用成功。NtAllocateVirtualMemory
NtAllocateVirtualMemory
烟囱又像水晶一样清晰,没有任何恶意的迹象。请注意,这是堆叠欺骗,因为在我们的例子中,堆栈完全展开而不会崩溃。还有更多这样的 API 调用可用于代理各种函数;我将留给读者使用自己的创造力。即将发布的 BRc4 将使用类似的东西,但具有不同的 API 调用集,这些调用完全未记录,并且将位于称为 .完整的代码可以在我的github存储库中找到。not
stealth++