写在最前面:本篇文章会介绍PE结构的相关知识,并通过PE根据分析实际文件进行进一步解析。

PE文件简介

PE(Portable Executable),即可移植的执行体。在 Windows 操作系统平台(包括 Win 9x、Win NT、Win CE 等)下,所有的可执行文件(包括 EXE 文件、DLL 文件、SYS 文件、OCX文件、COM 文件等)均使用 PE 文件结构。这些使用 PE 文件结构的可执行文件也可以称为PE 文件

  • windows 平台PE(Portable Executable) 文件结构。
  • Linux 平台ELF(Executable and Linking Format) 文件结构

:二者在结构上大同小异,所以这里以PE文件结构为例进行介绍。

PE文件结构

结构全貌

学习PE结构,先从全局入手。如上图所示,可以把可执行文件大致分为两个部分

  • 其一的DOS 头PE 头节表属于构成可执行文件的数据管理结构或数据组织结构部分
  • 其二的节表数据才是可执行文件真正的数据部分,包含着程序执行时真正的代码、数据、资源等内容

DOS 头分为两部分,分别是“MZ 头部”“DOS 存根”。前者设置开始的两个字节为“MZ”,用于标识文件信息;后者则是用于输出提示字符串

PE 头部保存着 Windows 系统加载可执行文件的重要信息。

PE 头部由 IMAGE_NT_ HEADERS 定义,从该结构体的定义名称可以看出IMAGE_NT_HEADERS 是由多个结构体组合而成的,该结构体中包含IMAGE_NT_SIGNATRUE(它不是结构体,而是一个宏定义)、IMAGE_FILE_HEADERIMAGE_OPTIONAL_HEADER 三部分。
PE 头部在 PE 文件中的位置不是固定不变的,PE 头部的位置由 DOS 头部的某个字段给出。

PE 头部之后就是一个结构体数组构成的节表。节表中描述了各个节在整个文件中的位置加载入内存后的位置,同时定义了节的属性(只读、可读写、可执行等)。如果 PE 文件中有 N 个节,那么节表就是由 N 个IMAGE_SECTION_HEADER 组成的数组。

可执行文件中的真正程序代码部分就保存在 PE 结构的节数据中,当然,数据、资源等内容也保存在节中。节表只是描述了节数据的起始地址、大小及属性等信息。

:对于PE结构的学习一定不要只抓细节,因为每一个部分都有很多结构体,每一个结构体中又有很多属性,这些属性不仅有其本身的含义还与其他部分关联。所以,在学习时要先理清整体体系,而后由各个部分入手,每一个部分也只需要先记住最重要的几个点即可。同时,要将PE文件的学习与计算机基础关联在一起。

闲话少说,我们下面开始正文的介绍。(以32位PE结构为例)。其中会涉及到很多代码部分,其来源为winnt.h头文件。

DOS 头部详解(IMAGE_DOS_HEADER)

前面我们提到过,DOS头本质是一个结构体。下面我们来具体看一下这个结构体的内容:


_IMAGE_DOS_HEADER即为DOS头结构体。如上图所示,其中有很多属性,对此我们现在只需要掌握两个:

  • e_magic:是一个 DOS 可执行文件的标识符,占用 2 个字节。(也就是在文件开头定义为MZ字符)
  • e_lfanew:此字段保存PE头的初始位置,占4个字节即32位地址。

下面我们通过一个例子来更加清晰的理解这两个字段的含义。

如上图,开始两个字节e_magic字段,也就是MZ字符;框一是e_lifnew字段,用于定位到PE头。

:位于e_lfanew字段PE头之间的数据就是DOS存根,用于输出提示信息,本身没什么用,不需要记。

PE 头部详解——IMAGE_NT_HEADERS

前面就已经提到过,IMAGE_NT_HEADERS本身不是一个结构体,而是一个宏定义,由:PE标识符文件头可选头三者组成。其具体定义如下:

