使用 C++/汇编语言的 Windows 线程池 API 代理 DLL 加载的主题。这个特定示例将使用 I/O 完成回调前,先介绍一些背景信息。
背景
要了解本研究的核心,我们必须想象这样一种情况:我们有一个位置无关格式的有效载荷,它最终将加载 DLL。当我们加载 shellcode 时,我们必须分配一个 RX/RWX 内存区域。更好的 OPSEC 是使用 RX 区域。尽管如此,如果我们使用标准函数调用(如 LoadLibraryA) 加载这些 DLL ,则会执行一个 调用汇编指令来调用 LoadLibraryA。这会将调用者的返回地址放在堆栈上,并且源自内存的 RX 区域(我们的 shellcode)。有些人可能会想,那又怎样?这里的问题是什么?
问题是 EDR 可以挂接 DLL 加载函数(例如 LoadLibraryA),然后检查堆栈以确定调用者的返回地址,该地址将指向您的 RX shellcode 区域。它还将扫描此内存区域以查找任何恶意内容以及已知的有效负载签名,这可能会导致您被抓住 - 只需以这种方式加载特定的 DLL。所以,你已经解开了包含 DLL 加载函数的给定 DLL,你应该没事吧?这不是你唯一需要担心的事情,还记得那些非常烦人的内核回调吗?有一个名为PsSetLoadImageNotifyRoutine(Ex)* 的回调,允许 EDR 注册一个回调函数,该函数每次在进程中加载 DLL 时都会被调用。一旦 EDR 在触发回调后检查堆栈,它就可以获取加载 DLL 的调用者的返回地址并检查此内存区域,就像我之前提到的一样。代理 DLL 加载可以解决这些 OPSEC 问题。
Windows 线程池 API
Windows 线程池 API 提供了一种高级抽象,用于管理一组可用于执行各种异步任务或工作项的工作线程。此 API 简化了应用程序内线程资源的管理,使开发人员可以专注于应用程序逻辑,而不必担心线程管理、同步和并发控制的复杂性。该 API 是 Windows 操作系统的一部分,它支持高效执行从系统管理的池中提取的工作线程上的回调。
为什么存在某些回调函数
1.工作项回调:当工作项由工作线程处理时,将执行这些函数。它们封装需要异步执行的任务或计算,允许应用程序从主线程卸载工作并提高响应能力或吞吐量。
2.计时器回调:计时器回调在计时器到期时执行。这对于定期更新、维护任务或延迟执行代码非常有用,而无需通过休眠来阻塞线程。
3.I/O 完成回调:这些函数在异步 I/O 操作完成后执行。它们允许应用程序在不阻塞的情况下启动 I/O 操作并异步处理结果,这对于在 I/O 密集型应用程序中保持高性能至关重要。
4.等待回调:等待回调在等待对象(例如事件或互斥锁)发出信号时执行。此机制用于异步等待事件或条件,而不会阻塞工作线程,促进线程之间的同步或对外部事件做出反应。
实验
正如我之前提到的,存在多个回调。我与您分享的示例将是一个 I/O 完成回调示例。要有效地将 I/O 完成回调与 Windows 线程池 API 结合使用,您本质上需要通过CreateThreadpoolIo创建一个线程池 I/O 对象,并将其与支持重叠 I/O 操作的文件句柄相关联。此设置允许以不阻塞执行线程的方式执行异步 I/O 操作(例如文件读取或写入)。此过程的关键是OVERLAPPED结构,系统使用该结构来跟踪这些操作的进度。启动任何异步 I/O 时,您必须首先调用StartThreadpoolIo 来为传入操作准备线程池,确保系统已准备好正确处理完成回调。
您的回调函数(定义符合线程池 API 的期望)将在 I/O 操作完成后调用。此函数将接收有关操作的详细信息,包括结果和传输的字节数,以便进行任何必要的操作后处理。操作及其相关回调完成后,通过关闭线程池 I/O 对象和任何打开的文件句柄来清理资源对于资源管理和防止泄漏至关重要。
总之,利用 Windows 线程池 API 进行 I/O 完成回调涉及使用正确配置的文件句柄和OVERLAPPED结构准备异步操作,使用**StartThreadpoolIo和CloseThreadpoolIo管理操作的生命周期,并在预定义的回调函数中处理结果。这种方法有助于在 Windows 应用程序中实现高效、无阻塞的 I/O 操作。
现在我们知道了需要什么,让我们来谈谈实现。正如我之前提到的,此示例的代码位于我的 GitHub 存储库中,但 C++ 代码如下:
extern "C" void CALLBACK IoCompletionCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PVOID Overlapped, ULONG IoResult, ULONG_PTR NumberOfBytesTransferred, PTP_IO Io);
void StartRead(HANDLE pipe, PTP_IO tpIo, OVERLAPPED* overlapped, char* buffer);
void CALLBACK ClientWorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);
PVOID pLoadLibraryA;
HANDLE g_WriteCompleteEvent; // Global event to signal completion of write operation
typedef struct LOAD_CONTEXT {
char* DllName;
PVOID pLoadLibraryA;
};
int main()
{
HANDLE pipe;
PTP_IO tpIo = NULL;
OVERLAPPED overlapped = { 0 };
char buffer[128] = { 0 };
// Get the address of LoadLibraryA
pLoadLibraryA = GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA");
// Prepare the LOAD_CONTEXT structure
LOAD_CONTEXT loadContext;
loadContext.DllName = (char*)"wininet.dll";
loadContext.pLoadLibraryA = pLoadLibraryA;
// Create a global event to signal when the write operation is complete
g_WriteCompleteEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
if (g_WriteCompleteEvent == NULL) {
printf("Failed to create write complete eventn");
return 1;
}
// Create a named pipe with FILE_FLAG_OVERLAPPED flag
pipe = CreateNamedPipe(
TEXT("\\.\pipe\MyPipe"),
PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
1, // Number of instances
4096, // Out buffer size
4096, // In buffer size
0, // Timeout in milliseconds
NULL); // Default security attributes
if (pipe == INVALID_HANDLE_VALUE) {
printf("Failed to create named pipen");
CloseHandle(g_WriteCompleteEvent);
return 1;
}
// Create an event for the OVERLAPPED structure
overlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
if (overlapped.hEvent == NULL) {
printf("Failed to create eventn");
CloseHandle(pipe);
CloseHandle(g_WriteCompleteEvent);
return 1;
}
// Associate the pipe with the thread pool
tpIo = CreateThreadpoolIo(pipe, IoCompletionCallback, &loadContext, NULL);
if (tpIo == NULL) {
printf("Failed to associate pipe with thread pooln");
CloseHandle(overlapped.hEvent);
CloseHandle(pipe);
CloseHandle(g_WriteCompleteEvent);
return 1;
}
// Create threadpool work item for the client code
PTP_WORK clientWork = CreateThreadpoolWork(ClientWorkCallback, NULL, NULL);
if (clientWork == NULL) {
printf("Failed to create threadpool work itemn");
CloseThreadpoolIo(tpIo);
CloseHandle(overlapped.hEvent);
CloseHandle(pipe);
CloseHandle(g_WriteCompleteEvent);
return 1;
}
// Submit the client work item to the thread pool
SubmitThreadpoolWork(clientWork);
// Wait for the client work item to signal that the write operation is complete
WaitForSingleObject(g_WriteCompleteEvent, INFINITE);
// Start an asynchronous read operation
StartRead(pipe, tpIo, &overlapped, buffer);
printf("Pipe buffer: %sn", buffer);
// Wait for the read operation to complete
WaitForSingleObject(overlapped.hEvent, INFINITE);
// Wait for client work to complete
WaitForThreadpoolWorkCallbacks(clientWork, FALSE);
CloseThreadpoolWork(clientWork);
// Cleanup
CloseThreadpoolIo(tpIo);
CloseHandle(overlapped.hEvent);
CloseHandle(pipe);
CloseHandle(g_WriteCompleteEvent);
printf("wininet.dll should be loaded! Input any key to exit...n");
getchar();
return 0;
}
void StartRead(HANDLE pipe, PTP_IO tpIo, OVERLAPPED* overlapped, char* buffer)
{
DWORD bytesRead = 0;
StartThreadpoolIo(tpIo);
if (!ReadFile(pipe, buffer, 128, &bytesRead, overlapped) && GetLastError() != ERROR_IO_PENDING) {
printf("ReadFile failed, error %lun", GetLastError());
CancelThreadpoolIo(tpIo);
}
}
void CALLBACK ClientWorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work)
{
// Open the named pipe
HANDLE pipe = CreateFile(
TEXT("\\.\pipe\MyPipe"),
GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (pipe == INVALID_HANDLE_VALUE) {
printf("Client failed to connect to pipen");
return;
}
const char message[] = "Hello from the pipe!";
DWORD bytesWritten;
if (!WriteFile(pipe, message, sizeof(message), &bytesWritten, NULL)) {
printf("Client WriteFile failed, error: %lun", GetLastError());
}
else {
printf("Client wrote to pipen");
}
// Signal that the write operation is complete
SetEvent(g_WriteCompleteEvent);
CloseHandle(pipe);
}
我不会逐行介绍代码,而是将重点放在本文的关键部分。概念验证还包括一些汇编代码,如下所示:
.CODE
; LOAD_CONTEXT* is passed in RDX
IoCompletionCallback PROC
; Extract the 'DllName' member (first member of the structure) to RCX
mov rcx, [rdx] ; Moves the address pointed to by DllName into RCX
; Extract the 'pLoadLibraryA' member (second member of the structure) into RAX
mov rax, [rdx + 8] ; Assumes 64-bit pointers, so offset is 8 bytes
; Now RCX contains the address of the dll string,
; and RAX contains the address to jump to (pLoadLibraryA)
xor rdx, rdx ; Clear RDX
; Jump to LoadLibraryA address, avoiding call instruction and return address placement on stack
jmp rax
IoCompletionCallback ENDP
END
很多代码都是样板代码,这些代码是使用命名管道通过 Windows 线程池 API 执行 I/O 完成回调所必需的。这也可以使用与 Windows API 相关的任何 I/O 对象(包括文件和套接字)来完成。在这个例子中,我们要关注的部分是LOAD_CONTEXT结构以及它如何传递给 CreateThreadPoolIo 以及当它传递给回调函数时它将是哪个参数。还请注意,我们的回调函数 IoCompletionCallback被标记为外部,并在汇编代码中定义。触发回调时,该结构将传递给此汇编函数。
当我们调用 CreateThreadpoolIo时,我们将管道句柄、回调函数和 LOAD_CONTEXT 结构传递给它。这指示线程池将指向loadContext变量的指针作为**Context 参数传递给 I/O 完成回调函数,或者作为第二个参数。这是我们即将发现的重要信息。
如果你看一下 LOAD_CONTEXT 结构,它包含两个成员:char* DllName 和PVOID pLoadLibraryA。我们 用指向字符串“wininet.dll”的指针 填充了DllName ,用**LoadLibraryA的内存地址 填充了pLoadLibraryA 。
避免呼叫指令
正如我之前提到的,通过从堆栈上的 shellcode 区域加载 DLL,最终导致我们检测到的是调用汇编指令。
汇编语言中的调用指令用于调用子程序(程序中的过程或函数)。该指令的主要目的是将控制权从调用函数转移到子程序,从而实现代码重用、模块化编程和程序内的组织控制流。子程序可以执行任务并返回结果,而无需在程序的各个部分复制代码。
当执行调用指令时,处理器主要做两件事:
1.将返回地址推送到堆栈:返回地址是调用函数中紧跟调用指令的指令的地址。此地址保存在堆栈中,以便子例程完成执行后,程序知道返回到哪里继续执行调用函数。堆栈用于此目的,因为它支持以后进先出 (LIFO) 方式嵌套调用子例程(调用其他函数的函数)。这对于支持递归函数调用和管理多个嵌套子例程的返回地址至关重要。
2.将控制权转移到子程序:程序计数器 (PC) 或指令指针 (IP) 设置为被调用的子程序的地址,从而使执行跳转到该位置。
子程序执行完成后,通常使用ret(返回)指令从堆栈中弹出返回地址并跳回到该地址,并在调用子程序的位置之后恢复调用函数的执行。
考虑到这些信息,我们希望避免使用 调用 指令来实现我们的目标。我们不希望在调用LoadLibraryA时将返回地址放在堆栈上,这样查看堆栈的任何内容都不会被引导到我们的 shellcode 内存区域进行分析。这就是汇编函数和 LOAD_CONTEXT 结构发挥作用的地方。
在调用回调函数 ( IoCompletionCallback ) 时, LOAD_CONTEXT结构作为第二个参数传递给回调。当我们从堆栈的角度看 64 位 Windows 调用约定时,这意味着该结构将包含在 rdx寄存器中。
汇编函数首先提取结构的 DllName成员并将其放入**rcx中。这将模拟在我们稍后执行 jmp 时 将DLL 字符串作为**LoadLibraryA 的第一个参数。汇编函数接下来要做的是提取LOAD_CONTEXT 结构的第二个成员,即**LoadLibraryA的内存地址放入 rax中。在执行 jmp之前,我们清除 rdx 寄存器,因为 LoadLibraryA只接受一个参数,如果不这样做,函数将抛出错误或失败。汇编函数做的最后一件事是使用我们特制的堆栈 执行jmp rax指令。使用**rcx中的 DLL 名称字符串,我们模拟 以给定的 DLL 名称作为第一个参数的LoadLibraryA 调用,然后加载该 DLL。
通过这种方式加载 DLL,我们基本上通过回调函数“代理”了加载。通常,在 DLL 加载后检查堆栈时,您在这种情况下会看到 I/O 完成回调函数的堆栈帧,因为返回地址会放在堆栈上。但是如果我们在这种情况下检查堆栈,我们看不到回调函数的堆栈帧,并且我们获得了一个干净的堆栈,没有任何内容指向我们的 shellcode 内存区域:
Process Hacker 中显示的干净堆栈
如果我们检查 Process Hacker 内部已加载模块的列表,我们还可以看到 wininet.dll 已正确加载:
Process Hacker Modules 选项卡显示已加载的 wininet.dll
结论
总之,在本文中,我们介绍了一种使用 I/O 完成回调函数和 Windows 线程池 API 以及 C++/汇编语言进行代理 DLL 加载的方法。有许多 Windows 回调可以以类似的方式使用。我们为我们的 OPSEC 执行此操作,通过从堆栈中删除加载 DLL 的调用函数的返回地址来防止 EDR 检测到我们的有效载荷。
原文始发于微信公众号(影域实验室):重生之我在干免杀-利用 Windows 线程池 API:使用 IO 完成回调代理 DLL 加载
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论