0x02 关键步骤
因为exe以文件形式存储的时候区段间的对齐方式与在内存中的对齐方式不尽相同,因此在手动加载exe时不能单纯的将文件格式的 exe 直接拷贝到内存中,而是应当根据内存区段(page size)的对齐方式做对齐处理。
为了验证一下,随便找一个 exe 文件作为学习资料。
FileAlignment 为 0x200,实际的Section也均是以 0x200 为单位进行对齐的,实际调试时就会发现section的对齐变为了SectionAlignment的大小:0x1000
导入表是PE文件从其它第三方程序中导入API,以供本程序调用的机制(与导出表对应),在exe运行起来的时, 加载器会遍历导入表, 将导入表中所有dll 都加载到进程中,被加载的DLL的DllMain就会被调用,通过导入表可以知道程序使用了哪些函数,导入表是一个数组,以全为零结尾。
要理解导入表,首先要理解PE文件分为两种编译方式:动态链接、静态链接。
静态链接方式:在程序执行之前完成所有的组装工作,生成一个可执行的目标文件(EXE文件)。
动态链接方式:在程序已经为了执行被装入内存之后完成链接工作,并且在内存中一般只保留该编译单元的一份拷贝。
静态链接优势在于其可移植性较强,基本上不依赖于系统的dll(自己全打包好了),动态链接的优势在于程序主体较小,占用系统资源不多。
动态链接库的两种链接方法:
(1) 装载时动态链接(Load-time Dynamic Linking):这种用法的前提是在编译之前已经明确知道要调用DLL中的哪几个函数,编译时在目标文件中只保留必要的链接信息,而不含DLL函数的代码;当程序执行时,调用函数的时候利用链接信息加载DLL函数代码并在内存中将其链接入调用程序的执行空间中(全部函数加载进内存),其主要目的是便于代码共享。(动态加载程序,处在加载阶段,主要为了共享代码,共享代码内存)
(2) 运行时动态链接(Run-time Dynamic Linking):这种方式是指在编译之前并不知道将会调用哪些DLL函数,完全是在运行过程中根据需要决定应调用哪个函数,将其加载到内存中(只加载调用的函数进内存),并标识内存地址,其他程序也可以使用该程序,并用LoadLibrary和GetProcAddress动态获得DLL函数的入口地址。(dll在内存中只存在一份,处在运行阶段)
相较于静态链接所有函数均在一个exe文件里,要调用某个函数时只需要按照写死的偏移进行调用,动态链接就存在一个找函数的问题:
当程序运行起来需要某个系统函数时,哪个dll包含该函数?dll加载到内存里之后地址是不确定的,如何按照从内存中定位到所需的函数地址。
PE文件中的导入表就可以解决上述问题。
要理解导入表首先要了解以下这几个结构:
IMAGE_DATA_DIRECTORY 位于 IMAGE_Optional_header 中的最后一个字段,是一个由16个_IMAGE_DATA_DIRECTORY 结构体构成的结构体数组,每个结构体由两个字段构成,分别为VirtualAddress和Size字段:
Offset (PE/PE32+) Description
96/112 Export table address and size
104/120 Import table address and size
112/128 Resource table address and size
120/136 Exception table address and size
128/144 Certificate table address and size
136/152 Base relocation table address and size
144/160 Debugging information starting address and size
152/168 Architecture-specific data address and size
160/176 Global pointer register relative virtual address
168/184 Thread local storage (TLS) table address and size
176/192 Load configuration table address and size
184/200 Bound import table address and size
192/208 Import address table address and size
200/216 Delay import descriptor address and size
208/224 The CLR header address and size
216/232 Reserved
根据微软提供的信息,IMAGE_DATA_DIRECTORY 的第二项指向的就是导入表了。
既然已经找到了导入表,就需要根据导入表内的元素来加载对应的dll,获取不同的函数地址了,此时就需要用到 IMAGE_IMPORT_DESCRIPTOR 结构了,该结构的详细内容如下:
Offset Size Field
0 4 Import Lookup Table RVA
4 4 Time/Date Stamp
8 4 Forwarder Chain
12 4 Name RVA
16 4 Import Address Table RVA
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;//(1) 指向导入名称表(INT)的RAV*
};
DWORD TimeDateStamp; // (2) 时间标识
DWORD ForwarderChain; // (3) 转发链,如果不转发则此值为0
DWORD Name; // (4) 指向导入映像文件的名字*
DWORD FirstThunk; // (5) 指向导入地址表(IAT)的RAV*
} IMAGE_IMPORT_DESCRIPTOR;
在修复IAT的过程中最重要的两个字段就是 OriginalFirstThunk 和 FirstThunk了:
根据OriginalFirstThunk获取要用到的函数名,将获取到的函数地址填到FirstThunk中。
0x03 具体分析
为了方便理解和记忆,默认读取的 PE 文件格式不存在问题,不做错误处理。
相较于dll的内存加载,exe的内存加载简化了很多,其中省略掉的一个大步骤就是导出表的修复。
文件读取步骤基本上可以说条条大路通罗马,只要将 PE 文件完整的读取到内存中可供后续处理即可,除了把一个文件放在目录中进行读取以外还有很多种方式,比如将要加载的 exe 转换成shellcode进行加载、将shellcode进行简单xor后在内存xor回来再加载。。。
ifstream inFile("nc.exe", ios::in | ios::binary);
stringstream tmp;
tmp << inFile.rdbuf();
unsigned char* content = (unsigned char*)tmp.str().c_str();
因为 exe 文件默认有加载基址,一般情况下在运行 exe 的时候会首先尝试加载到默认地址上去,因此就要根据 exe 的默认加载基址和映像大小(exe加载到内存后的大小:SizeOfImage)来分配内存
SIZE_T SizeOfImage = pFileNtHeader->OptionalHeader.SizeOfImage;// 获取加载基址
DWORD base = pFileNtHeader->OptionalHeader.ImageBase;
// 分配内存
unsigned char* memExeBase = (unsigned char*)VirtualAlloc((LPVOID)base, SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(memExeBase, content, pFileNtHeader->OptionalHeader.SizeOfHeaders);
分配内存时的 VirtualAlloc
指定的页类型为 MEM_COMMIT|MEM_RESERVE
这里是一个小小的延迟分配的知识点,如果是 MEM_RESERVE
的话只有当对该段内存进行内存操作时才会被真正Load进入物理内存中。页权限使用的是PAGE_EXECUTE_READWRITE
,这在实际编码过程中是一个很不好的习惯,为了更清晰的理解 exe 内存加载的核心流程,就省略了根据section来确定内存权限的步骤。
分配内存完毕后首先要将 PE header 拷贝到相应的地址空间去,因为后续的操作均需要用到。
memcpy(memExeBase, content, pFileNtHeader->OptionalHeader.SizeOfHeaders);
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)memExeBase;
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)(memExeBase + pDosHeader->e_lfanew);
之后要根据新分配的内存地址计算新的 DOS头 和 NT头。
因为已经根据ImageBase分配了内存,所以需要将拷贝后的OptionalHeader中的ImageBase字段根据实际内存地址进行更新,如果开启了aslr的话需要根据实际的内存地址更新ImageBase,分配到默认基址上的话没有必要。
pNtHeader->OptionalHeader.ImageBase = (DWORD)memExeBase;
拷贝区段这部分是内存加载的第一个关键点,要根据内存页的大小来将原本的文件区段进行处理。在文件中Section通常以 0x200 进行对齐,内存中页大小单位为 0x1000, 因此内存对齐单位为 0x1000,所以当PE文件加载到内存中后需要对Section进行变换。
// 拷贝区段
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(pNtHeader);
int sectionSize;
for (int i = 0; i < pNtHeader->FileHeader.NumberOfSections; i++, section++) {
if (section->SizeOfRawData == 0) {
sectionSize = pNtHeader->OptionalHeader.SectionAlignment; // 最小内存Seciton单位为 SectionAlignment的大小
}
else {
sectionSize = section->SizeOfRawData;
}
if (sectionSize > 0) {
void* dest = memExeBase + section->VirtualAddress;
memcpy(dest, content + section->PointerToRawData, sectionSize);
}
}
OriginalFirstChunk指向的INT表表项以4字节为单位,全0结尾,如果最高位为1则代表是函数序号,反之则是一个RVA,指向IMAGE_IMPORT_BY_NAME结构,INT表实际上的功能是获取要导入的函数名。目前没有遇到最高位为1的情况。
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1]; //函数名称,0结尾.
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
IAT表项则直接是一个指向真实函数地址的指针。
PIMAGE_IMPORT_DESCRIPTOR pImportDesc;
bool result = true;
PIMAGE_DATA_DIRECTORY pDataDir = (PIMAGE_DATA_DIRECTORY)(&pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]); // 获取IMAGE_DATA_DIRECTORY 位置
pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)(memExeBase + pDataDir->VirtualAddress);// 获取第一个IMAGE_IMPORT_DESCRIPTOR
for (;!IsBadReadPtr(pImportDesc,sizeof(IMAGE_IMPORT_DESCRIPTOR)) && pImportDesc->Name;pImportDesc++) {
uintptr_t* thunkRef;
FARPROC* funcRef;
HMODULE handle = LoadLibraryA((LPCSTR)(memExeBase + pImportDesc->Name)); // 加载dll(此处也是可以手工加载的)
if (pImportDesc->OriginalFirstThunk) {
thunkRef = (uintptr_t*)(memExeBase + pImportDesc->OriginalFirstThunk);
funcRef = (FARPROC*)(memExeBase + pImportDesc->FirstThunk);
}
else {
thunkRef = (uintptr_t*)(memExeBase + pImportDesc->FirstThunk);
funcRef = (FARPROC*)(memExeBase + pImportDesc->FirstThunk);
}
for (; *thunkRef; thunkRef++, funcRef++) {
if (IMAGE_SNAP_BY_ORDINAL(*thunkRef)) { // 判断OriginalFirstThunk表项最高位为1的情况
*funcRef = GetProcAddress(handle, (LPCSTR)(IMAGE_ORDINAL(*thunkRef)));//修复导入表
}
else {
PIMAGE_IMPORT_BY_NAME thunkData = (PIMAGE_IMPORT_BY_NAME)(memExeBase + (*thunkRef));
*funcRef = GetProcAddress(handle, (LPCSTR)&thunkData->Name); //修复导入表
}
if (*funcRef == 0) {
cout << " error import " << endl;
exit(0);
}
}
}
上述修复导入表的代码实际完成的工作就是遍历导入表中的 IMAGE_IMPORT_DESCRIPTOR
结构,根据dll名称将对应的dll加载到内存中,并根据OriginalFirstThunk字段来获取所需的函数名进而使用GetProcAddress来获取该函数在内存中的实际地址填充到FirstThunk字段指向的空间中。
if (pNtHeader->OptionalHeader.AddressOfEntryPoint != 0) {
ExeEntryProc exeEntry = (ExeEntryProc)(LPVOID)(memExeBase + pNtHeader->OptionalHeader.AddressOfEntryPoint);
exeEntry();
}
结语
至此exe的内存加载就已经结束了,诱发我写下这篇文章的一个主要原因是回忆起之前看过的几篇APT相关的分析文章,涉及到主机的远控目前内存加载已经是标配,杀软动态检测的对抗方式种类繁多,静态对抗的方法以内存加载为王。
参考链接
https://www.cnblogs.com/iBinary/p/9740757.html
https://github.com/fancycode/MemoryModule
https://blog.csdn.net/Apollon_krj/article/details/77417063
https://www.cnblogs.com/tracylee/archive/2012/10/15/2723816.html
转载自安全客:https://www.anquanke.com/post/id/260054