反射 DLL 加载器原理与实现

First Post:

Last Update:

介绍

Windows 的 PE 文件通过系统的 PE 加载器来加载运行。比如双击一个 EXE 时,PE 加载器会将其从磁盘映射到内存中,并通过补充导入表、基址重定位等操作使其能运行。

反射 DLL 加载就是自己实现一个 PE 加载器来加载运行 DLL,DLL 可以加密存储在内存、云端、本地文件等任意位置,达到恶意程序隐藏效果。

反射加载流程与实现

PE 结构如图:

1.png

下面逐个加载步骤进行讲解

先生成一个简单的实验用 DLL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "pch.h"

/*
* 1.Release x64
* 2.C/C++
* 代码生成: 运行库(多线程)
* 3.链接器
* 清单文件: 生成清单(否)
* 调试: 生成调试信息(否)
*/

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
MessageBoxA(0, "", "", MB_ICONINFORMATION);
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

获取 DLL 数据

以从文件读取 DLL 数据为例。

1
2
3
4
5
HANDLE hFile = CreateFileA((char*)"D:\\DLL.bin", GENERIC_READ, NULL, NULL, OPEN_EXISTING, 0, NULL);
DWORD fileSize = GetFileSize(hFile, NULL);
PVOID dllData = VirtualAlloc(NULL, fileSize, MEM_COMMIT, PAGE_READWRITE);
DWORD readFileSize;
ReadFile(hFile, dllData, fileSize, &readFileSize, NULL);

解析 PE 结构信息

PE 的开头即为 DOS 头,通过 DOS 头的 e_lfanew 字段可以定位到 NT 头。

1
2
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)dllData;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD_PTR)dllData + pDos->e_lfanew);

NT 头中有很多记录了 PE 结构信息的字段和结构。

1.png

NumberOfSections 字段是 Sections 的数量,Sections 包括 .text、.rdata、.data、.reloc 等,.text 其实就是要运行的汇编的机器码,其他 Sections 起到了存储数据等作用。

PE 映射到内存中称为 Image,ImageBase 字段可以理解为一个虚构的 Image 基址,因为 PE 加载前不知道 Image 的真实基址,所以先假设一个。

SizeOfImage 字段是 Image 的大小。

NT 头中的 OptionalHeader 中有一个元素为 IMAGE_DATA_DIRECTORY 的 DataDirectory 数组,其中下标为 IMAGE_DIRECTORY_ENTRY_IMPORT 的元素记录了 “导入表” 相对 ImageBase 的偏移,下标为 IMAGE_DIRECTORY_ENTRY_BASERELOC 的记录了 “基址重定位表” 的偏移。

1
2
3
4
5
WORD numberOfSections = pNt->FileHeader.NumberOfSections;
DWORD_PTR imageBase = pNt->OptionalHeader.ImageBase;
DWORD sizeOfImage = pNt->OptionalHeader.SizeOfImage;
DWORD importDirRVA = ((IMAGE_DATA_DIRECTORY)(pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT])).VirtualAddress;
DWORD relocDirRVA = ((IMAGE_DATA_DIRECTORY)(pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC])).VirtualAddress;

映射 Sections 到内存

从图1可以看出 Sections 映射到内存后位置会发生变化。此处没有映射 DOS、NT、节表到内存是因为它们在 PE 加载后的运行期间起不到任何作用,反而会成为查杀特征。

每个节表记录了一个 Section 的信息,Name 字段为当前 Section 的名称,SizeOfRawData 字段为当前 Section 的大小,PointerToRawData 字段为当前 Section 相对 PE 原数据首地址的偏移,VirtualAddress 字段为当前 Section 相对 ImageBase 的偏移。

先定位到第一个节表,节表紧挨在 NT 头后面,而 NT 头最后一个结构是 OptionalHeader,所以 OptionalHeader 首地址 + OptionalHeader 大小即为第一个节表的首地址。

分配一块可读可写的空间用于存储 Image,再通过遍历节表根据每个节表记录的信息将每个 Section 映射到正确位置。需要特别记录 .text 的地址和大小,最后要改为可读可执行权限。

1
2
3
4
5
6
7
8
9
10
11
int textSize;
DWORD_PTR textAddr;
DWORD_PTR realImageBase = (DWORD_PTR)VirtualAlloc(NULL, sizeOfImage, MEM_COMMIT, PAGE_READWRITE);
PIMAGE_SECTION_HEADER pSectionTable = (PIMAGE_SECTION_HEADER)((DWORD_PTR) & (pNt->OptionalHeader) + pNt->FileHeader.SizeOfOptionalHeader);
for (int i = 0; i < numberOfSections; i++) {
if (!strcmp((char*)pSectionTable[i].Name, ".text")) {
textAddr = realImageBase + pSectionTable[i].VirtualAddress;
textSize = pSectionTable[i].SizeOfRawData;
}
memcpy((PVOID)(realImageBase + pSectionTable[i].VirtualAddress), (PVOID)((DWORD_PTR)dllData + pSectionTable[i].PointerToRawData), pSectionTable[i].SizeOfRawData);
}

