手工模拟PE加载器

selph
selph
发布于 2020-10-23 / 1100 阅读
1
0

手工模拟PE加载器

前言

手工模拟PE加载器,也就是内存加载技术,做一遍这个可谓真是把我仅有的PE结构的知识用到了个遍,算是作为PE入门阶段的“期末考试”吧,哈哈哈

本来想写好几篇关于PE结构的笔记文章来分享对PE结构常见表结构(导入表,导出表,重定位表)的分析,完成这一篇之后,我觉得没必要了

因为我觉得这一篇基本上概括了对所有常用表结构的分析与实践,理论结合实践才是最好的学习方式;

这里将从头到尾的来介绍如何手工模拟PE加载器加载PE文件并执行(其实是学习笔记哈哈哈),希望能帮到需要的人

内存加载运行介绍

把DLL或exe加载到内存中去执行,而不需要通过LoadLibrary等API函数去操作,当程序需要动态调用DLL的时候,内存加载技术可以将DLL作为资源插入程序,然后内存加载,这样就不需要将DLL释放到本地了

内存加载技术的核心就在于模拟PE加载器加载PE文件,也就是对导入表,导出表,重定位表的操作过程

内存加载技术的原理&实现

首先,将DLL文件加载到内存当中,需要先将DLL按照映像对齐大小映射到内存中,然后根据重定位表修改硬编码数据,最后根据导出表函数地址来修改导入表函数地址(当然,也可以通过GetProcAddress来实现)

具体PE原理在之前的文章或笔记里都有详细提到,这里就不提了

加载到内存中后,需要获取DLL文件的入口地址,然后跳转执行来完成启动

总的来说操作流程如下:

  1. 根据映像大小SizeOfImage在自己程序内申请可读可写可执行的空间,首地址就是DLL的加载基址
  2. 将DLL文件按照映像对齐大小复制到上述空间
  3. 修正重定位表
  4. 修正导入表
  5. 修改DLL的加载基址ImageBase
  6. 获取DLL的入口地址,构造DLLMain函数实现加载

对于EXE文件,重定位表不是必须的,exe和dll加载的原理唯一的区别在于构造入口函数,exe不需要构造入口函数,根据PE结构获得入口地址偏移AddressOfEntryPoint并计算出入口地址即可,然后跳转

下面来分别实现一下两种内存加载技术:

1,将DLL文件读入内存

	char szFileName[MAX_PATH] = "C:\\Users\\selph\\source\\WinHackBook\\04-LoadPE\\Debug\\TestDll1.dll";

	// 打开DLL文件并获取DLL文件大小
	HANDLE hFile = ::CreateFileA(szFileName, GENERIC_READ | GENERIC_WRITE,
		FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING,
		FILE_ATTRIBUTE_ARCHIVE, NULL);

	DWORD dwFileSize = ::GetFileSize(hFile, NULL);
	// 申请动态内存并读取DLL到内存中
	BYTE* lpData = new BYTE[dwFileSize];
	DWORD dwRet = 0;
	::ReadFile(hFile, lpData, dwFileSize, &dwRet, NULL);

	// 将内存DLL加载到程序中
	LPVOID lpBaseAddress = MemLoadLibrary(lpData, dwFileSize);
	printf("DLL加载成功\n");

通过CreateFile API 打开PE文件(这里是DLL文件):

HANDLE CreateFileA(
  LPCSTR                lpFileName,				//文件名
  DWORD                 dwDesiredAccess,		//请求的访问,常用的是GENERIC_READ | GENERIC_WRITE
  DWORD                 dwShareMode,			//共享模式,读/写/删除,FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE
  LPSECURITY_ATTRIBUTES lpSecurityAttributes,	//安全描述符,一般NULL
  DWORD                 dwCreationDisposition,	//对存在或者不存在进行的操作,OPEN_EXISTING为打开已经存在的文件
  DWORD                 dwFlagsAndAttributes,	//文件或设备的属性,不设置属性是FILE_ATTRIBUTE_NORMAL,文件应该被存档,标记要备份或者删除的文件用FILE_ATTRIBUTE_ARCHIVE
  HANDLE                hTemplateFile			//访问模板,NULL
);

成功则返回文件的打开句柄

https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea


然后通过GetFileSize API 获取其大小:

DWORD GetFileSize(
  HANDLE  hFile,			//文件句柄
  LPDWORD lpFileSizeHigh	//返回文件大小的高位双字,不需要可NULL
);

