【学习】Windows PE文件学习(一:导出表)

  今天做了一个读取PE文件导出表的小程序,用来学习。

  参考了《Windows PE权威指南》一书。

  首先, PE文件的全称是Portable Executable,可移植的可执行的文件,常见的EXE、DLL、OCX、SYS、COM都是PE文件。

  我们知道,一个Windows程序,它所实现的所有功能最终几乎都是调用系统DLL提供的API函数。要使用任何一个DLL所提供的函数,我们需要将它导入,也就是用到了导入表。然而对于那些提供了被导出的函数的DLL程序来说,他们必须使用导出表将函数导出,之后别的程序才可以使用。无论是系统提供的标准DLL还是个人编写的DLL,只要想提供自己的函数给别人使用就必须建立导出表。一般使用任何开发环境编写具有导出功能的程序,导出表都是由链接器自动建立的。程序员只需指定被导出的函数名称或序号即可。

  导出表通常出现在DLL文件的.edata节中。

  知道了导出表的位置,我们可以得到导出函数的地址,进而对这些函数进行Hook。而我们现在的目的是为了学习PE文件中导出表的构成,所以有必要了解PE文件的结构。

  

1 基本概念

  注:以下引用部分均来自网络

下表描述了贯穿于本文中的一些概念:

名称 描述
地址 是“虚拟地址”而不是“物理地址”。为什么不是“物理地址”呢?因为数据在内存的位置经常在变,这样可以节省内存开支、避开错误的内存位置等的优势。同时用户并不需要知道具体的“真实地址”,因为系统自己会为程序准备好内存空间的(只要内存足够大)
镜像文件 包含以EXE文件为代表的“可执行文件”、以DLL文件为代表的“动态链接库”。为什么用“镜像”?这是因为他们常常被直接“复制”到内存,有“镜像”的某种意思。看来西方人挺有想象力的哦^0^
RVA 英文全称Relatively Virtual Address。偏移(又称“相对虚拟地址”)。相对镜像基址的偏移。(有时候不一定是相对镜像的基址,还可能以某个结构的首地址为基址)
节是PE文件中代码或数据的基本单元。原则上讲,节只分为“代码节”和“数据节”。(文件中节大小通常以磁盘的一个物理扇区也就是512B对齐,若是镜像文件加载到内存中,以一个内存页大小对齐,32位为4K,64位为8K)
VA 英文全称Virtual Address。虚拟地址(虚拟内存中的正常地址,不需要进行转换)

    有特殊的节无论是在文件中还是在内存中,对齐粒度与其他的节都不同,如:资源字节码以双字对齐

2 PE文件的结构

  PE文件的总体结构:如果形象地说,即是3个头和身子。3个头是Dos头、Nt头和节表(节头),身子就是一个一个地节(存放数据和代码的地方)以上的各个头部都是数据结构,可以在winnt.h头文件中找到它们对应的struct定义(Nt头分为32位和64位)。

  由于PE文件是兼容Windows NT以前的Dos系统的,所以现在的任何一个PE文件拿到Dos系统上都是可以运行的,不过大多数可能也只能打出一句话:“This program cannot be run in DOS mode”。这是由PE文件的结构中的Dos头决定的。

用记事本打开任何一个镜像文件,其头2个字节必为字符串“MZ”,这是Mark Zbikowski的姓名缩写,他是最初的MS-DOS设计者之一。然后是一些在MS-DOS下的一些参数,这些参数是在MS-DOS下运行该程序时要用到的。在这些参数的末尾也就是文件的偏移0x3C(第60字节)处是是一个4字节的PE文件签名的偏移地址。该地址有一个专用名称叫做“E_lfanew”。这个签名是“PE00”(字母“P”和“E”后跟着两个空字节)。紧跟着E_lfanew的是一个MS-DOS程序。那是一个运行于MS-DOS下的合法应用程序。当可执行文件(一般指exe、com文件)运行于MS-DOS下时,这个程序显示“This program cannot be run in DOS mode(此程序不能在DOS模式下运行)”这条消息。用户也可以自己更改该程序,有些还原软件就是这么干的。同时,有些程序既能运行于DOS又能运行于Windows下就是这个原因。Notepad.exe整个DOS头大小为224个字节,大部分不能在DOS下运行的Win32文件都是这个值。MS-DOS程序是可有可无的,如果你想使文件大小尽可能的小可以省掉MS-DOS程序,同时把前面的参数都清0。