补充导入表

每个导入表记录了 PE 在运行时需要导入的一个 DLL 的名称等信息,每个导入表可定位到的每个 IAT 表记录了需要调用该 DLL 中的一个函数的名称等信息,但是没有记录这些函数的地址。

1.png

因为每个 DLL 加载到内存中的地址是不固定的,自然每个函数的地址也是不固定的,所以需要我们自己找到每个函数的地址填充到每个 IAT 表中。

先通过偏移定位到第一个导入表。遍历每个导入表时,先获取要导入的 DLL 名称并导入该 DLL,再通过偏移定位到当前导入表的第一个 IAT 表,遍历每个 IAT 表,先判断该函数通过 “序号” 还是 “函数名” 进行导入,以正确的方式获取该函数的地址后填充到当前 IAT 表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PIMAGE_IMPORT_DESCRIPTOR pImportDir = (PIMAGE_IMPORT_DESCRIPTOR)(realImageBase + importDirRVA);
while (pImportDir->FirstThunk) {
char* dllName = (char*)(realImageBase + pImportDir->Name);
HMODULE importDll = LoadLibraryA(dllName);
PIMAGE_THUNK_DATA pIAT = (PIMAGE_THUNK_DATA)(realImageBase + pImportDir->FirstThunk);
while (pIAT->u1.AddressOfData) {
// 通过 "序号" 导入
if (IMAGE_SNAP_BY_ORDINAL(pIAT->u1.Ordinal)) {
*(PDWORD_PTR)pIAT = (DWORD_PTR)GetProcAddress(importDll, (char*)(*(PDWORD_PTR)pIAT & 0xFFFF));
}
// 通过 "函数名" 导入
else {
PIMAGE_IMPORT_BY_NAME pImportByName = (PIMAGE_IMPORT_BY_NAME)(realImageBase + pIAT->u1.AddressOfData);
*(PDWORD_PTR)pIAT = (DWORD_PTR)GetProcAddress(importDll, pImportByName->Name);
}
pIAT++;
}
pImportDir++;
}

基址重定位

PE 原数据中有一些虚构的绝对地址,它们是以 ImageBase 字段为基准设置的。

在 PE 加载过程中已经分配了 Image 的空间,所以已经知道了 Image 的真实基址,就可以根据真实基址和 ImageBase 字段的差值对这些虚构的绝对地址重新校准了。

每个基址重定位表由两部分构成,第一部分的 VirtualAddress 字段为一个需要进行重定位的页的偏移地址,SizeOfBlock 字段为当前重定位表的大小,第一部分的大小为 sizeof(IMAGE_BASE_RELOCATION)。第二部分由很多大小为 WORD 的 Type + Offset 构成,每个 Type + Offset 记录了一个要重定位的地址的重定位类型和其相对当前页的首地址的偏移。

1.png

先通过偏移定位到第一个基址重定位表。遍历每个重定位表时,先通过偏移定位到当前重定位表中的第一个 Type + Offset,遍历每个 Type + Offset 进行重定位。

1
2
3
4
5
6
7
8
9
10
11
PIMAGE_BASE_RELOCATION pRelocDir = (PIMAGE_BASE_RELOCATION)(realImageBase + relocDirRVA);
while (pRelocDir->SizeOfBlock) {
PWORD pTypeOffset = (PWORD)((DWORD_PTR)pRelocDir + sizeof(IMAGE_BASE_RELOCATION));
int typeOffsetNum = (pRelocDir->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
while (typeOffsetNum--) {
if (pTypeOffset[typeOffsetNum] >> 12 == IMAGE_REL_BASED_DIR64) {
*(PDWORD_PTR)(realImageBase + pRelocDir->VirtualAddress + (pTypeOffset[typeOffsetNum] & 0xFFF)) += realImageBase - imageBase;
}
}
pRelocDir = (PIMAGE_BASE_RELOCATION)((DWORD_PTR)pRelocDir + pRelocDir->SizeOfBlock);
}

调用入口函数

最后将 .text 改为可读可执行权限,通过偏移定位到入口函数进行调用。

1
2
3
4
DWORD oldProtect;
VirtualProtect((PVOID)textAddr, textSize, PAGE_EXECUTE_READ, &oldProtect);

((BOOL(*)(...))(realImageBase + pNt->OptionalHeader.AddressOfEntryPoint))(realImageBase, DLL_PROCESS_ATTACH, 0);