编写PE文件解析器(一)

第一篇先写一个PE格式解析器,学了那么久了写出来防止自己忘记,顺便练练手。PE格式解析是比较基础的内容,后面再越写越深。我写这个不是介绍pe格式,而是说编写解析代码,解释定义什么的网上一堆就不粘了,重要的定义我尽量简洁的描述清楚就行,如果想知道每一个定义或者字段是用来干嘛的,在pecoff文档里写的很清楚。业余时间写的,时间不充裕写得很慢,写错请斧正,转载请注明,高手轻批。


解析前需要弄清楚的是RVA相对虚拟地址和RA相对地址,RVA是系统的loader加载到内存中用的,RA是解析文件用的。PE里存的偏移地址大部分都是RVA,因为它是要加载到内存中使用的,所以pe里存rva会快一些。

写解析代码的时候如果想让RVA和RA通用, 需要对载入的方式进行区分,因为后面文章会讲到修改PE内容往里面塞东西和其它一些什么的,所以为了方便我写成通用的。

用读文件或者用GetModuleHandle之类的函数载入,读文件方式解析时需要做rva2ra转换,GetModuleHandle载入的是运行中的进程,已经映射好了,就不用做转换了,把得到的地址作为基址,直接把它转成PCHAR就行。

几乎所有的定义都在winnt.h里,一般使用时只要导入这个就行,只有一些不太常用的定义在其它头文件里。

下面开始:


1、IMAGE_DOS_HEADER【Dos头】

这个段应该是历史遗留问题,需要到的只有e_magic和e_lfanew两个字段。e_magic直接判断等于IMAGE_DOS_SIGNATURE就行,一般pe是以0x4D5A开始的,看定义有其它的值,但是我没见过有其它值的pe。e_lfanew是用来跳过dos stub的,就是用21中断显示那一段英文的代码,删掉这段代码也没关系,以后再写。其它字段可以全部无视。


2、IMAGE_NT_HEADERS【Nt头】

通过base+dosHeader->e_lfanew偏移得到。这个结构分为32版本和64位版本,包含Signature(PE的为IMAGE_NT_SIGNATURE,就是PE\0\0)、FileHeader和OptionalHeader,其中只有OptionalHeader有32和64的区别,其它都是一样,解析到这里的时候需要注意区分是否为32或64位的pe,其信息记录在OptionalHeader字段中,字段中的Magic记录了该pe类型,因为Magic所在位置是一样的,所以可以用PIMAGE_NT_HEADERS32(base + DosHeader->e_lfanew)->OptionalHeader.Magic判断值,再确定使用IMAGE_NT_HEADERS32还是IMAGE_NT_HEADERS64来定这个结构的类型,声明的时候直接用union就行,直接以32位版本赋值后也不用改。

IMAGE_FILE_HEADER FileHeader相对有用的地方是NumberOfSections和SizeOfOptionalHeader。其它有用的信息都记录在OptionalHeader里。(叫做可选,实际上非常重要)


2.1、IMAGE_FILE_HEADER【文件头】

Machine记录机器类型,后面会用到。FileHeader里的NumberOfSections记录了PE节的数量,SizeOfOptionalHeader记录了可选头的大小,第一个节头是紧跟着可选头的,可以通过偏移可选头大小得到。因为64位的可选头跟32位是不同的,所以需要使用可选头大小偏移。Characteristics的定义是IMAGE_FILE_XXX,具体每一项是什么功能在pecoff文档的第14页。


2.2、IMAGE_OPTIONAL_HEADER【可选头】

可选头的Magic定义为IMAGE_NT_OPTIONAL_XXX_MAGIC,32位和64位区别是,64位把32位的BaseOfData跟ImageBase合并了(用ULONGLONG,因为长度不够,但是这里的低32位跟32位版本的位置是一样的,不知道是不是有什么特殊用意),另外的不同点是后面的SizeOfXXXX,也是把双字扩展到四字这样。

相对有用的是AddressOfEntryPoint(程序入口点)、ImageBase(映像基址,dll默认的为0x10000000,ce的exe默认为0x00010000,95~NT系统的默认为0x00400000);SectionAlignment是节载入内存时使用的对齐值,必须大于等于FileAlignment;其它的也有用,现在先暂时不用,以后再说。具体结构解释网上有,不贴了。

入口点解析的时候不用到,今后详述。映像基址不必多说,GetModuleHandle返回的就是映像基址,只不过有时候这个位置被其它东西占了的时候,系统会换一个位置载入,所以取到的地址不一定是这个字段写的值,这个值只能说是一个默认的选择,如果载入到了其它地方,就需要重定位,这个后面会说。

Subsystem取值为IMAGE_SUBSYSTEM_XXX,DllCharacteristics取值为IMAGE_DLLCHARACTERISTICS_XXX,具体定义在文档(后面指的文档都是pecoff文档)的第20页。

2.3、IMAGE_DATA_DIRECTORY【数据目录】

