PE文件,全称Portable
Executable文件,是Windows系统可执行文件采用的普遍格式,像我们平时接触的EXE、DLL、OCX,甚至SYS文件都属于PE文件的范畴。为了在可执行文件中方便的引用其它类型资源内容,PE文件有一个独立的资源段,将程序执行所需要的全部资源文件链接到PE文件内部方便使用。今天我们就来研究一下PE文件资源段的内容与格式。
在研究PE文件资源段之前,我们必须了解PE文件头结构。下面我们来分析PE文件头的相关格式。PE文件头总体结构如图1所示,可见PE文件的格式是相对复杂的。我将在下文依次分析各个部分的含义。
图1 PE文件结构
MS-DOS头部
第一个结构是MS-DOS
头,这个是为了兼容旧的DOS程序而设计的,如果一个Win32程序在DOS模式下运行(所谓的DOS模式是指纯DOS环境,而不是Windows控制台),DOS头部会把执行定位到MS-DOS实模式残余程序,该程序会调用int
21中断输出一个字符串“This program cannot be run in DOS
mode”,然后直接退出。MS-DOS头部在“winnt.h”里面有定义。
typedef struct _IMAGE_DOS_HEADER {// DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp;// Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss;// Initial (relative) SS value
WORD e_sp;// Initial SP value
WORD e_csum; // Checksum
WORD e_ip;// Initial IP value
WORD e_cs;// Initial (relative) CS value
WORD e_lfarlc;// File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4];// Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew;// File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
第一成员变量e_magic被称为魔术数字,它被用于表示一个MS-DOS兼容的文件类型。所有MS-DOS兼容的可执行文件都将这个值设为0x5A4D,表示ASCII字符MZ。MS-DOS头部之所以有的时候被称为MZ头部,就是这个缘故。
至于其余的成员变量,基本上都是为了DOS下实模式设计,如今已经没有什么实际作用,除了最后一个成员变量e_lfanew。这个成员变量用来表示PE头部在这个PE文件中的偏移量。通过如下代码可以获得PE头部地址:
BYTE *pFileImage = (BYTE*)pPeImage;
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
PIMAGE_FILE_HEADER pFileHeader
=(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
注意,在计算偏移地址的时候除了偏移量pDosHeader->e_lfanew,还有一个DWORD的偏移,这个DWORD
是存储PE文件标志的,值为0x4550,对应ASCII字符“PE”。
PE头部
下面我们来介绍PE头部的内容。PE头部在“winnt.h”中定义如下。
typedef struct _IMAGE_FILE_HEADER {
WORDMachine;
WORDNumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORDSizeOfOptionalHeader;
WORDCharacteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
这个结构比较简单,Machine表示这个可执行文件被构建的目标机器种类,本程序获得的Machine值是0x14c,代表i386;NumberOfSection表示本PE文件具有多少个有多少个段头部和多少个段实体,每一个段头部和段实体都在文件中连续地排列着,所以要决定段头部和段实体在哪里结束的话,段的数目是必需的;TimeDataStamp是一个时间戳变量;PointerToSymbolTable和NumberOfSymbols确定了符号表的位置和大小。SizeOfOptionalHeader表示选项头部的大小,选项头部就在PE文件头部后面线性排列,这个结构容后介绍,但是大家不要被名称迷惑,选项头部是对PE文件执行至关重要的结构,并非“Optional”。Characteristics表示文件的一些特征,比如对于一个可执行文件而言,分离调试文件是如何操作的。
选项头
选项头在“winnt.h”中的定义如下。
typedef struct _IMAGE_OPTIONAL_HEADER {
// Standard fields.
WORDMagic;
BYTEMajorLinkerVersion;
BYTEMinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
// NT additional fields.
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORDMajorOperatingSystemVersion;
WORDMinorOperatingSystemVersion;
WORDMajorImageVersion;
WORDMinorImageVersion;
WORDMajorSubsystemVersion;
WORDMinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORDSubsystem;
WORDDllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
此结构成员按照功能的区别可以分为两个域:标准域和NT附加域。标准域由前九个成员变量构成,是和UNIX可执行文件的COFF格式所公共的部分。虽然标准域保留了COFF中定义的名字,但是Windows
NT仍然将它们用作了不同的目的。其中,Magic表示了不同PE文件的种类,我们一般的Win32程序这个值都是0x10b;MajorLinkerVersion、MinorLinkerVersion表示链接此映像的链接器版本;SizeOfCode表示可执行代码尺寸;SizeOfInitializedData表示已初始化的数据尺寸;SizeOfUninitializedData表示未初始化的数据尺寸;AddressOfEntryPoint表示本PE文件执行的入口点;BaseOfCode表示已载入映像的代码(“.text”段)的相对偏移量;BaseOfData表示已载入映像的未初始化数据(“.bss”段)的相对偏移量。
剩下的变量构成了NT附加域。顾名思义,这个域主要存储一些和程序在Windows系统下运行相关的信息,所示如下。
ImageBase:进程映像地址空间中的首选基地址。Windows NT的Microsoft Win32
SDK链接器将这个值默认设为0x00400000。
SectionAlignment:从ImageBase开始,每个段都被相继的装入进程的地址空间中。SectionAlignment则规定了装载时段能够占据的最小空间数量。Windows
NT虚拟内存管理器规定,段对齐不能少于页尺寸(当前的x86平台是4096字节),并且必须是成倍的页尺寸。4096字节是x86链接器的默认值,但是它可以通过-ALIGN:
linker开关来设置。
FileAlignment:映像文件首先装载的最小的信息块间隔。
MajorOperatingSystemVersion:系统主板本号。
MinorOperatingSystemVersion:系统次版本号。
MajorImageVersion:应用程序主板本号。
MinorImageVersion:应用程序次版本号。
MajorSubsystemVersion:Windows Win32子系统主板本号。
MinorSubsystemVersion:Windows Win32子系统次版本号。
Win32VersionValue:保留,一般被链接器设为零。
SizeOfImage:表示载入的可执行映像的地址空间中要保留的地址空间大小,其值等于本PE文件各个段所占的地址空间之和。注意,各段所占的地址空间是考虑SectionAlignment对齐后的空间,所以说SectionAlignment的大小会影响本变量的值。
SizeOfHeaders:本PE文件全部头结构所占的体积,包括DOS头,PE文件头和PE选项头等,也可以把这个值看成一个偏移地址,本PE文件的第一个段实体就开始于此。
CheckSum:校验和,某些文件可以设置该值对本身进行完整性保护。
Subsystem:表示该可执行文件的目标子系统。
DllCharacteristics:用来表示一个DLL映像是否为进程和线程的初始化及终止包含入口点的标记,现在已经废弃。
SizeOfStackReserve:程序保留的栈大小。
SizeOfStackCommit:栈提交大小。
SizeOfHeapReserve:程序堆保留大小。
SizeOfHeapCommit:堆提交大小。
默认情况下,程序的堆栈空间都是1个页面的申请大小和16个页面的保留大小。
LoaderFlags:告知装载器是否在装载时中止和调试。
NumberOfRvaAndSizes:表示后面的DataDirectory数组个数,一般都为16。
DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]:数据目录表示文件中其它可执行信息重要组成部分的位置。它事实上就是一个IMAGE_DATA_DIRECTORY结构的数组,位于可选头部结构的末尾。当前的PE文件格式定义了16种可能的数据目录,这之中的11种现在在使用中。
数据目录数组各项含义在“winnt.h”中的定义如下。
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (X86 usage)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE7 // Architecture Specific
Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG10 // Load Configuration
Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 //Bound Import Directory
in headers
#define IMAGE_DIRECTORY_ENTRY_IAT12 // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 //Delay Load Import
Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 //COM Runtime descriptor
上面各项的含义注释已经明确说明,不再赘述。我们通过下面的代码可以定位到选项头:
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)
(pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));
PE文件段
PE文件段没有什么特定的结构特点,它几乎可以被链接器链接到PE文件的任何地方,程序执行时从PE文件定位段全靠段头部。
段头部每个40字节长,以数组的形式存放在Image Optional Header后面,可以使用如下代码获得该数组的起始地址。
PIMAGE_SECTION_HEADER pSectionHeader =(PIMAGE_SECTION_HEADER)(((char
*)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));
我们可以读取PE文件头部的NumberOfSections变量获取该数组的大小。IMAGE_SECTION_HEADER在“winnt.h”中的定义如下。
typedef struct _IMAGE_SECTION_HEADER {
BYTEName[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORDNumberOfRelocations;
WORDNumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
其中,Name储存区段的名称,这个名称最大长度8字节,且开头第一个字符必须是“.”,如“.text”、“.data”等。接下来的Union现在已经不再使用,没有什么实际意义。VirtualAddress:这个域标识了进程地址空间中要装载这个段的虚拟地址。实际的地址由将这个域的值加上可选头部结构中的ImageBase虚拟地址得到。切记,如果这个映像文件是一个DLL,那么这个DLL就不一定会装载到ImageBase要求的位置。所以一旦这个文件被装载进入了一个进程,实际的ImageBase值应该通过使用GetModuleHandle来检验。SizeOfRawData:表示原始数据的大小,也就是根据FileAlignment进行对齐之前的数据大小。PointerToRawData:这是一个文件中段实体位置的偏移量。接下来的四个变量PointerToRelocations、PointerToLinenumbers、NumberOfRelocations、NumberOfLinenumbers在PE格式中不使用。Characteristics定义了段的特征。表1显示了不同Characteristics值对应的不同含义。
可能取值 对应含义
0x00000020 代码段
0x00000040 已初始化数据段
0x00000080 未初始化数据段
0x04000000 该段数据不能被缓存
0x08000000 该段不能被分页
0x10000000 共享段
0x20000000 可执行段
0x40000000 可读段
0x80000000 可写段
表1 Characteristics取值范围及含义
虚拟地址
虚拟地址的概念最早在数据目录数组中就有涉及,其标示的各个关键域位置就是虚拟地址。PE文件是需要在进程运行时加载到进程地址空间的,其加载方式不是简单的将文件读入内存,而是按照段头的标示,将各个段分别加载到进程地址空间的特定位置。在PE文件头部有一个变量ImageBase是该PE文件加载入进程地址空间的首选地址,其他各段加载的地址是以ImageBase为基地址,以其段头部变量VirtualAddress为偏移量计算出来的。所谓某变量的虚拟地址就是在PE文件加载后,该变量相对于PE文件加载基地址的偏移量。
所以,如果需要把某个虚拟地址转换为其在PE文件中的偏移量,我们需要首先知道该虚拟地址所指变量属于的区段,然后根据区段头部的VitrualAddress变量和该虚拟地址的差值求出该变量相对本区段起始位置的偏移量,再根据区段在文件中的位置求出该变量在文件中的偏移量。
解析资源文件
下面我们开始解析资源文件的工作。在我的示例程序中,首先将选定的PE文件映射到进程内存中,pFileImage即为映射基地址。下一步程序需要确定资源段,一般可以采用遍历全部段头部寻找名称为“.rsrc”段的方式,但是此方式并不可靠,因为资源段的名称并非固定。在我的程序中使用根据数据目录特定项确定资源段的方式,代码如下。
PIMAGE_SECTION_HEADER GetResoucreSectionHeader(char *pFileImage)
{
DWORD dwDataDirectoryIndex =IMAGE_DIRECTORY_ENTRY_RESOURCE;
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
PIMAGE_FILE_HEADER pFileHeader
=(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader
=(PIMAGE_OPTIONAL_HEADER32)(pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));
PIMAGE_SECTION_HEADER pSectionHeader =(PIMAGE_SECTION_HEADER)(((char
*)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));
if(dwDataDirectoryIndex>=pOptionalHeader->NumberOfRvaAndSizes)
{
return NULL;
}
PIMAGE_SECTION_HEADER pSectionBelong=NULL;
for(int i=0;i<pFileHeader->NumberOfSections;i++)
{
if(pSectionHeader[i].VirtualAddress<=pOptionalHeader->DataDirectory[dwDataDirectoryIndex].VirtualAddress&&pSectionHeader[i].VirtualAddress+pSectionHeader[i].SizeOfRawData>=pOptionalHeader->DataDirectory[dwDataDirectoryIndex].VirtualAddress+pOptionalHeader->DataDirectory[dwDataDirectoryIndex].Size)
{
pSectionBelong=&(pSectionHeader[i]);
break;
}
}
return pSectionBelong;
}
资源段的构成比较复杂,是一个树状结构,主要由两种结构维护。
typedef struct _IMAGE_RESOURCE_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORDMajorVersion;
WORDMinorVersion;
WORDNumberOfNamedEntries;
WORDNumberOfIdEntries;
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;
这个结构每一个实体对应资源树的一个分支节点,其中NumberOfNamedEntry代表这个节点的子节点中靠名字标识的子节点的个数,NumberOfIdEntries代表这个节点的子节点中靠ID标识的子节点的个数,这两个数之和即为该节点全部叶子节点的个数。
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31;
DWORD NameIsString:1;
};
DWORD Name;
WORDId;
};
union {
DWORD OffsetToData;
struct {
DWORD OffsetToDirectory:31;
DWORD DataIsDirectory:1;
};
};
}IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;
这个结构的实体对应树的每个节点本身,其本质上就是两个DWORD成员变量,第一个变量表示该节点的名称,第二个变量表示其对应数据的位置。具体使用方式后面会有介绍。
资源树
我们可以通过如下代码获得资源树根节点。
PIMAGE_RESOURCE_DIRECTORY pImageResourceDirectory =
(PIMAGE_RESOURCE_DIRECTORY)(((char
*)pFileImage)+pResourceHeader->PointerToRawData);
资源树的第一层分支代表各种资源类型,每一类资源分出一棵子树,不同子树根节点对应的成员变量Name的值不同,代表不同类型的资源,具体含义参见Winnt.h,不再赘述了。我们可以使用如下代码遍历资源树的第一层。
PIMAGE_RESOURCE_DIRECTORY_ENTRY pResourceEntry =
(IMAGE_RESOURCE_DIRECTORY_ENTRY *) ((char *) pImageResourceDirectory +
sizeof (IMAGE_RESOURCE_DIRECTORY));
for(int i=0; i< (pImageResourceDirectory->NumberOfIdEntries +
pImageResourceDirectory->NumberOfNamedEntries); i++,pResourceEntry++)
{ //所有资源
CString strName;
switch(pResourceEntry->Name)
{
case 1:
{
strName.Format("光标");
break;
}
……重复代码省略……
default:
{
strName.Format("Unknow :%u",pResourceEntry->Name);
break;
}
}
}
资源树的第二层对应本类资源的全部资源,例如PE文件光标资源有3个,则根节点下对应光标资源的子树第一层就有三个分支。我们可以通过如下代码遍历各个子树。
PIMAGE_RESOURCE_DIRECTORY pResourceBranch = (IMAGE_RESOURCE_DIRECTORY *)
((char *) pImageResourceDirectory + pResourceEntry->OffsetToDirectory);
PIMAGE_RESOURCE_DIRECTORY_ENTRY pFirstFloorResourceEntry =
(IMAGE_RESOURCE_DIRECTORY_ENTRY *) ((char *) pResourceBranch +
sizeof(IMAGE_RESOURCE_DIRECTORY));
for(int k=0; k<(pResourceBranch->NumberOfIdEntries +
pResourceBranch->NumberOfNamedEntries); k++,pFirstFloorResourceEntry++)
{
……通过pFirstFloorResourceEntry访问被遍历的二层子树
}
该层子树主要存储资源的名称,资源可以按照两种方式导入:名称导入或者ID导入。如果资源采用ID导入,则其成员变量Name直接就代表ID值;如使用名称导入,则Name变量为一个相对于资源段首的偏移量,指向一个IMAGE_RESOURCE_DIR_STRING_U结构,这个结构里保存着WCHAR格式存储的资源名称,下面代码将尝试解析该项。
CString strName;
if(pFirstFloorResourceEntry->NameIsString ==0)
{
strName.Format("ID :%u",pFirstFloorResourceEntry->Name);
}
else
{
PIMAGE_RESOURCE_DIR_STRING_U pImageResourceDirString =
(PIMAGE_RESOURCE_DIR_STRING_U)((char
*)pImageResourceDirectory+pFirstFloorResourceEntry->NameOffset);
WCHAR wsBuffer[128];
memset(wsBuffer,0,sizeof(WCHAR)*128);
memcpy(wsBuffer,pImageResourceDirString->NameString,sizeof(WCHAR)*pImageResourceDirString->Length);
USES_CONVERSION;
char *p =W2A(wsBuffer);
strName.Format("Name :%s",p);
}
资源树的第三层不再分支,每一个资源对应一个下层节点,存储其语言ID,具体语言ID值的含义请参考winnt.h,下面给出读取语言ID的代码。
PIMAGE_RESOURCE_DIRECTORY pResourceLeaf = (IMAGE_RESOURCE_DIRECTORY *)
((char *) pImageResourceDirectory +
pFirstFloorResourceEntry->OffsetToDirectory );
PIMAGE_RESOURCE_DIRECTORY_ENTRY pResourceLeafEntry =
(IMAGE_RESOURCE_DIRECTORY_ENTRY *) ((char *) pResourceLeaf +
sizeof(IMAGE_RESOURCE_DIRECTORY));
CString Tmp;
if(pResourceLeafEntry->Id<=0xfff)
{
Tmp.Format("语言ID: 0x0%X",pResourceLeafEntry->Id);
}
else
{
Tmp.Format("语言ID: 0x%X",pResourceLeafEntry->Id);
}
资源树的最后叶子节点是一个特殊的结构:IMAGE_RESOURCE_DATA_ENTRY,其定义如下:
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData;
DWORD Size;
DWORD CodePage;
DWORD Reserved;
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;
这样,我们就可以根据这个结构中的变量获得该资源在PE文件中的偏移量和大小,注意这个偏移量是一个虚拟地址,下面代码获得每个资源的大小和相对文件头部的偏移量。
PIMAGE_RESOURCE_DATA_ENTRY pResourceData = (IMAGE_RESOURCE_DATA_ENTRY *)
((char *) pImageResourceDirectory + pResourceLeafEntry->OffsetToData );
//资源入口结构
Tmp.Format("偏移量:%u" ,
pResourceData->OffsetToData-pResourceHeader->VirtualAddress);
Tmp.Format("资源大小:%u",pResourceData->Size);
结束语
至此,整个PE文件资源段就分析完了,基于本文对应的示例程序,大家可以写一个PE文件资源抽取器,或者写一个PE文件资源修改器。如果你对本文有什么问题到黑防论坛或者发信和交流。最后祝大家玩得愉快。