3 Nt头部 IMAGE_NT_HEADERS

  PE文件中较为复杂的部分就是这里了。

  在 2 中说到的DosHeader->E_lfanew所指向的签名“PE\0\0”就是Nt头的第一个成员了,我们在编程中得到Nt头的方法也是这样做的,因为Dos头的第二部分MS-DOS程序部分的大小是可以改变的,连带着整个Dos就是不定长的了,只有其中的E_lfanew指向它自己的末尾。

  Nt头同样分为两部分(除去签名4个字节):

  给出winnt.h中的定义

 typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; //4 bytes PE文件头标志:(e_lfanew)->‘PE\0\0’
IMAGE_FILE_HEADER FileHeader; //20 bytes PE文件物理分布的信息
IMAGE_OPTIONAL_HEADER32 OptionalHeader; //224bytes PE文件逻辑分布的信息
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

  其中的IMAGE_FILE_HEADER我们称作文件头,IMAGE_OPTIONAL_HEADER32称作可选映像头(我习惯称之为选项头)。有点滑稽的是,选项头可以说是PE文件中最重要、最复杂的部分了,却是可选的。。

同时我们看到,选项头在32位和64位PE文件中结构是有所不同的,注意,只是有所不同而已,大致上还是没什么区别的。但是在编程中我们必须将其考虑进去,由于选项头是不同的,所以Nt头也会是不同的。