长度为IMAGE_NUMBEROF_DIRECTORY_ENTRIES的IMAGE_DATA_DIRECTORY数组。

用IMAGE_DIRECTORY_ENTRY_XXX做下标取对应目录的内容,IMAGE_DATA_DIRECTORY的内容是对应目录的rva和大小,如果是读文件形式解析的PE的话,需要进行rva转换,这就需要读节表的一些信息。


3、IMAGE_SECTION_HEADER【节头/段头】

使用宏IMAGE_FIRST_SECTION(NtHeader32)可以取到第一个节头,偏移的方法是从ntheader偏移到可选头,然后再偏移一个可选头大小,得到的就是第一个节头的位置。

这个宏不需要区分32位64位是因为nt头只有可选头长度不一样,其长度又记在前面同位置的字段中,所以跳过的时候就不需要区分,另外节头是不区分32位64位的。

得到第一个节头之后可以使用指针++或者下标来遍历节表,注意不要超过NumberOfSections就行了。IMAGE_SECTION_HEADER的第一个字段Name是节的名称,使用IMAGE_SIZEOF_SHORT_NAME(8)作为长度的一个字符数组(以BYTE声明,以0填充,使用UTF8),如果节名正好是8个字符的话,字符串后是没有\0的。如果想要支持更长的名称,需要使用以”/ascii十进制值“的形式,后面跟着的十进制是字符串表的偏移,但是可执行文件不使用字符串表并且不支持大于8个字符的节名,如果名字太长会被截断(文档24页)。所以名字最好不要直接用strcmp判断,而且需要判断以“/”开头的情况;贴一下代码,专注打脸一百年,其实这样够用了:

PIMAGE_SECTION_HEADER PE::GetSectionHeaderByName(LPCSTR name)
{
	auto sectionCount = NtHeader32->FileHeader.NumberOfSections;
	auto firSec = FirstSectionHeader;
	CHAR tmpStr[9] = {0}; 
	for (int i = 0; i < sectionCount; i++)
	{
		memcpy(tmpStr,firSec->Name,8); 
		if (strcmp(name,tmpStr) == 0)
		{
			return firSec;
		}
		firSec++;
	}
	return NULL;
}

节头的VirtualAddress记录节的rva(loader映射到内存的位置),PointerToRawData记录节的文件偏移位置,因为要把文件中对应的位置映射到内存,所以需要记录下这两个值,而这两个值就是rva2va的关键。因为loader把不同的节映射到不同的内存位置,所以当转换一个rva时先要确定它所在的节,因为同属一个节,所以,要转换的rva所在节头的VirtualAddress - PointerToRawData = 要转换的rva - 转换后的文件偏移。转换一下式子不难得到rva2va的公式。因为这个要常用,所以写成函数,代码很简单,就是判断rva在哪个段里,然后根据式子算一下就行,就不贴了。另外也只有在节中的地址能做这个转换,原因前面说的都是。

Characteristics是节的属性,loader映射时根据该属性设置节所在的内存区域是否可读写等,这个以后会用到。IMAGE_SCN_XXX是关于这些属性的定义,因为贴进来会增加文章长度又没什么用,自己去看吧。

解析完节表之后得到了rva2va的函数,下面就可以解析数据目录中的内容了。取数据目录对应内容的地址我偷懒就简化成一个宏:

#define GET_IMAGE_DIRECTORY(type, index, save) {auto tmp = GetDataDirectory(index);\
	if (tmp->VirtualAddress != 0)\
	{\
		if(isUseRva)\
		{\
			save = (type)(_filebuf + tmp->VirtualAddress);\
		}\
		else\
		{\
			save = (type)(_filebuf + RvaToRaw( tmp->VirtualAddress));\
		}\
		save##Size = &tmp->Size;\
	}}

作用是取得对应目录指针赋予已定义好的变量。save是预先定义好的变量,index是IMAGE_DIRECTORY_ENTRY_XXX,type是对应下标内容的类型。里面用的函数代码不贴了,应该怎么写的前面都讲了。


4、IMAGE_DIRECTORY_ENTRY_EXPORT【导出表 - IMAGE_EXPORT_DIRECTORY】

因为写了宏的关系,定位到导出表我用的是:

GET_IMAGE_DIRECTORY(PIMAGE_EXPORT_DIRECTORY,IMAGE_DIRECTORY_ENTRY_EXPORT,ExportDirectory);

直接使用ExportDirectory就行了,其它的也是这样用。下面解析导出表结构。