返回值为文件大小的低位双字(文件大于4GB,一个DWORD就不够表示其大小,所以文件小于4GB,只用低位双字即可)

https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfilesize


有了文件句柄和文件大小,接下来就可以把文件读到内存缓冲区了,通过new申请一个这么大的内存缓冲区

//动态申请数组类型:
int *iArr = new int[3];
//释放:
delete [] iArr;

然后使用ReadFile API进行读入:

BOOL ReadFile(
  HANDLE       hFile,				//文件句柄
  LPVOID       lpBuffer,			//内存缓冲区
  DWORD        nNumberOfBytesToRead,//要读取的最大字节数
  LPDWORD      lpNumberOfBytesRead,	//读取的实际字节数
  LPOVERLAPPED lpOverlapped			//使用FILE_FLAG_OVERLAPPED打开文件时才用,这里NULL即可
);

返回TRUE即为成功,到这里文件内容全部复制到缓冲区里了,lpData就是文件内容的缓冲区指针,接下来通过MemLoadLibrary函数进行文件到映像的各种转换

https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-readfile


2,获取映像大小

现在已经打开了文件,获取了文件内容,接下来要把文件版的PE转化为映像版的PE,首先第一步,为映像申请空间,这就需要知道需要的映像大小是多少

DWORD GetSizeOfImage(LPVOID lpData){
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpData;
	PIMAGE_NT_HEADERS32 pNt = (PIMAGE_NT_HEADERS32)(pDos->e_lfanew + (DWORD)pDos);
	DWORD dwSizeOfImage = pNt->OptionalHeader.SizeOfImage;

	return dwSizeOfImage;
}

PE文件的映像大小写在了PE头的扩展头的SizeOfImage里,只需要将它读取出来即可

在内存中和在文件中,PE文件的headers都是一样的

3,申请内存空间

	LPVOID lpBaseAddress = ::VirtualAllocEx(GetCurrentProcess(),NULL,dwSizeOfImage,MEM_COMMIT|MEM_RESERVE,PAGE_EXECUTE_READWRITE);

这就不用封装成函数了,因为一句话搞定哈哈哈,用的是VirtualAllocEx API:

LPVOID VirtualAllocEx(
  HANDLE hProcess,			//进程句柄
  LPVOID lpAddress,			//指定要分配内存的起始位置,如NULL,则函数来确定起始位置
  SIZE_T dwSize,			//申请空间的大小
  DWORD  flAllocationType,	//申请权限类型,这里要一步一步保存和提交修改,所以选择MEM_COMMIT|MEM_RESERVE
  DWORD  flProtect			//保护类型,可读可写可执行:PAGE_EXECUTE_READWRITE
);

函数会将分配的内存初始化为0,不用手动再初始化了,GetCurrentProcess() 函数可以返回当前进程的伪句柄(不用释放的那种)

https://docs.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualallocex

4,按照内存对齐大小读取文件

现在有为映像准备的空间了,接下来把文件读取成映像吧

image-20201023102043628

通过PELoad可以很方便查看到文件和内存的对齐大小,简单来说,内存中每个部分分配1000字节,文件中则分配200个字节,现在要把文件的200字节复制到内存中的1000字节中去,多余的空位用0填充

借用网上的图片来看,就比较好理解:

PE结构

代码如下:参数分别是文件缓冲区的指针(基地址),映像缓冲区的指针(基地址)

BOOL MemMapFile(LPVOID lpData, LPVOID lpBaseAddress){
	//读取NT头
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpData;
	PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD)pDos + pDos->e_lfanew);
	
	//读取所有头+区段头的大小
	DWORD dwSizeOfHeaders = pNt->OptionalHeader.SizeOfHeaders;
	//获取区段数量
	WORD dwNumOfSections = pNt->FileHeader.NumberOfSections;
	//加载所有头+区段头的大小
	::RtlCopyMemory(lpBaseAddress,lpData,dwSizeOfHeaders);

	//对齐SectionAlignment循环加载区段
	//获得第一个区段的文件位置(NT头之后紧接着就是区段)
	PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD)pNt + sizeof(IMAGE_NT_HEADERS));
	for (int i = 0; i < dwNumOfSections; i++){
		//判断区段存不存在于文件dll中,不存在就跳过
		if ((pSectionHeader->VirtualAddress == 0) || (pSectionHeader->SizeOfRawData == 0)) {
			pSectionHeader++;
			continue;
		}

		LPVOID lpSrcMem = (LPVOID)(pSectionHeader->PointerToRawData + (DWORD)lpData);
		LPVOID lpDstMem = (LPVOID)(pSectionHeader->VirtualAddress + (DWORD)lpBaseAddress);
		DWORD dwSizeOfRawData = pSectionHeader->SizeOfRawData;
		::RtlCopyMemory(lpDstMem,lpSrcMem,dwSizeOfRawData);
		
		pSectionHeader++;
	}
	return TRUE;
}