如上图,PE头部有三个属性,与上面的定义一一对应。下面我们就依次介绍这些结构体。

PE标识符–Signature

此部分就是PE标识符,占4个字节,内容可见上图的winhex界面。在winnt.h里也有一个宏定义:

1
#define IMAGE_NT_SIGNATURE                  0x50450000  // PE00

该值非常重要。在判断一个文件是否是 PE 文件时,首先要判断文件的起始位置是否为“MZ”,如果是“MZ”,那么通过 DOS 头部的相应偏移取得“PE 头部的位置”,接着判断文件该位置的前四个字节是否为“PE\0\0”。如果是的话,则说明该文件是一个有效的 PE 文件

PE文件头–IMAGE_FILE_HEADER

IMAGE_FILE_HEADER 结构体的大小为 20 个字节,主要描述文件的相关信息,其具体定义如下:


下面就行具体属性的介绍:

  1. Machine字段:该字段表示可执行文件的目标CPU类型。其具体宏定义如下:

  2. NumberOfSection:该字段是 WORD 类型,占用 2 个字节。该字段表示 PE 文件的节表的个数

  3. TimeDataStamp:该字段表明文件是何时被创建的,占4个字节。这个值是自 1970 年 1 月 1 日以来用格林尼威治时间计算的秒数。

  4. SizeOfOptionalHeader:该字段为 WORD 类型,占用 2 个字节。定义IMAGE_OPTIONAL_HEADER 结构体的大小。(在计算位置时,可选头大小需要通过这个字段获取而不是使用sizeof)

  5. Characteristics:该字段为 WORD 类型,占用 2 个字节。该字段指定文件的类型,其具体宏定义如下:


    下面我们通过一个具体的示例来看一下这些字段的具体含义:


如上图所示,深色部分为文件头对应的20个字节信息。按照我们上面的介绍来一一对应:

  • Machine=014c:表示指定的CPU为Intel 32.
  • NumberOfSection=0030:表示节表个数为0030h
  • TimeDataStamp=618fbe83:表示文件创建时间
  • SizeOfOptionalHeader=00E0:表示可选头结构体大小为00E0h
  • Characteristics=010F:这个有一点复杂,需要分解,最终表示此文件目标平台为32位不存在重定位信息文件可执行Line nunbers stripped from fileLocal symbols stripped from file.

可选头详解——IMAGE_OPTIONAL_HEADER

不要以貌取人,虽然这个部分叫做可选头,但是却是一个必须存在的头部,甚至是PE头三个组成部分中最重要的部分,主要是用来管理 PE 文件被操作系统装载时所需要的信息
可选头的大小在文件头中已经给出,其大小为0x00E0 字节(224个字节)。