Name字段写了自己的名字(这个必要吗?),结构中使用3个DWORD存储导出的函数地址、函数名地址、下标。AddressOfNames里的字符串是经过排序的; AddressOfFunctions里的项可能会比AddressOfNames里的少,因为有一种情况是一个函数导出多个名字,也有可能多,因为有些函数不导出名字(例子:mshtml.dll);AddressOfNameOrdinals存储的是下标号,有的时候函数还可以按序号导入,这个表长度跟AddressOfNames一样(所以只有NumberOfFunctions和NumberOfNames两个字段)。所以这三个数组有这样的关系:AddressOfNames和AddressOfNameOrdinals是对应的,AddressOfNameOrdinals里的内容是AddressOfFunctions的下标。AddressOfNames和AddressOfFunctions大小不一定相同,如果想得到所有导出函数,需要遍历较大的数组。下面代码演示怎么读导出表:

bool PE::GetExportArrays(OUT PDWORD& funcs, OUT PDWORD& names,OUT PWORD& nameOrdinals)
{
	auto tmpExport = ExportDirectory;
	if (!tmpExport)
	{
		return false;
	}
	if (isUseRva)
	{
		funcs = PDWORD(_filebuf + tmpExport->AddressOfFunctions);
		names = PDWORD(_filebuf + tmpExport->AddressOfNames);
		nameOrdinals=PWORD(_filebuf + tmpExport->AddressOfNameOrdinals);
	}
	else
	{
		funcs = PDWORD(_filebuf + RvaToRaw(tmpExport->AddressOfFunctions));
		names = PDWORD(_filebuf + RvaToRaw(tmpExport->AddressOfNames));
		nameOrdinals=PWORD(_filebuf + RvaToRaw(tmpExport->AddressOfNameOrdinals));	
	}
	return true;
}

bool PE::ReadExportDirectory(OUT PDWORD& func, OUT PDWORD& name, OUT PWORD& nameOrdinal, IN OUT PDWORD stat, bool isSkipNullName)
{
	static auto tmpExport = ExportDirectory;
	static PDWORD tmpAddrFunc;
	static PDWORD tmpAddrName;
	static PWORD tmpAddrNameOrd;
	static DWORD numberOfFuncs;
	static DWORD numberOfNames;
	static int i,j;
	bool statFlag;
	switch (*stat)
	{
	default:
	case 0:
		func = NULL;
		name = NULL;
		nameOrdinal = NULL;
		if (!GetExportArrays(tmpAddrFunc, tmpAddrName, tmpAddrNameOrd))
		{
			return false;
		}
		numberOfFuncs = tmpExport->NumberOfFunctions;
		numberOfNames = tmpExport->NumberOfNames;
		statFlag = numberOfFuncs < numberOfNames;
		if (isSkipNullName)
		{
			// 跳过空函数名,强制读name数组
			statFlag = true;
		}
		if (statFlag)
		{
			// 读name
			*stat = 1;
			for (i = 0; i < numberOfNames; i++)
			{

				name = &tmpAddrName[i];
				if (*name == NULL)
				{
					continue;
				}
				nameOrdinal = &tmpAddrNameOrd[i];
				func = &tmpAddrFunc[*nameOrdinal];
				return true;
	case 1:
				func = NULL;
				name = NULL;
				nameOrdinal = NULL;
			}
		}
		else
		{
			// 读func
			*stat = 2;
			for (i = 0; i < numberOfFuncs; i++)
			{
				func = &tmpAddrFunc[i];
				if (*func == NULL)
				{
					continue;
				}
				for (j = 0; j < numberOfNames; j++)
				{
					if (tmpAddrNameOrd[j] == i)
					{
						nameOrdinal = &tmpAddrNameOrd[j];
						name = &tmpAddrName[j];
						break;
					}
				}
				return true;
	case 2:
				func = NULL;
				name = NULL;
				nameOrdinal = NULL;
			}
		}
		break;
	}
	return false;
}

PVOID PE::GetFuncAddressByName(LPCSTR name) //GetProcAddress
{
	PDWORD tmpAddrFunc;
	PDWORD tmpAddrName;
	PWORD tmpAddrNameOrd;
	DWORD stat = NULL;
	PCHAR tmpName;
	while (ReadExportDirectory(tmpAddrFunc, tmpAddrName, tmpAddrNameOrd, &stat, true))
	{
		if (isUseRva)
		{
			tmpName = PCHAR(_filebuf + *tmpAddrName);
		}
		else
		{
			tmpName = PCHAR(_filebuf + RvaToRaw(*tmpAddrName));
		}
		if (strcmp(tmpName,name) == 0)
		{
			return isUseRva?_filebuf+*tmpAddrFunc:
				_filebuf+RvaToRaw(*tmpAddrFunc);
		}
	}
	return NULL;
}

写了个根据名字取函数地址的函数,其实就是个GetProcAddress,只不过这里支持解析硬盘上的文件,读文件的部分在其它函数里,这里只是其中一个功能。

还可以写一个按序号来取得函数地址的,感觉用处不大,代码就不贴了。如果要从序号来取的话就需要用到输出表中的Base,AddressOfNameOrdinals记录的是相对Base的偏移,根据以上的代码应该不难扩展。

写了蛮长的,再写下去不利于阅读体验,新建另一篇来写。



你可能感兴趣的:(PE)