首先从PE结构中读取头的大小,然后将头部分直接复制到映像中去,API RtlCopyMemory:

VOID RtlMoveMemory(
  _Out_       VOID UNALIGNED *Destination,	//目的存储块
  _In_  const VOID UNALIGNED *Source,		//源头存储块
  _In_        SIZE_T         Length			//长度
);

https://docs.microsoft.com/en-us/windows/win32/devnotes/rtlmovememory

接下来获取区段数量(在PE头的文件头里NumberOfSections)和第一个区段头的位置就是PE头结束后的位置:

image-20201023103202447

这两个头是紧挨着的,有这两样就可以开始循环填充了,循环区段数量次:

先进行判断,判断区段存不存在,如果区段不存在,则区段头里的几个值(文件中大小,内存中大小)都会是0;

如果区段存在,分别获取文件中和映像该区段的首地址,然后按照区段在文件中的大小向映像中进行复制,复制完检查下一个区段头,直到将全部区段都映射到映像中去

5,修正重定位表

到此,映像已经映射出来了,只需要稍作修改就能让映像运行起来了:

PE加载器在加载PE的时候会修正重定位表中的地址中的硬编码信息,将硬编码进行重定位(也就是把原来的VA转换成RVA,再转换成现在的VA)

先了解一下重定位表的结构:

image-20201023104015751

重定位表是一个结构体数组,以全0作为数组最后一项结尾,数组里有3个内容,VirtualAddress是映像中存在硬编码的部分的基地址的偏移,SizeOfBlock是结构体的大小,Block数组记录了存在硬编码的地址RVA:

举个例子:

image-20201023104313786

这里第一个成员的值是360F,这里WORD类型是2字节,这2个字节16位里,前4位记录的是类型,一般来说只要是x86是3,x64是A,就需要进行重定位,这里用的是x86的软件,所以是3

后12位记录的是偏移量,这里的后12位是60F,则存在硬编码的地址应该在内存中的基址+11000h+60Fh的位置,换算成文件位置则是:400h+60Fh = A0Fh的位置:

image-20201023105059798

定位到这个位置之后,把这个位置DWORD值 - PE的ImageBase + 实际的ImageBase 就是修正后的结果了

所以就先把重定位表中的重定位信息修正了,根据以上原理去看代码就不难理解:

//修改重定位表,这里的参数是内存中的基址
BOOL DoRelocationTable(LPVOID lpBaseAddress){
	//定位到重定位表
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpBaseAddress;
	PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD)lpBaseAddress + pDos->e_lfanew);
	PIMAGE_BASE_RELOCATION pLoc = (PIMAGE_BASE_RELOCATION)((DWORD)lpBaseAddress+
		pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);

	//判断是否有重定位表,数据目录表不存在的时候,VirtualAddress为0,也就是指向映像基址
	if ((LPVOID)pLoc == lpBaseAddress) {
		return TRUE;
	}

	//开始扫描重定位表,重定位表VirtualAddress和SizeOfBlock都为0表示重定位表结束
	while ((pLoc->VirtualAddress + pLoc->SizeOfBlock) != 0) {
		//重定位数据,位于IMAGE_BASE_RELOCATION表开头8字节之后
		PWORD pLocData = (PWORD)((DWORD)pLoc + sizeof(IMAGE_BASE_RELOCATION));
		//计算本节需要修正的重定位项(地址)的数目,每个数据都是16字节(4+12字节,高4位指出重定位类型,低12位是RVA)
		//sizeOfBlock的值包括了SizeOfBlock和VirtualAddress的大小,8字节,所以需要减去
		DWORD dwNumOfReloc = (pLoc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);

		for (DWORD i = 0; i < dwNumOfReloc; i++){
			//高4位是类型,如果等于3则表示需要修正
			if ((DWORD)((pLocData[i] & 0x0000F000) == 0x00003000)) {
				//需要修正的数据
				//修改重定位表的数据,重定位表记录的是存在硬编码的地址,以基址+偏移的形式
				//存在硬编码的地址 = 重定位基址+重定位表数据偏移 = 基址+重定位地址+重定位数据(数据后12位)
				PDWORD pAddress = (PDWORD)((PBYTE)pDos + pLoc->VirtualAddress + (pLocData[i] & 0x0FFF));
				//重定位地址 = 硬编码地址0 - ImageBase + 实际基地址
				//			 = 实际基地址 - ImageBase + 硬编码地址
				//这种写法也行:*pAddress = *pAddress - pNt->OptionalHeader.ImageBase + (DWORD)pDos
				DWORD dwDelta = (DWORD)pDos - pNt->OptionalHeader.ImageBase;
				*pAddress += dwDelta;
			}
		}
		//转移到下一个重定位区段进行处理
		pLoc = (PIMAGE_BASE_RELOCATION)((PBYTE)pLoc + pLoc->SizeOfBlock);
	}
	return TRUE;
}