typedef struct _IMAGE_FILE_HEADER {
WORD Machine; //运行平台
WORD NumberOfSections; //文件区块数目
DWORD TimeDateStamp; //文件创建日期和时间
DWORD PointerToSymbolTable; //指向符号表(主要用于调试)
DWORD NumberOfSymbols; //符号表中符号个数
WORD SizeOfOptionalHeader; //IMAGE_OPTIONAL_HEADER32 结构大小
WORD Characteristics; //文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

  但是文件头还是很清晰明了的,其中比较常用的成员就是Machine和Characteristics了。都是用来判断的,其中Machine标志了PE文件需要运行的目标平台,也就是期望在哪种指令集的CPU的平台上被加载,一般可以用来判断PE文件是64位还是32位的;Characteristics是采用标志位的方式来判断许多关于PE文件的信息,其中最重要的是判断其是不是dll,使用的时候与(&)上就行了。

#define IMAGE_FILE_RELOCS_STRIPPED           0x0001  // Relocation info stripped from file.
#define IMAGE_FILE_EXECUTABLE_IMAGE 0x0002 // File is executable (i.e. no unresolved external references).这是标志其能不能独立运行,像dll就必须让别的模块来加载自己,但是exe和sys是自己加载运行的
#define IMAGE_FILE_LINE_NUMS_STRIPPED 0x0004 // Line nunbers stripped from file.
#define IMAGE_FILE_LOCAL_SYMS_STRIPPED 0x0008 // Local symbols stripped from file.
#define IMAGE_FILE_AGGRESIVE_WS_TRIM 0x0010 // Aggressively trim working set
#define IMAGE_FILE_LARGE_ADDRESS_AWARE 0x0020 // App can handle >2gb addresses
#define IMAGE_FILE_BYTES_REVERSED_LO 0x0080 // Bytes of machine word are reversed.
#define IMAGE_FILE_32BIT_MACHINE 0x0100 // 32 bit word machine.
#define IMAGE_FILE_DEBUG_STRIPPED 0x0200 // Debugging info stripped from file in .DBG file
#define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0x0400 // If Image is on removable media, copy and run from the swap file.
#define IMAGE_FILE_NET_RUN_FROM_SWAP 0x0800 // If Image is on Net, copy and run from the swap file.
#define IMAGE_FILE_SYSTEM 0x1000 // System File.
#define IMAGE_FILE_DLL 0x2000 // File is a DLL.重要
#define IMAGE_FILE_UP_SYSTEM_ONLY 0x4000 // File should only be run on a UP machine
#define IMAGE_FILE_BYTES_REVERSED_HI 0x8000 // Bytes of machine word are reversed. #define IMAGE_FILE_MACHINE_UNKNOWN 0
#define IMAGE_FILE_MACHINE_I386 0x014c // Intel 386.32位
#define IMAGE_FILE_MACHINE_R3000 0x0162 // MIPS little-endian, 0x160 big-endian
#define IMAGE_FILE_MACHINE_R4000 0x0166 // MIPS little-endian
#define IMAGE_FILE_MACHINE_R10000 0x0168 // MIPS little-endian
#define IMAGE_FILE_MACHINE_WCEMIPSV2 0x0169 // MIPS little-endian WCE v2
#define IMAGE_FILE_MACHINE_ALPHA 0x0184 // Alpha_AXP
#define IMAGE_FILE_MACHINE_SH3 0x01a2 // SH3 little-endian
#define IMAGE_FILE_MACHINE_SH3DSP 0x01a3
#define IMAGE_FILE_MACHINE_SH3E 0x01a4 // SH3E little-endian
#define IMAGE_FILE_MACHINE_SH4 0x01a6 // SH4 little-endian
#define IMAGE_FILE_MACHINE_SH5 0x01a8 // SH5
#define IMAGE_FILE_MACHINE_ARM 0x01c0 // ARM Little-Endian
#define IMAGE_FILE_MACHINE_THUMB 0x01c2 // ARM Thumb/Thumb-2 Little-Endian
#define IMAGE_FILE_MACHINE_ARMNT 0x01c4 // ARM Thumb-2 Little-Endian
#define IMAGE_FILE_MACHINE_AM33 0x01d3
#define IMAGE_FILE_MACHINE_POWERPC 0x01F0 // IBM PowerPC Little-Endian
#define IMAGE_FILE_MACHINE_POWERPCFP 0x01f1
#define IMAGE_FILE_MACHINE_IA64 0x0200 // Intel 64 64位
#define IMAGE_FILE_MACHINE_MIPS16 0x0266 // MIPS
#define IMAGE_FILE_MACHINE_ALPHA64 0x0284 // ALPHA64
#define IMAGE_FILE_MACHINE_MIPSFPU 0x0366 // MIPS
#define IMAGE_FILE_MACHINE_MIPSFPU16 0x0466 // MIPS
#define IMAGE_FILE_MACHINE_AXP64 IMAGE_FILE_MACHINE_ALPHA64
#define IMAGE_FILE_MACHINE_TRICORE 0x0520 // Infineon
#define IMAGE_FILE_MACHINE_CEF 0x0CEF
#define IMAGE_FILE_MACHINE_EBC 0x0EBC // EFI Byte Code
#define IMAGE_FILE_MACHINE_AMD64 0x8664 // AMD64 (K8) 64位
#define IMAGE_FILE_MACHINE_M32R 0x9041 // M32R little-endian
#define IMAGE_FILE_MACHINE_CEE 0xC0EE

  接下来重点介绍选项头 IMAGE_OPTIONAL_HEADER。

偏移(32/64) 大小 英文名 中文名 描述
0 2 Magic 魔数 这个无符号整数指出了镜像文件的状态。
0x10B表明这是一个32位镜像文件。
0x107表明这是一个ROM镜像。
0x20B表明这是一个64位镜像文件。
2 1 MajorLinkerVersion 链接器的主版本号 链接器的主版本号。
3 1 MinorLinkerVersion 链接器的次版本号 链接器的次版本号。
4 4 SizeOfCode 代码节大小 一般放在“.text”节里。如果有多个代码节的话,它是所有代码节的和。必须是FileAlignment的整数倍,是在文件里的大小。
8 4 SizeOfInitializedData 已初始化数大小 一般放在“.data”节里。如果有多个这样的节话,它是所有这些节的和。必须是FileAlignment的整数倍,是在文件里的大小。
12 4 SizeOfUninitializedData 未初始化数大小 一般放在“.bss”节里。如果有多个这样的节话,它是所有这些节的和。必须是FileAlignment的整数倍,是在文件里的大小。
16 4 AddressOfEntryPoint 入口点 当可执行文件被加载进内存时其入口点RVA。对于一般程序镜像来说,它就是启动地址。为0则从ImageBase开始执行。对于dll文件是可选的。
20 4 BaseOfCode 代码基址 当镜像被加载进内存时代码节的开头RVA。必须是SectionAlignment的整数倍。
24 4 BaseOfData 数据基址 当镜像被加载进内存时数据节的开头RVA。(在64位文件中此处被并入紧随其后的ImageBase中。)必须是SectionAlignment的整数倍。
28/24 4/8 ImageBase 镜像基址 当加载进内存时镜像的第1个字节的首选地址。它必须是64K的倍数。DLL默认是10000000H。Windows CE 的EXE默认是00010000H。Windows 系列的EXE默认是00400000H。
32 4 SectionAlignment 内存对齐 当加载进内存时节的对齐值(以字节计)。它必须≥FileAlignment。默认是相应系统的页面大小。
36 4 FileAlignment 文件对齐 用来对齐镜像文件的节中的原始数据的对齐因子(以字节计)。它应该是界于512和64K之间的2的幂(包括这两个边界值)。默认是512。如果SectionAlignment小于相应系统的页面大小,那么FileAlignment必须与SectionAlignment相等。
40 2 MajorOperatingSystemVersion 主系统的主版本号 操作系统的版本号可以从“我的电脑”→“帮助”里面看到,Windows XP是5.1。5是主版本号,1是次版本号
42 2 MinorOperatingSystemVersion 主系统的次版本号
44 2 MajorImageVersion 镜像的主版本号
46 2 MinorImageVersion 镜像的次版本号
48 2 MajorSubsystemVersion 子系统的主版本号
50 2 MinorSubsystemVersion 子系统的次版本号
52 2 Win32VersionValue 保留,必须为0
56 4 SizeOfImage 镜像大小 当镜像被加载进内存时的大小,包括所有的文件头。向上舍入为SectionAlignment的倍数。
60 4 SizeOfHeaders 头大小 所有头的总大小,向上舍入为FileAlignment的倍数。可以以此值作为PE文件第一节的文件偏移量。
64 4 CheckSum 校验和 镜像文件的校验和。计算校验和的算法被合并到了Imagehlp.DLL 中。以下程序在加载时被校验以确定其是否合法:所有的驱动程序、任何在引导时被加载的DLL以及加载进关键Windows进程中的DLL。
68 2 Subsystem 子系统类型 运行此镜像所需的子系统。参考后面的“Windows子系统”部分。
70 2 DllCharacteristics DLL标识 参考后面的“DLL特征”部分。
72 4/8 SizeOfStackReserve 堆栈保留大小 最大大小。CPU的堆栈。默认是1MB。
76/80 4/8 SizeOfStackCommit 堆栈提交大小 初始提交的堆栈大小。默认是4KB。
80/88 4/8 SizeOfHeapReserve 堆保留大小 最大大小。编译器分配的。默认是1MB。
84/96 4/8 SizeOfHeapCommit 堆栈交大小 初始提交的局部堆空间大小。默认是4KB。
88/104 4 LoaderFlags 保留,必须为0
92/108 4 NumberOfRvaAndSizes 目录项数目

数据目录项的个数。由于以前发行的Windows NT的原因,它只能为16。

96/112 8*16 DataDirectory 数据目录

目录项数组,包含16个目录项

  这是完整的选项头的结构,其中只提Magic和DataDirectory,至于镜像加载时的基址与重定向问题,本文不做介绍,因为PE文件解析并不需要把镜像给加载到我们自己的程序中,只需要映射到内存中,对其内容进行解析即可。

  对Magic域进行判断,可以区分文件是64位还是32位,所以到现在我们有两种方法来区分。

  本文的主角——导出表就是由DataDirectory[0]中的目录项指出的,具体如下:

typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

  由此我们可以知道,DataDirectory并不是直接指向导出表的,真相是这样的:DataDirectory是一个数组,每个项都是一样的,IMAGE_DATA_DIRECTORY,每一项都由一个地址和大小,这就告诉我们导出表的基地址和其大小(别小看这个大小,我们会用到的)。

  得到了导出表的地址和大小,那么我们就可以搞些事情了(23333~)。

typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;            // 这是这个PE文件的模块名
DWORD Base;           
DWORD NumberOfFunctions;   // 这两个域按字面意思理解,这个为总的导出函数的个数
DWORD NumberOfNames;      // 这个是有名称的函数的个数,因为有的导出函数是没有名字的,只有序号
DWORD AddressOfFunctions; // RVA from base of image 这三个就是所谓的EAT,导出地址表
DWORD AddressOfNames; // RVA from base of image Nt头基址加上这个偏移得到的数组中存放所有的名称字符串
DWORD AddressOfNameOrdinals; // RVA from base of image Nt头基址加上这个偏移得到的数组中存放所有的函数序号,并不一定是连续的,但一般和导出地址表是一一对应的
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

  这是导出表的结构,其中重要的域我用红色的字标注了出来。

  我在网上查的资料说的比较清晰:

导出地址表(Export Address Table,EAT)

导出地址表的格式为下表所述的两种格式之一。如果指定的地址不是位于导出节(其地址和长度由NT头给出)中,那么这个域就是一个Export RVA;否则这个域是一个Forwarder RVA,它给出了一个位于其它DLL中的符号的名称。

偏移 大小 描述
0 4 Export RVA 当加载进内存时,导出函数RVA。
0 4 Forwarder RVA 这是指向导出节中一个以NULL结尾的ASCII码字符串的指针。这个字符串必须位于Export Table(导出表)数据目录项给出的范围之内。这个字符串给出了导出函数所在DLL的名称以及导出函数的名称(例如“MYDLL.expfunc”),或者DLL的名称以及导出函数的序数值(例如“MYDLL.#27”)。

Forwarder RVA导出了其它镜像中定义的函数,使它看起来好像是当前镜像导出的一样。因此对于当前镜像来说,这个符号同时既是导入函数又是导出函数。

例如对于Windows XP系统中的Kernel32.dll文件来说,它导出的“HeapAlloc”被转发到“NTDLL.RtlAllocateHeap”。这样就允许应用程序使用Windows XP系统中的Ntdll.dll模块而不需要实际包含任何相关的导入信息。应用程序的导入表只与Kernel32.dll有关。

导出地址表的的值有时为0,此时表明这里没有导出函数。这是为了能与以前版本兼容,省去修改的麻烦。

导出名称指针表

导出名称指针表是由导出名称表中的字符串的地址(RVA)组成的数组。二进制进行排序的,以便于搜索。

只有当导出名称指针表中包含指向某个导出名称的指针时,这个导出名称才算被定义。换句话说,导出名称指针表的值有可能为0,这是为了能与前面版本兼容。

导出序数表

导出序数表是由导出地址表的索引组成的一个数组,每个序数长16位。必须从序数值中减去Ordinal Base域的值得到的才是导出地址表真正的索引。注意,导出地址表真正的索引真正的索引是从0开始的。由此可见,微软弄出Ordinal Base是找麻烦的。导出序数表的值和导出地址表的索引的值都是无符号数。

导出名称指针表和导出名称序数表是两个并列的数组,将它们分开是为了使它们可以分别按照各自的边界(前者是4个字节,后者是2个字节)对齐。在进行操作时,由导出名称指针这一列给出导出函数的名称,而由导出序数这一列给出这个导出函数对应的序数。导出名称指针表的成员和导出序数表的成员通过同一个索引相关联。

导出名称表(Export Name Table,ENT)

导出名称表的结构就是长度可变的一系列以NULL结尾的ASCII码字符串。 导出名称表包含的是导出名称指针表实际指向的字符串。这个表的RVA是由导出名称指针表的第1个值来确定的。这个表中的字符串都是函数名称,其它文件可以通过它们调用函。

  这里需要特别注意的是,有时候你在遍历导出地址表的时候,有可能得到的并不是一个地址(或者说并不是目标函数的地址),而是一个字符串。那么这就是遇到了函数转发的情况。判断方法就是上面所说的判断这个指针是不是在导出表的范围内。

  学习PE文件可能比较难想象其中的数据结构组织,因为比较复杂,所以我建议可以上网找关于PE文件各个结构的示意图看看。

上一篇:Java动态代理 深度详解


下一篇:Oracle--存储过程学习进阶