众所周知,windows下可执行文件必须符合一定的格式要求,微软官方称之为PE文件(关于PE文件的详细介绍这里就不赘述了,google一下可以找到大把);用户在界面双击exe时,有个叫做explorer的进程会监测并接受到这个事件,然后根据注册表中的信息取得文件名,再以Explorer.exe这个文件名调用CreateProcess函数去运行用户双击的exe;PC中用户一般都是这样运行exe的,所以很多用户态的exe都是exlporer的子进程。
用process hacker截图如下:
那么这个explorer究竟是怎么成功“运行”这个exe的了?这里面涉及到大量细枝末节就不深究了,本文先把主干思路捋一遍!
好了,到此为止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的位置就能找到了!