6,修正导入地址表

PE加载器在加载PE的时候会将导入函数的地址填入导入地址表中,导入表结构如下:是个导入表结构体数组

image-20201023111056882

主要要用到的项是OriginalFirstThunk和FirstThunk,前者是导入名称表,后者是导入地址表

这两个表用到的结构体是一样的,都是IMAGE_THUNK_DATA:

typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // PBYTE 
        DWORD Function;             // PDWORD
        DWORD Ordinal;
        DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;

这个结构体里装了个联合体,

如果指向导入名称表:则内容是AddressOfData,指向PIMAGE_IMPORT_BY_NAME的结构体

如果指向导入地址表:

  • 序号导入的话,Ordinal首位是1,低4位是导入序号,

  • 名称导入的话,Function的值是函数地址

这里我们要将导入函数的地址填入导入地址表中,所以需要知道这个函数是怎么导入的,然后通过GetProcAddress API获取其函数地址,然后将函数地址填入导入地址表中。

通过GetProcAddress获取函数地址,还需要知道dll名称,通过dll名称获取模块句柄

所以这里的代码流程是:

  1. 先获取导入表数组的数量和第一个成员的地址
  2. 根据导入表的数量,进行循环遍历
    1. 获取导入名称表地址
    2. 获取导入地址表地址
    3. 进行导入名称表的遍历(导入名称表数组以0作为最后一个成员结束)
      1. 获取导入函数的名称或序号
      2. 加载这个dll,通过名称或序号,获取其函数地址
      3. 将这个地址填入导入地址表
      4. 进入下一次循环
    4. 进入下一次循环
  3. 两次遍历完成后,导入表就已经完成了修正

结合以上原理,下面的代码应该不难看懂:

//向导入表中填入导入表地址
BOOL DoImportTable(LPVOID lpBaseAddress){
	//定位导入表
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpBaseAddress;
	PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD)lpBaseAddress + pDos->e_lfanew);
	PIMAGE_IMPORT_DESCRIPTOR pImp = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD)lpBaseAddress +
		pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);	

	//遍历DLL导入表中的DLL及获取导入表中的函数地址
	char* lpDllName;
	HMODULE hDll = NULL;
	DWORD i = 0;
	PIMAGE_THUNK_DATA lpImpName = NULL;
	PIMAGE_IMPORT_BY_NAME lpName = NULL;
	PIMAGE_THUNK_DATA lpImpAddr = NULL;
	FARPROC lpFunAddress = NULL;
	while (TRUE) {
		if (0 == pImp->OriginalFirstThunk) {
			break;
		}
		//获取导入表中的DLL名称并加载DLL
		lpDllName = (char*)((DWORD)pDos + pImp->Name);
		hDll = ::GetModuleHandleA(lpDllName);
		if (hDll == NULL) {
			hDll = ::LoadLibraryA(lpDllName);
			if (hDll == NULL) {
				pImp++;
				continue;
			}
		}

		i = 0;
		//IMAGE_THUNK_DATA指向一个4字节的联合体,内容是某数组的RVA
		//获取INT首地址
		lpImpName = (PIMAGE_THUNK_DATA)(pImp->OriginalFirstThunk + (DWORD)pDos);
		//获取IAT首地址
		lpImpAddr = (PIMAGE_THUNK_DATA)(pImp->FirstThunk + (DWORD)pDos);
		while (TRUE) {
			if (lpImpName[i].u1.AddressOfData == 0) {
				break;
			}

			//获取导入函数名称结构
			lpName = (PIMAGE_IMPORT_BY_NAME)(lpImpName[i].u1.AddressOfData  +(DWORD)pDos);

			//判断是名称导出还是序号导出,如果是序号导出,则Ordinal最高位为1,低4位为序号
			if (0x80000000 & lpImpName[i].u1.Ordinal) {
				//序号导出
				//当IMAGE_THUNK_DATA最高位为1时,表示函数以序号方式导入
				//????序号导入长啥样
				lpFunAddress = ::GetProcAddress(hDll,(LPCSTR)(lpImpName[i].u1.Ordinal & 0x0000FFFF));
			}
			else {
				lpFunAddress = ::GetProcAddress(hDll, lpName->Name);
			}
			lpImpAddr[i].u1.Function = (DWORD)lpFunAddress;
			i++;
		}
		pImp++;
	}
	return TRUE;
}

