第一篇先写一个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的偏移,根据以上的代码应该不难扩展。
写了蛮长的,再写下去不利于阅读体验,新建另一篇来写。