参考:《逆向工程核心原理》,Microsoft_ImageHlp
可执行文件结构与导入地址表
可执行文件结构
PE/COFF与ELF
- Portable Executable 是Windows系统使用的可执行文件格式,其前身是UNIX平台的COFF(Common Object File Format),一般有PE32和PE32+(64位)两种格式。Windows下.exe, .scr, .sys, .vxd, .dll, .ocx, .cpl, .drv, .obj扩展名均为PE文件
- Executable and Links Format 是linux系统下的可执行文件格式
PE基本结构

- 从DOS Header到Section Table称为PE头部分,余下节区合称PE体。文件中使用偏移(offset),内存中使用虚拟地址(VA, Virtual Address)来表示位置。PE头尾部与各节区之间有NULL填充(NULL padding)
- 以Win32为例,每个程序都被分配了4GB的虚拟内存(0x00000000~0xFFFFFFFF),VA指的是在此内存上的绝对地址,而为了实现重定位,PE头内广泛使用相对地址(RVA),其基准称为ImageBase
PE file header
DOS header
1 | //未注释掉的为重要成员 |
e_magic:DOS签名(魔数),PE文件为0x4D5A(ASCII:MZ)e_lfanew:指示NT头的offset,根据PE规范应为0x000000E0
DOS stub
- DOS存根(stub)在DOS Header之后,是个可选项且大小不固定:
The MS-DOS stub is a valid application that runs under MS-DOS. It is placed at the front of the EXE image. The linker places a default stub here, which prints out the message “This program cannot be run in DOS mode” when the image is run in MS-DOS. The user can specify a different stub by using the /STUB linker option.
NT Header
- NT头包含一个4字节的签名,一个文件头结构头和一个可选头结构体
1
2
3
4
5typedef struct _IMAGE_NT_HEADER {
DWORD Signature;//PE
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} - 其中Signature处于最前部,其文件偏移正是
_IMAGE_DOS_HEADER.e_lfanew所指示的offset
PE Header(COFF File Header)
- 标准COFF文件头,即_IMAGE_FILE_HEADER,其结构为:
1
2
3
4
5
6
7
8
9
10//未注释掉的为重要成员
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
//DWORD TimeDateStamp;
//DWORD PointerToSymbolTable;
//DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
}Machine
每个CPU都有唯一的Machine码,兼容32位Intel x86芯片的Machine码为0x014C。具体定义可见Microsoft debug help libraryNumberOfSections
PE文件把代码、数据、资源等依据属性分类到各节区(Sections)中存储。该值指出文件中存在节区的数量。一定要大于0,且当与实际数量不符时,文件会运行错误。SizeOfOptionalHeader
指出_IMAGE_OPTIONAL_HEADER结构体的长度,optional的意识是对于obj文件来说,这个结构体不是必须的,此时该长度值可为0Characteristics
该字段用于标示文件属性,文件是否是可运行的状态,是否为DLL文件等信息,以bit_OR形式组合起来,即同EFLAGS相同以某一位的0/1指示属性,具体定义可见Microsoft debug help library
Optional Header
可选头是PE头结构体中最大的。完整定义参考Microsoft debug help library,其中的重要成员有:
Magic
为0x010B表示这是32位可选头结构体;为0x020B表示这是64位可选头结构体
AddressOfEntryPoint
持有EP的RVA值,指出最先执行的代码起始地址
ImageBase
加载到虚拟内存时的基准;一般来说,.exe, .dll文件将被优先装载到0x00000000->0x7FFFFFFF,.sys文件将被装载到0x80000000->0xFFFFFFFF;执行PE文件时即先创建进程,再将文件载入内存,然后把EIP寄存器值设为ImageBase+AddressOfEntryPoint
SectionAlignment FileAlignment
分别指定了在内存中节区的最小单位和在磁盘文件中的最小单位。磁盘文件和内存的节区大小必定为改值的整数倍
SizeOfImage
加载PE文件到内存时,指定了镜像在虚拟内存中所占空间的大小,一般而言,文件的大小与加载到内存的大小是不同的
SizeOfHeader
指出整个PE FileHeader的大小,也必须是FileAlignment的整数倍。第一节区所在位置,与SizeOfHeader距文件开始偏移的量相同
Subsystem
用来区分系统驱动文件与一般可执行文件,值为1时表示系统驱动文件,2表示GUI文件,3表示CUI文件
NumberOfRvaAndSizes
指定DataDirectory数组中的成员个数
DataDirectory
理解成一个结构体数组即可,结构体内只有两个值分别是一个RVA和一个Size;数组内的关键成员有:
1 | DataDirectory[0] = EXPORT Directory |
Section Table(SectionHeaders)
- 将代码、数据、资源分区存储的结构,可以保证程序的安全性。
- 一个_IMAGE_SECTION_HEADER结构体构成的数组,其中每个结构体对应一个节区
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[8];//该名称仅供参考用,无意义
union {
DWORD PhysicalAddress;
DWORD VirtualSize;//内存中节区所占大小
} Misc;
DWORD SizeOfRawData;//磁盘文件中节区所占大小
DWORD VirtualAddress;//不带任何值,内存中节区起始地址
DWORD PointerToRawData;//不带任何值,磁盘文件中节区起始位置
//分别由SectionAlignment和FileAlignment确定
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
}
内存映射
文件偏移 RawDataOffset 等于 虚拟地址RVA 减去 节区起始地址 _IMAGE_SECTION_HEADER.VirtualAddress 加上 磁盘起始位置 _IMAGE_SECTION_HEADER.PointerToRawData
导入地址表
动态链接库
- Dynamic Linked Library是支撑整个Windows OS的基石
- 在16位DOS时代并不存在DLL这一概念,代码连接将所需要的“库函数”直接插入到程序中,这在32位Windows下会极高浪费内存与磁盘空间,于是便有了DLL
- 将“库”单独组成DLL文件,程序需要时即可调用
- 内存映射技术使加载后的DLL代码、资源在多个进程中共享
- 更新库时也只需更新DLL文件即可
- DLL可以显式加载——调用时再加载,和隐式加载——加载程序时即加载;函数调用时先获取IAT地址处的值,这个值再指向内存中DLL函数的地址
那么,为什么不直接调用内存中DLL函数的地址呢?
- 即某一DLL函数在不同系统不同语言不同版本中的加载地址都不相同,且发生DLL重定位时,其
ImageBase会发生改变。这样编译器记录了该函数IAT地址并预留了位置,文件执行时即发生DLL函数地址写到IAT预留位置的过程实际操作中无法保证DLL一定会被加载到PE头指定位置,但总能保证EXE文件被加载到
ImageBase,因为后者拥有自己的虚拟内存空间_IMAGE_IMPORT_DESCRIPTOR
- 该结构体记录了PE文件导入哪个库文件,导入多个库即有多个结构体并组成数组,末位有NULL结构体标记;结构体具体定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //INT(Import Name Table)的地址
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD NAME; //库名称字符串的地址
DWORD FirstThunk; //IAT的地址
}
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
} - INT与IAT均为整型数组(4 bytes),以NULL结尾,两数组大小应相同;INT中各元素值为IMAGE_IMPORT_BY_NAME结构体指针;
IAT importing
下面即可总结出PE装载器将一个DLL各个函数地址导入IAT的过程
- 读取一个
IMAGE_IMPORT_DESCRIPTOR的Name成员,获取DLL库名称 - 装载对应库
- 读取
IID的OriginalFirstThunk成员,获取INT地址 - 读取INT中的一个值,获取指向的
IMAGE_IMPORT_BY_NAME结构体地址 - 依据该结构体获取对应函数的起始地址
- 读取
IID的FirstThunk成员,获取IAT地址 - 将5获得的地址写入IAT对应地址上
- 重复4~7直到INT读到NULL
分析练习
- 以Windows的
notepad.exe为例- 打开确认几个签名,
MZ@0x00000000占据两个字节,PE@0x000000E0占据四个字节 PE后即为IMAGE_FILE_HEADER
,如图机器标识码为0x014C,节区个数有0x0003个,正下方即是可选头大小0x00E0与特征值0x010F- 随即即可确认可选头区域
:开头字0x010B标示这是32位可选头,正下方双字即为EP地址0x0000739D,EP地址左下方双字即为基址地址0x01000000,基址地址右侧两个双字即分别是节区校准值0x00001000与文件校准值0x00000200(**alignment);两个双字下一行为不重要数据,再下行开始:第一个双字为内存镜像大小0x00014000,第二个双字为整个Headers大小0x00000400,右侧第三个字为子系统描述符0x0002表示是GUI程序;下一行为不重要数据,再一行开始第二个双字为目录(Directory)数组元素个数0x00000010,之后每个四倍字即描述一个目录,之后先看节区表(Section Table) - 此时我们可以发现右栏ASCII转义区显示了有意义的节区名
,以此可以快速分析,节区名所处四倍字为命名预留区域,下一个双字标示内存中节区所占大小,下一个双字标示内存中节区的起始地址,下一个双字标示磁盘文件中节区所占大小,下一个双字标示磁盘文件中节区的起始地址,再下下个双字为特征值;这样一个节区头结构体占36个字节 - 回到IAT导入过程,在第三步找到的目录数组中的第二个元素即为导入目录,四倍字的前双字标示导入目录在内存中的起始地址,通过内存映射换算:确定所处节区,减去内存中节区起始地址加上磁盘文件中节区所处地址,即找到导入描述结构体数组,每个元素由五个双字构成
- 第一个双字即为INT的RVA,第四个双字为命名字符串的RVA,第五个双子即为预留IAT的RVA,要在wWinHex上找到这些值均需要经过内存映射换算。
- 打开确认几个签名,