7,修改加载基地址

PE加载器在加载PE的时候会将进程分配的基地址填入到扩展头的ImageBase里去,我们也这么干:

// 修改PE文件加载基址IMAGE_NT_HEADERS.OptionalHeader.ImageBase
BOOL SetImageBase(LPVOID lpBaseAddress){
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpBaseAddress;
	PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD)lpBaseAddress + pDos->e_lfanew);
	pNt->OptionalHeader.ImageBase = (DWORD)lpBaseAddress;
	return TRUE;
}

8,修改内存页属性

因为我们加载到内存中的映像是需要执行的,所以给这块内存加上可执行的属性

	//修改内存页属性
	DWORD dwoldProtect = 0;
	::VirtualProtectEx(GetCurrentProcess(), lpBaseAddress, dwSizeOfImage, PAGE_EXECUTE_READWRITE, &dwoldProtect);

9,修改Dllmain入口点

调用DLL的入口函数DllMain,函数地址即为PE文件的入口点IMAGE_NT_HEADERS.OptionalHeader.AddressOfEntryPoint

BOOL CallDllMain(LPVOID lpBaseAddress){
	typedef_DllMain DllMain = NULL;
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpBaseAddress;
	PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD)pDos + pDos->e_lfanew);
    
    typedef BOOL(__stdcall* typedef_DllMain)(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved);
	DllMain = (typedef_DllMain)((DWORD)pDos + pNt->OptionalHeader.AddressOfEntryPoint);
    
	BOOL bRet = DllMain((HINSTANCE)lpBaseAddress, DLL_PROCESS_ATTACH, NULL);

    return bRet;
}

这里定义入口函数,然后获取入口函数地址,进行调用执行

到这里,这个dll已经成功加载到内存中了

接下来我们可以获取dll的导出函数来调用

10,获取Dll导出函数

因为这个DLL是我们手工加载的,所以我们也需要手工来获取函数地址(主要是不了解GetProcAddress具体是怎么操作的哈哈哈),那么我们就来模拟一个GetProcAddress函数:

这里涉及的知识是PE结构体的导出表,我们通过遍历导出表,通过参数传入的名称和导出表的函数名称相比较,来获取指定的函数地址

image-20201023120354066

导出表结构如图,导出表内部重要的几个成员分别是:

  • 函数名称数组AddressOfNames
  • 函数地址数组AddressOfFunctions
  • 函数序号数组AddressOfNameOrdinals
  • 函数名称数量NumberOfNames
  • 函数地址数量NumberOfFunctions

上面3个数组里装的都是DWORD类型的RVA,RVA分别指向函数名称,函数地址,函数序号

由于这三个数组是对齐的,所以序号要同时往后进行遍历

遍历流程如下:

  1. 获取导出函数名称
  2. 比较是否是我们要找的函数名称
  3. 如果是,则获取函数序号(2字节)
  4. 根据函数序号获取函数地址