还是先给出可选头结构体具体定义 (代码较多,不使用截图了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//
// Optional header format.
//

typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.==基础字段
//

WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;

//
// NT additional fields.===NT 附加字段
//

DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD ajorSubsystemVersion;
WORD MMajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
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附加字段,下面我们会一一介绍。

基础字段–Standard fields

  • Magic:该成员指定了文件的标识
  • MajorLinkerVersion主连接版本号。
  • MinorLinkerVersion次连接版本号。
  • SizeOfCode代码节的大小,如果有多个代码节的话,该值是所有代码节大小的总和(通常只有一个代码节),该处是指所有包含可执行属性的节点大小。
  • SizeOfInitializedData已初始化数据块的大小。
  • SizeOfUninitializedData未初始化数据块的大小。
  • AddressOfEntryPointer:程序执行的入口地址。该地址是一个相对虚拟地址,简称 EP(EntryPoint),这个值指向了程序第一条要执行的代码。程序如果被加壳后会修改该字段的值,成为壳的入口地址,这样壳代码就有机会先进行执行了。在脱壳的过程中找到了加壳前的入口地址,就说明找到了原始入口点,原始入口点称为 OEP。该字段的地址指向的不是 main函数的地址,也不是 WinMain 函数的地址,而是运行库的启动代码的地址。
  • BaseOfCode:代码节的起始相对虚拟地址,也就是RVA。
  • BaseOfData:数据节的起始相对虚拟地址,也就是RVA。

NT附加字段–NT additional fields

  • ImageBase:文件被装入内存后的首选建议装载地址。也就是内存基址,是一个很重要的地址量,可以用于后面的地址转换。

    注解:打开 OD 后,OD 停留在第一行的反汇编代码处就是 AddressOfEntryPoint+ImageBase 的值,OD 在打开被调试程序后,数据窗口默认显示的位置是BaseOfData+ImageBase 的值。
    对于 EXE 文件而言,所有的相对虚拟地址加ImageBase 后,就得到了虚拟地址;对于 DLL而言,在其装入内存后,就需要通过重定位表修正相关的地址信息。

  • SectionAlignment:节表数据被装入内存后的对齐值,也就是节表数据被映射到内存中需要对齐的单位。在 Win32 下,通常情况下,内存对齐的该值为 0x1000 字节,也就是 4KB 大小。Windows 操作系统的内存分页一般为 4KB,这样做的原因是在切换时速度会快。

  • FileAlignment:节表数据在文件中的对齐值。通常情况下,该值为 0x1000 字节或 0x200字节。在文件对齐为 0x1000 字节时,由于与内存对齐值相同,可以加快操作系统对可执行文件装载入内存的速度。而文件对齐值为 0x200 字节时,可以占用相对较少的磁盘空间。0x200字节是 512 字节,通常磁盘的一个扇区即为 512 字节

    注解地址对齐也是一个很重要的知识点。程序无论是在内存中还是磁盘上,都无法恰好满足 SectionAlignment 和 FileAlignment 值的倍数,在不足的情况下编译器会自动地进行补 0,这样就导致节数据与节数据之间存在着为了对齐而存在的大量的 0 空隙。这些空隙对于病毒之类的程序而言就有了可利用的价值,病毒通过搜索空隙而将病毒代码进行植入,从而在不改变文件大小的情况下感染文件。

  • MajorOperatingSystemVersion:要求最低操作系统的主版本号

  • MinorOperatingSystemVersion:要求最低操作系统的次版本号

  • MajorImageVersion:可执行文件的主版本号。

  • MinorImageVersion:可执行文件的次版本号。

  • Win32VersionValue:该成员变量是被保留的。

  • SizeOfImage:可执行文件装入内存后的总大小。该大小按内存对齐方式对齐。

  • SizeOfHeaders:整个 PE 头部的大小。这个 PE 头部指 DOS 头、PE 头、节表的总和大小。该大小按照文件对齐方式进行对齐。(也可以说是PE文件中数据结构的大小

  • CheckSum校验和值。对于 EXE 文件通常为 0;对于 SYS 文件(驱动文件、内核文件),则必须有一个校验和。用于校验文件是否被修改。

  • SubSystem:可执行文件的子系统类型,具体如下

  • DllCharacteristics:指定 DLL 文件的属性

  • SizeOfStackReserve:为线程保留的栈大小,以字节为单位

  • SizeOfStackCommit:为线程已提交的栈大小,以字节为单位

  • SizeOfHeapReserve:为线程保留的堆大小。

  • SizeOfHeapCommit:为线程提交的堆大小

  • LoadFlags保留字段,必须为 0。MSDN 上的原话为“This member is obsolete”,说是一个废弃的字段。但是该值在某些情况下还是会被用到的,比如针对原始的低版本的 OD 来说,修改该值会起到反调试的作用。

  • NumberOfRvaAndsize:数据目录项的个数

  • DataDirectory数据目录表,由 NumberOfRvaAndSizeIMAGE_DATA_DIRECTORY结构体组成的数组。

注解:该数组包含输入表输出表资源重定位数据目录项。每一个数组元素都是一个结构体,其包含:VirtualAddressSize两个字段,前者为目录项的RVA,后者为目录项大小。
对于数据目录中的具体数据,并不包含在可选头中,只是可选头提供了相应数据的相对虚拟地址,具体数据目录中的内容在后面的内容中将进行介绍。

附表:数据目录在数组中的索引如下图:

节表详解——IMAGE_SECTION_HEADER

节表中的每个IMAGE_SECTION_HEADER 中都存放着可执行文件被映射到内存中所在位置的信息,节的
个数由 IMAGE_FILE_HEADER 中的 NumberOfSections 给出。下面我们具体进行介绍。

首先给出IMAGE_SECTION_HEADER结构体的定义:


此结构体大小为40字节,其成员变量的介绍如下:

  • Name:该成员变量保存着节表项的名称,节表项的名称用 ASCII 编码来保存。表项的名称长度是 8 个 ASCII 字符。

注解:节表项的名称可以随意地改变,甚至可以删除掉,因此不能以节表项的名称作为依据判断节中保存的内容,也不能通过节表项的名称判断加壳的种类。

  • VirtualSize:该值为节数据实际的大小,该值不一定是对齐后的值,该字段的值在某些情况下可以为 0。这里的大小不是对齐之后的数据。
  • VirtualAddress:该值为该节区数据装入内存后的相对虚拟地址(RVA),这个地址是按内存对齐的。该地址加上 IMAGE_OPTIONAL_HEADER 结构体中的 ImageBase 才是内存中的虚拟地址(VA)
  • SizeOfRawData:该值为该节区数据在磁盘上的大小,该值是按照文件对齐进行对齐后的值,但是也有例外。
  • PointerToRawData:该值为该节区在磁盘文件上的偏移地址
  • Characteristics:该值为该节区的属性

三种地址及转换

三种地址详解

  • VA虚拟内存地址。是在虚拟内存空间的实际地址,也就是PE 文件被 Windows 加载到内存后的地址。
  • RVA相对虚拟内存地址。PE 文件虚拟地址相对于映射基地址(对于 EXE 文件来说,映射基地址是 IMAGE_OPTIONAL_HEADER 的 ImageBase 字段的值)的偏移地址
  • FOA文件偏移地址,相对于 PE 文件在磁盘上文件开头的偏移地址。

地址转换

准备工作

FileAlignmentSectionAlignment值不相同时,磁盘文件与内存文件映像的同一节表数据在磁盘和内存中的偏移也不相同。当 FileAlignmentSection Alignment值相同时,如果存在类似.data?节的话,磁盘文件与内存映像的同一节表数据在磁盘和内存中的偏移也不相同
这样两个偏移就发生了一个需要转换的问题。当知道某数据的 RVA,希望在文件中读取同样的数据的时候,就必须将 RVA 转换为 FOA,反之,也同样的情况。

我们可以通过PEditor工具查看某PE文件的地址信息

相同对齐值的地址转换

对齐值相同的情况下,地址的转换就很简单了,大概分为以下两步。

  1. 将 VA(虚拟地址)转换为 RVA(相对虚拟地址),即 RVA = VA – ImageBase。
  2. 将RVA(相对虚拟地址)转换为FOA(文件偏移地址),即RVA=FOA。

注意
① 上面的例子使用的是 EXE 文件进行演示,对于 DLL 的话,DLL 的装载地址并不是 IMAGE_OPTIONAL_HEADER 结构体中的 ImageBase 字段。因此不能按照上面的方式转换,需要得到具体的 DLL 文件装载到内存中的起始位置。
② SectionAlignment 和 FileAlignment 相同时,也存在 RVA 和 FOA 不同的情况,比如存在data?时,文件本身没有空间但是在内存中有预留空间,这就导致偏移不同。

不同对齐值的地址转换

如果对齐值不同的话,地址的转换就要复杂一些。在介绍具体的转换公式之前,我们先来说一下对齐值对地址的影响到底是怎么样的。

IMAGE_OPTINAL_HEADERFileAlignmentSectionAlignment 两个字段的值确定了文件对齐值和内存对齐值。而对齐值则会导致节的起始位置不同。前面我们介绍了PE文件的结构,可以将其分为数据结构节表数据两部分,其中数据结构又可以分为DOS头、PE头、节表,而这些数据加起来一般也不会超过512个字节,也就是在一个对齐值之内,之后就要补0至对齐值或者对齐值的倍数。而后不同节表数据都是占据对齐值倍数的空间,换句话说,每一个节表数据都是从一个新的对齐值空间开始的

实例如下:不同节的起始虚拟偏移都是对齐值的整数倍。

通过上面的介绍,下面我们给出FOA与VA的具体转换公式

1
某数据的 FOA=该数据所在节的起始 FOA+(某数据的 RVA–该数据所在节的起始 RVA)

这也很好理解,因为每一个节(区段)都是从一个新的对齐值空间开始的,就是这个节只有1个字节也会补0至对齐值倍数。所以我们首先需要知道要转换的RVA所在节的起始FOA,之后计算内存偏移(转换处相对于节头)再加和即可得到实际FOA。

同样的,FOA转换为RVA的公式如下:

1
某数据的RVA=该数据所在节的起始RVA+(某数据的FOA-该数据所在节的起始FOA)

通过工具直接转换

很多PE编辑工具都带有地址转换的功能,以PEditor为例

数据目录相关结构详解

前面我们介绍了PE文件中一些基本的数据结构,但是还有一些与PE结构相关的结构体不在PE的头部,而是在各个节数据里。它们的位置由 IMAGE_OPTIONAL_HEADER 结构体中的 DataDirectory 数组(数据目录)给出相应的RVA和Size

数据目录有很多,一般是16个。下面我们会选择其中较为重要的几个进行介绍。

导入表

导入表是 PE 数据组织中的一个很重要的组成部分,它是为实现代码重用而设置的。通过分析导入表数据,可以获得诸如 PE 文件的指令中调用了多少外来的函数,以及这些外来函数都存在于哪些动态链接库里等信息。

Windows 加载器在运行 PE 时会将导入表中声明的动态链接库DLL一并加裁到进程的地址空间,并修正指令代码中调用的函数地址

导入表的查看


通过PEditor打开hello.exe,而后选取目录按钮就看看到此文件对应的数据目录信息。通过上图可见,hello.exe 文件在执行时需要装载 2个 DLL 文件,分别是 user32.dllkernel32.dll 两个动态链接。该 EXE 文件在每个 DLL 文件又使用了若干个函数。对于 PE 文件而言,调用的其他模块的函数称为“导入函数”。

如上图,MessageBoxA就是一个导入函数,被hello.exe调用。但是若是相对于user32.dll来说,此函数就是一个导出函数

导入表的结构

上图是导入表在磁盘和内存中的基本框架,有3个关键结构体

  • IMAGE_IMPORT_DESCRIPTOR
  • IMAGE_THUNK_DATA
  • IMAGE_IMPORT_BY_NAME

IMAGE_IMPORT_DESCRIPTOR的结构体定义在 Winnt.h 头文件中,它的定义如下

1
2
3
4
5
6
7
8
9
10
typedef struct _IMAGE_IMPORT_DESCRIPTOR { 
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;

该结构体的名字叫IMAGE_IMPORT_DESCRIPTOR,有5个字段组成,其具体含义如下:(20个字节)

  • OriginalFirstThunk:该字段保存了指向导入函数名称(序号)的 RVA 表,这个表其实是
    一个 IMAGE_THUNK_DATA 结构体。
  • Name:指向导入模块名称的 RVA
  • FirstThunk:该字段保存了指向导入地址表的 RVA。

    上字段解释OriginalFirstThunk字段保存了指向导入函数名称(序号)的 RVA 表,这个表其实是
    一个 IMAGE_THUNK_DATA 结构体FirstThunk字段在 PE 文件没有被装载前的内容OriginalFirstThunk 指向相同的内容,也就是在 PE 文件没有被装载前它也指向 IMAGE_THUNK_ DATA 结构体。当被Windows 操作系统装入内存后,它的值则发生了变化,被装载入内存后,这里保存了导入函数实际地址


IMAGE_THUNK_DATA 结构体的定义如下:

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_THUNK_DATA32 { 
union {
DWORD ForwarderString; // PBYTE
DWORD Function; // PDWORD
DWORD Ordinal;
DWORD AddressOfData; // PIMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

IMAGE_THUNK_DATA 结构体中关键字段如下:(4个字节,一个地址)

  • Oridinal:导入函数的序号,当 IMAGE_THUNK_DATA 的最高位为 1 时,该值有效。
  • AddressOfData:指向 IMAGE_IMPORT_BY_NAME 结构体的 RVA,当 IMAGE_THUNK_ DATA 的最高位不为 1 时,该值有效。

上结构体解析
OridinalAddressOfData 本质上是一个值,但是在使用时取决于 IMAGE_THUNK_DATA 的最高位。当 IMAGE_THUNK_DATA 的最高位为 1 时,使用的是序号进行导入的函数,导入函数的序号为Oridinal 的低 31 位;当最高位不为 1 时,说明导入函数是通过名称进行导入的,而 AddressOfData 保存了指向 IMAGE_IMPORT_BY_NAME的 RVA。

通过 IMAGE_THUNK_DATA 结构体,可以了解导入函数是通过序号还是名称导入的。
如果是通过序号进行导入,那么导入序号可以在 IMAGE_THUNK_DATA 中获得;如果是通过名称导入,那么就需要借助 IMAGE_IMPORT_BY_NAME 来得到导入函数的名称


IMAGE_IMPORT_BY_NAME 结构体的定义如下

1
2
3
4
typedef struct _IMAGE_IMPORT_BY_NAME { 
WORD Hint;
BYTE Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
  • Hint:该字段表示该函数在导出函数表中导出函数名称对应的序号,该值不是必需的;
  • Name:该字段表示导入函数的函数名称

导入表整体概述

  1. 在PE扩展头最后有一个数据目录数组,其中每一个元素都是结构体,大小为8个字节,按顺序存储各个数据目录描述表(结构体)的大小和RVA。其中第二个元素就是导入表的地址和大小。

    如图所示,导入表的RVA为00002018H,大小为3CH。一个描述表结构体大小为20个字节,所以3C/20=3,也就是说导入了3个模块。(实际是2个模块,还有一个是全0,用于表示结束)

  2. 接下来我们进入一个实际文件来看一下这个描述表

    如图所示,我使用竖线将其分为了三个部分,分别对应导入表里的三个导入模块。下面我们选择第一个模块(user32.dll)进行跟踪。

    注意:这里的一个模块为20个字节,分为5个字段,我们需要关注的是第一个字段和最后一个字段,前者为INT(导入函数名称表),后者为IAT(导入函数地址表)。这里地址分别为00002054h与00002008h,各自指向一个结构体(在静态和动态中指向的地址存储的数据不同)

  3. 静态地址跟踪:

    如图所示,在静态文件中,第一个字段和第五个字段指向的地址不同,因为是不同的结构体。但是结构体中存储的内容是一致的。

导出表

导出表概述

一般情况下 ,PE 中的导出表存在于动态链接库文件里 。
导出表的主要作用是将 PE 中存在的函数引出到外部,以便其他人可以使用这些函数,实现代码的重用 。

通过函数名
通过索引

IAT会覆盖为函数VA(不是RVA),覆盖的依据为导出表

DLL文件加载到内存空间,也是一个PE文件,其结构与前相同。(也有Base,装载基址)

所以一个很关键的点在于:理清寻址的过程、

导出表的结构

PE头偏移78H的位置就是导出表的位置

上图为导出表的数据结构。其中字段的含义也已经给出,这里我们需要重点了解的是以下几个字段:

  • NumberOfFunctions:文件中包含的导出函数的总数
  • NumberOfNames:被定义函数名称的导出函数的总数。

上二字段解析:只有NumberOfNames数量的函数既可以用函数名方式导出,也可以用序号方式导出,剩下 的NumberOfFunctions 减去NumberOfNames 数量的函数只能用序号方式导出。该字段的值只会小于或者等于 NumberOfFunctions 字段的值,如果这个值是0,表示所有的函数都是以序号方式导出的。

  • AddressOfFunctions:一个RVA 值,指向包含全部导出函数入口地址双字数组。数组中的每一项是一个RVA 值,数组的项数等于NumberOfFunctions 字段的值。(这个字段很重要,因为函数的导出最终都要归到这个表里,根据索引查找入口地址)
  • Base导出函数序号的起始值,将AddressOfFunctions 字段指向的入口地址表的索引号加上这个起始值就是对应函数的导出 序号。假如Base 字段的值为x,那么入口地址表指定的第1个导出函数的序号就是x;第2个导出函数的序号就是x+1。总之,一个导出函数的导出序号等于Base 字段的值加上其在入口地址表中的位置索引值

上二字段解析:最终目的是获取到入口地址,而入口地址的获取是根据索引来的,所以我们要先得到一个函数的索引值。对于通过序号导出的函数,其索引号等于序号-Base.

  • AddressOfNames 和 AddressOfNameOrdinals:均为RVA 值。前者指向函数名字符串地址表。这个地址表是一个双字数组,数组中的每一项指向一个函数名称字符串的RVA。数组的项数等于NumberOfNames 字段的值,所有有名称的导出函数的名称字符串都定义在这个表中;后者指向另一个word 类型的数组(注意不是双字数组)。数组项目与文件名地址表中的项目一一对应,项目值代表函数入口地址表的索引,这样函数名称与函数入口地址关联起来

看一下最终的结构图:

导出表实训

实训目的:找到

实训步骤:首先要先理清思路,从序号和名称两个角度:

从序号查找函数入口地址:简单说就是,序号-base 对应找到AddressOfFunctions 的第几项

1-定位到PE文件头
2- 从PE 文件头中的 IMAGE_OPTIONAL_HEADER32 结构中取出数据目录表,并从第一个数据目录中得到导出表的RVA
3- 从导出表的 Base 字段得到起始序号
4- 将需要查找的导出序号减去起始序号,得到函数在入口地址表中的索引
5- 检测索引值是否大于导出表的 NumberOfFunctions 字段的值,如果大于后者的话,说明输入的序号是无效的
6- 用这个索引值在 AddressOfFunctions 字段指向的导出函数入口地址表中取出相应的项目,这就是函数入口地址的RVA 值,当函数被装入内存的时候,这个RVA 值加上模块实际装入的基地址,就得到了函数真正的入口地址 。

从函数名称查找函数入口地址:

1- 最初的步骤是一样的,那就是首先得到导出表的地址
2- 从导出表的 NumberOfNames 字段得到已命名函数的总数,并以这个数字作为循环的次数来构造一个循环 从 AddressOfNames 字段指向得到的函数名称地址表的第一项开始,在循环中将每一项定义的函数名与要查找的函数名相比较,如果没有任何一个函数名是符合的,表示文件中没有指定名称的函数 ;如果某一项定义的函数名与要查找的函数名符合,那么记下这个函数名在字符串地址表中的索引值,然后在 AddressOfNamesOrdinals 指向的数组中以同样的索引值取出数组项的值,我们这里假设这个值是x 最后,以 x 值作为索引值,在 AddressOfFunctions 字段指向的函数入口地址表中获取的 RVA 就是函数的入口地址。
简单说是:查找AddressOfNames ,对应到a项,取AddressOfNamesOrdinals 的第a项的值得到b,取AddressOfFunctions 的第b项

: 一定要记住导出表中的关键字段的值,比如:各个函数数目、base、各个地址表的数组大小等。