众所周知,windows下可执行文件必须符合一定的格式要求,微软官方称之为PE文件(关于PE文件的详细介绍这里就不赘述了,google一下可以找到大把);用户在界面双击exe时,有个叫做explorer的进程会监测并接受到这个事件,然后根据注册表中的信息取得文件名,再以Explorer.exe这个文件名调用CreateProcess函数去运行用户双击的exe;PC中用户一般都是这样运行exe的,所以很多用户态的exe都是exlporer的子进程。
用process hacker截图如下:
那么这个explorer究竟是怎么成功“运行”这个exe的了?这里面涉及到大量细枝末节就不深究了,本文先把主干思路捋一遍!
- 分配内存 既然是运行,肯定是需要放在内存的,所以首先要开辟内存空间,才能把exe从磁盘加载进来;以32位为例,由于每个进程都有自己的4GB虚拟空间,所以还涉及到新生成页表、填充CR3等琐碎的细节工作;
- 加载到内存 内存分配好后,接着就该把exe从磁盘读取到内存了;
- 重定位(文章末尾有扩展,详细介绍imagebase、VA、RVA、PointerToRawData、foa等概念) 这一步我个人觉得是最关键、最容易出错的了!PE文件在编译器编译的时候,编译器是不知道文件会被加载到那个VA的(一般exe默认从40000开始,这个还好;但是dll默认从100000开始,这个就不同了。一个exe一般会调用多个dll,后面加载的dll肯定会和前面加载dll的imagebase冲突),这个时候只能把dll或exe加载到其他虚拟地址;一旦改变了imagebase,涉及到地址硬编码的地方都要改了,包括:全局/静态变量、子函数调用;所以PE文件里面单独有个relc段,标明了需要重新定位和生成VA的地址;由于硬编码存放的都是相对地址,所以重定位后新VA的计算公式也很简单,
- 填写导入表 一个exe的运行,很多时候要依赖操作系统提供的函数,举个最简单的例子:比如我要打印一段string,console下要用到printf或cout,MFC要用到messagebox,这些都是操作系统提供的API,编译器编译时也是不知道这些系统函数究竟被操作系统放在了内存的哪个地方,call的时候该往哪跳转了?所以只能把需要用到的这些系统函数统一放在一张叫做导入表的表格,explorer加载的时候还要挨个遍历导入表,一旦发现该PE文件用到了某些系统API,需要用这些API在内存的真实地址替换PE文件中call的地址(这也是用OD、x96dbg这些常见的调试器能找到这些系统函数的根本原因:都是系统提供的嘛,函数名必须保存起来,否则加载的时候没法替换成内存中真正的地址)!
好了,到此为止exe被加载的核心步骤都缕过了;具体实现上,explorer调用了createPorcess来加载和运行exe,这就直接导致了一个后果:被任务管理器或process hacker检测到(这里和通过loadLibrary类似:只要是通过windows提供的API使用内存,都会在某些地方被记录,这也是windows常见的内存管理方式之一,用了必须记录!所以规避检测的方式之一就是自己实现exe或dll的加载和运行,不依赖window的API)!为了躲避任务管理器或process hacker的监察,只能不调用createProcess,而是自己模拟PE加载的思路重新实现一遍了(类似于自己重新openProcess函数一样吧)!
自己实现PE loader核心思路代码如下(参考第5个链接):
int main()
{
char szFileName[] = "D:\\software\\PELoader-master1\\test.exe";
//打开文件,设置属性可读可写
HANDLE hFile = CreateFileA(szFileName, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_ARCHIVE, NULL);
if (INVALID_HANDLE_VALUE == hFile)
{
printf("文件打开失败\n");
return 1;
}
//获取文件大小
DWORD dwFileSize = GetFileSize(hFile, NULL);
//申请空间
char* pData = new char[dwFileSize];
if (NULL == pData)
{
printf("空间申请失败\n");
return 2;
}
//将文件读取到内存中
DWORD dwRet = 0;
ReadFile(hFile, pData, dwFileSize, &dwRet, NULL);
CloseHandle(hFile);
//将内存中exe加载到程序中
char* chBaseAddress = RunExe(pData, dwFileSize);
delete[] pData;
system("pause");
return 0;
}
其他代码如下(老规矩:精华都在注释了):
#include <windows.h>
#include <stdio.h>
//跳转到入口点执行
bool CallEntry(char* chBaseAddress)
{
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)chBaseAddress;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(chBaseAddress + pDos->e_lfanew);
char* ExeEntry = (char*)(chBaseAddress + pNt->OptionalHeader.AddressOfEntryPoint);
// 跳转到入口点处执行
__asm
{
mov eax, ExeEntry
jmp eax
}
return TRUE;
}
//设置默认加载基址
bool SetImageBase(char* chBaseAddress)
{
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)chBaseAddress;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(chBaseAddress + pDos->e_lfanew);
pNt->OptionalHeader.ImageBase = (ULONG32)chBaseAddress;
return TRUE;
}
//填写导入表
bool ImportTable(char* chBaseAddress)
{
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)chBaseAddress;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(chBaseAddress + pDos->e_lfanew);
PIMAGE_IMPORT_DESCRIPTOR pImportTable = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD)pDos +
pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
// 循环遍历DLL导入表中的DLL及获取导入表中的函数地址
char* lpDllName = NULL;
HMODULE hDll = NULL;
PIMAGE_THUNK_DATA lpImportNameArray = NULL;
PIMAGE_IMPORT_BY_NAME lpImportByName = NULL;
PIMAGE_THUNK_DATA lpImportFuncAddrArray = NULL;
FARPROC lpFuncAddress = NULL;
DWORD i = 0;
while (TRUE)
{
if (0 == pImportTable->OriginalFirstThunk)
{
break;
}
// 获取导入表中DLL的名称并加载DLL
lpDllName = (char*)((DWORD)pDos + pImportTable->Name);
//看看这个dll是否已经加载
hDll = GetModuleHandleA(lpDllName);
//如果没有加载,那么先加载到内存
if (NULL == hDll)
{
hDll = LoadLibraryA(lpDllName);
if (NULL == hDll)
{
pImportTable++;
continue;
}
}
i = 0;
// 获取OriginalFirstThunk以及对应的导入函数名称表首地址
lpImportNameArray = (PIMAGE_THUNK_DATA)((DWORD)pDos + pImportTable->OriginalFirstThunk);
// 获取FirstThunk以及对应的导入函数地址表首地址
lpImportFuncAddrArray = (PIMAGE_THUNK_DATA)((DWORD)pDos + pImportTable->FirstThunk);
while (TRUE)
{
if (0 == lpImportNameArray[i].u1.AddressOfData)
{
break;
}
// 获取IMAGE_IMPORT_BY_NAME结构
lpImportByName = (PIMAGE_IMPORT_BY_NAME)((DWORD)pDos + lpImportNameArray[i].u1.AddressOfData);
// 判断导出函数是序号导出还是函数名称导出
if (0x80000000 & lpImportNameArray[i].u1.Ordinal)
{
// 序号导出
// 当IMAGE_THUNK_DATA值的最高位为1时,表示函数以序号方式输入,这时,低位被看做是一个函数序号
lpFuncAddress = GetProcAddress(hDll, (LPCSTR)(lpImportNameArray[i].u1.Ordinal & 0x0000FFFF));
}
else
{
// 名称导出
lpFuncAddress = GetProcAddress(hDll, (LPCSTR)lpImportByName->Name);
}
// 注意此处的函数地址表的赋值,要对照PE格式进行装载,不要理解错了!!!
// 把需要调用其他dll函数的VA写回导入表,就能通过call跳转到这里执行了
lpImportFuncAddrArray[i].u1.Function = (DWORD)lpFuncAddress;
i++;
}
pImportTable++;
}
return TRUE;
}
//修复重定位表
bool RelocationTable(char* chBaseAddress)
{
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)chBaseAddress;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(chBaseAddress + pDos->e_lfanew);
PIMAGE_BASE_RELOCATION pLoc = (PIMAGE_BASE_RELOCATION)(chBaseAddress + pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
//判断是否有重定位表
if ((char*)pLoc == (char*)pDos)
{
return TRUE;
}
while ((pLoc->VirtualAddress + pLoc->SizeOfBlock) != 0) //开始扫描重定位表
{
WORD* pLocData = (WORD*)((PBYTE)pLoc + sizeof(IMAGE_BASE_RELOCATION));
//计算需要修正的重定位项(地址)的数目
int nNumberOfReloc = (pLoc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
for (int i = 0; i < nNumberOfReloc; i++)
{
if ((DWORD)(pLocData[i] & 0x0000F000) == 0x00003000) //这是一个需要修正的地址
{
DWORD* pAddress = (DWORD*)((PBYTE)pDos + pLoc->VirtualAddress + (pLocData[i] & 0x0FFF));
DWORD dwDelta = (DWORD)pDos - pNt->OptionalHeader.ImageBase;//实际的imageBase减去pe文件里面标识的imagebase得到“移动的距离”
*pAddress += dwDelta;//把移动的距离在原地址加上去
}
}
//转移到下一个节进行处理
pLoc = (PIMAGE_BASE_RELOCATION)((PBYTE)pLoc + pLoc->SizeOfBlock);
}
return TRUE;
}
//将内存中的文件映射到进程内存空间中
bool MapFile(char* pFileBuff, char* chBaseAddress)
{
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pFileBuff;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pFileBuff + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);
//所有头 + 结表头的大小
DWORD dwSizeOfHeaders = pNt->OptionalHeader.SizeOfHeaders;
//获取区段数量
int nNumerOfSections = pNt->FileHeader.NumberOfSections;
// 将前一部分都拷贝过去
RtlCopyMemory(chBaseAddress, pFileBuff, dwSizeOfHeaders);
char* chSrcMem = NULL;
char* chDestMem = NULL;
DWORD dwSizeOfRawData = 0;
for (int i = 0; i < nNumerOfSections; i++)
{
if ((0 == pSection->VirtualAddress) ||
(0 == pSection->SizeOfRawData))
{
pSection++;
continue;
}
// 拷贝节区
chSrcMem = (char*)((DWORD)pFileBuff + pSection->PointerToRawData);
chDestMem = (char*)((DWORD)chBaseAddress + pSection->VirtualAddress);
dwSizeOfRawData = pSection->SizeOfRawData;
RtlCopyMemory(chDestMem, chSrcMem, dwSizeOfRawData);
pSection++;
}
return TRUE;
}
//获取镜像大小,传入的是文件的开始地址
DWORD GetSizeOfImage(char* pFileBuff)
{
DWORD dwSizeOfImage = 0;
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pFileBuff;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pFileBuff + pDos->e_lfanew);
dwSizeOfImage = pNt->OptionalHeader.SizeOfImage;
return dwSizeOfImage;
}
//运行文件
char* RunExe(char* pFileBuff, DWORD dwSize)
{
char* chBaseAddress = NULL;
//获取镜像大小
DWORD dwSizeOfImage = GetSizeOfImage(pFileBuff);
//根据镜像大小在进程中开辟一块内存空间
chBaseAddress = (char*)VirtualAlloc(NULL, dwSizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (NULL == chBaseAddress)
{
printf("申请进程空间失败\n");
return NULL;
}
//将申请的进程空间全部填0
RtlZeroMemory(chBaseAddress, dwSizeOfImage);
//将内存中的exe数据映射到peloader进程内存中,避免重新生成一个进程,这是隐藏exe的方式之一
if (FALSE == MapFile(pFileBuff, chBaseAddress))
{
printf("内存映射失败\n");
return NULL;
}
//修复重定位
if (FALSE == RelocationTable(chBaseAddress))
{
printf("重定位修复失败\n");
return NULL;
}
//填写导入表
if (FALSE == ImportTable(chBaseAddress))
{
printf("填写导入表失败\n");
return NULL;
}
//将页属性都设置为可读可写可执行
DWORD dwOldProtect = 0;
if (FALSE == VirtualProtect(chBaseAddress, dwSizeOfImage, PAGE_EXECUTE_READWRITE, &dwOldProtect))
{
printf("设置页属性失败\n");
return NULL;
}
//设置默认加载基址
if (FALSE == SetImageBase(chBaseAddress))
{
printf("设置默认加载基址失败\n");
return NULL;
}
//跳转到入口点执行
if (FALSE == CallEntry(chBaseAddress))
{
printf("跳转到入口点失败\n");
return NULL;
}
return chBaseAddress;
}
从代码看:这个pe loader本质上是在loader的进程开辟空间,然后运行exe的,所以exe的代码和数据其实都在loader的空间,并未单独生成一个进程,所以任务管理器、process hacker是都查不到的!这里也是把exe想办法当成了shellcode在用!整体感觉就像“寄生”一样!
效果如下:单独双击运行test.exe:这就是最终呈现的效果;
最后,编译exe的时候出于安全考虑,建议随机基址选是,编译生成的exe每次被加载的时候imagebase都是变化的,能在一定程度上增加逆向的难度,让逆向变得很繁琐,有效消耗逆向人员的时间和精力!
扩展:很多小伙伴刚接触PE的时候,分不清楚imagebase、VA、RVA、PointerToRawData、foa等概念,这里来缕一缕;
(1)imageBase:整个文件(比如pe、sys、dll等)在虚拟内存中的起始地址;以32位为例,exe默认都是从400000开始的;OD中查询PE文件头就是imageBase;上面说的重定位也是从imageBase这里开始重新计算新地址;
(2)virtualAddress:OD中左边的地址列就是VA,也就是在虚拟内存中的地址;
(3)RVA: related virtual address,翻译成中文就是相对虚拟地址;这个“相对”怎么理解了?“相对”就是VA和当前所在区段的距离;比如一个VA=0x401010,很明显是属于text段的,由于text段的基址是401000,那么这个地址的RVA=0x401010-0x401000=0x10;
(4)PointerToRawData:我也不知道怎么翻译成中文合适,所以干脆不翻译了;为什么会有这么一个概念了? 或则说这个概念想表达啥了?由于历史原因,很久以前磁盘的价格是很贵的,为了节约磁盘空间,pe文件尽量“压缩”式地存放在磁盘中。为了标注各个段在磁盘中的位置,就衍生出了PointerToRawData:即磁盘中,每个段头部相对于文件开始位置的距离;当运行程序时,需要把文件加载到内存。由于采用了虚拟地址、页交换等技术,虚拟内存空间大很多,没必要“节约”着用了,为了提高cpu寻址的效率,就需要内存对齐了,直观感觉就是下图中绿色的部分;这就导致了另一个问题:同样一个段,在磁盘中相对文件起始的距离,和内存中相对imageBase的距离是不一样的(因为地址对齐,拉伸了)! 用010editor这种软件是可以查到PointerToRawData的,如下:
(5)FOA: file offset address,又叫file address,简称FA,也就是磁盘文件内部的地址,计算出这个地址有利于静态查找和破解打补丁(比如改if跳转逻辑)。比如我们用OD找到了一个内存虚拟地址,怎么根据这个地址在磁盘的文件中找到同样的地址了?原理很简单,如下:
先计算出RAV,也就是当前虚拟地址相对于所在段的距离,比如上面的0x401010-0x401000=0x10,也就是这个地址距离text段的偏移是0x10;现在问题就转换成了怎么找text段在文件中的起始地址了?也很简单,直接查PointerToRawData呗!比如这个值是0x200,那么FA=PointerToRawData+RVA=0x200+0x10=0x210!在磁盘文件内部0x210的位置就能找到了!