//模拟GetProcAddress函数
LPVOID MemGetProcAddress(LPVOID lpBaseAddress, PCHAR lpszFuncName){
	//定位导出表
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpBaseAddress;
	PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD)lpBaseAddress + pDos->e_lfanew);
	PIMAGE_EXPORT_DIRECTORY pExp = (PIMAGE_EXPORT_DIRECTORY)((DWORD)pDos 
		+ pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

	//获得导出表数据
	PDWORD lpAddressOfNames = (PDWORD)(pExp->AddressOfNames + (DWORD)lpBaseAddress);
	DWORD dwNumOfNames = pExp->NumberOfNames;
	PCHAR pFunName = NULL;
	PDWORD lpAddressOfFunction = (PDWORD)((DWORD)pDos + pExp->AddressOfFunctions);
	PWORD lpAddressOfNameOrdinals = (PWORD)((DWORD)pDos + pExp->AddressOfNameOrdinals);
	LPVOID lpFunc = NULL;
	//遍历导出表寻找函数
	for (int i = 0; i < dwNumOfNames; i++){
		pFunName = (PCHAR)((DWORD)pDos + lpAddressOfNames[i]);//导出函数名数组
		if (0 == ::lstrcmpA(pFunName, lpszFuncName)) {
			//0表示相同
			WORD wHint = lpAddressOfNameOrdinals[i];
			lpFunc = (LPVOID)((DWORD)pDos + lpAddressOfFunction[wHint]);
			break;
		}
	}
	return lpFunc;
}

11,释放内存加载的DLL

到这里关于加载DLL及其调用导出函数已经结束了,剩下的内容就是释放资源了

//释放加载到内存中的dll的空间
BOOL MemFreeLibrary(LPVOID lpBaseAddress){
	BOOL bRet = FALSE;
	if (lpBaseAddress == NULL) {
		return bRet;
	}

	bRet = ::VirtualFreeEx(GetCurrentProcess(),lpBaseAddress,0,MEM_RELEASE);
	lpBaseAddress = NULL;

	return bRet;
}

12,释放读取的DLL文件

	//释放读取的DLL文件
	delete[] lpData;
	lpData = NULL;
	::CloseHandle(hFile);

内存加载DLL执行演示

示例DLL源代码

// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"

extern "C" __declspec(dllexport) 
void ShowMessage() {
    MessageBoxA(NULL, "I'm DLL File", "HELLO", MB_OK);
}

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

调用导出函数

image-20201023121824479

内存加载EXE执行

内存加载DLL和加载EXE基本上流程是一样的,不同的地方在于入口点的操作

判断文件是exe还是dll

在PE头的标准头里的Characteristic里,0x2000有值表示是DLL,否则是exe,所以可以通过这个进行判断

//判断是exe(TRUE)还是dll(FALSE)
BOOL IsExeorDll(LPVOID lpBaseAddress){
	//exe和dll的区别在PE头的标准头的特征里有
	//读取NT头
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpBaseAddress;
	PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD)pDos + pDos->e_lfanew);

	if (pNt->FileHeader.Characteristics & 0x00002000 == 1) {
		return FALSE;
	}

	return TRUE;
}

如果是EXE,那么入口点的操作要进行一点点更变:

修改EXE入口点

exe文件只需要直接跳转到入口点即可

// 执行exe,直接跳转到PE文件的入口点IMAGE_NT_HEADERS.OptionalHeader.AddressOfEntryPoint即可
BOOL CallExeEntry(LPVOID lpBaseAddress){
	PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)lpBaseAddress;
	PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((DWORD)pDos + pDos->e_lfanew);
	LPVOID lpExeEntry = (LPVOID)((DWORD)pDos + pNt->OptionalHeader.AddressOfEntryPoint);
	
    // 跳转到入口点处执行
	__asm
	{
		mov eax, lpExeEntry
		jmp eax
	}
	return TRUE;
}

到此为止,EXE和DLL加载的区别已经讲完了,对前面的代码稍作修改即可完成对exe文件的内存加载运行

还有一点不同就是exe通常来说,重定位是不必要的,如果exe程序没有重定位表的话,那就需要将exe文件加载到默认加载基址上,不然会加载失败

内存加载EXE执行演示

示例EXE源代码

// TestExe.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
#include<Windows.h>
int main()
{
    MessageBoxA(NULL,"This is Loaded Exe 哦","阳光正好",MB_OK);
    return 0;
}

内存加载执行

image-20201023124130067


可通过暴力枚举PE结构特征头的方法来枚举进程加载的所有模块,通过与正常方法获得的模块进行进行比对,可以判断是否存在可以的PE文件

参考资料

  • 《逆向工程核心原理》第13,16章
  • 《Windows黑客编程技术详解》第4章
  • 《C++黑客编程揭秘与防范》第6章

评论