首页
论坛
专栏
课程

[原创]打造自己的PE解析器

2019-7-24 10:23 5470

[原创]打造自己的PE解析器

2019-7-24 10:23
5470

目录


前言

  • 文章概述:写本篇文章的宗旨就是同学可以完全通过阅读本篇文章打造一个自己的PE文件解释器。让同学们能够更加深入的理解并掌握PE文件的基本结构,少走一些弯路。
  • 阅读方法:本篇文章的结构是一章理论加一章代码实现的结构,这样可以完美的达到理论与实践相结合的学习效果。同时也希望同学们在看完理论部分后可以动手实践完成本章的课后练习,在文章的附件中包含每一个练习的参考代码。文章的图片也会附加在附件当中。
  • 具备基础:
    (1)掌握C语言

  • 学习环境:VS2017 或 VC++6.0,Windows操作系统,LordPE(PE解释器)

贯穿全文的概念

  地址空间:这个地址空间指的是PE文件被加载到内存的空间,是一个虚拟的地址空间,之所以不是物理空间是因为数据在内存中的位置经常在变,这样既可以节约内存开支又可以避开错误的内存位置。这个地址空间的大小为4G,但其中供程序装载的空间只有2G而且还是低2G空间,高2G空间则被用于装载内核DLL文件,所以也被称作内核空间。

 

  文件映射:PE文件在磁盘上的状态和在内存中的状态是不一样的,我们把PE文件在磁盘上的状态称作FileBuffer,在内存中的状态称为ImageBuffer。当PE文件通过装载器装入内存是会经过“拉伸”的过程,所以它在FileBuffer状态下和ImageBuffer状态下的大小是不一样的。这个拉伸的具体过程会在讲完PE头结构后进行介绍。大致的图解如下:
PE拉伸

 

  VA:英文全称是Virual Address,简称VA,中文意思是虚拟地址。指的是文件被载入虚拟空间后的地址。

 

  ImageBase:中文意思是基址,指的是程序在虚拟空间中被装载的位置。

 

  RVA:英文全称是Relative Virual Address,简称RVA,中文意思是相对虚拟地址。可以理解为文件被装载到虚拟空间(拉伸)后先对于基址的偏移地址。计算方式:RVA = VA(虚拟地址) - ImageBase(基址)。它的对齐方式一般是以1000h为单位在虚拟空间中对齐的(传说中的4K对齐),具体对齐需要参照IMAGE_OPTIONAL_HEADER32中的SectionAlignment成员。

 

  FOA:英文全称是File Offset Address,简称FOA,中文意思是文件偏移地址。可以理解为文件在磁盘上存放时相对于文件开头的偏移地址。它的对齐方式一般是以200h为单位在硬盘中对齐的(512对齐),具体对齐需要参照IMAGE_OPTIONAL_HEADER32中的FileAlignment成员。

 

图解RVA和FOA


第一章:打造自己的PE解析器——PE文件头结构

  • 本章目的:通过简单的讲解PE文件头结构及其基本概念,让刚开始学习PE的同学基本了解PE文件头的结构和相关理论知识,并通过学到的知识获取PE文件头的结构信息。
  • 重点掌握:
    (1)IMAGE_DOS_HEADER
    (2)IMAGE_NT_HEADERS32
    (3)IMAGE_FILE_HEADER
    (4)IMAGE_OPTIONAL_HEADER32
    (5)IMAGE_SECTION_HEADER
 

废话太多了。。。开始正文!!!


第一节:PE文件结构

PE文件结构图
  PE文件是由许许多多的结构体组成的,程序在运行时就会通过这些结构快速定位到PE文件的各种资源,其结构大致如图所示,从上到下依次是Dos头、Nt头、节表、节区和调试信息(可选)。其中Dos头、Nt头和节表在本文中统称为PE文件头(因为SizeOfHeaders就是这三个头的总大小)、节区则称为节,所以也可以说PE文件是由PE文件头和节组成。
  PE文件头保存着整个PE文件的索引信息,可以帮助PE装载器定位资源,而节则保存着整个PE文件的所有资源。正因为如此,所以存在着这样的说法:头是节的描述,节是头的具体化。


第二节:IMAGE_DOS_HEADER

IMAGE_DOS_HEADER的结构体定义如下:

typedef struct _IMAE_DOS_HEADER {        
    WORD e_magic;        **重要成员 相对该结构的偏移0x00**
    WORD e_cblp; 
    WORD e_cp;
    WORD e_crlc;
    WORD e_cparhdr;
    WORD e_minalloc;
    WORD e_maxalloc;
    WORD e_ss;
    WORD e_sp;
    WORD e_csum;
    WORD e_ip;
    WORD e_cs;
    WORD e_lfarlc;
    WORD e_ovno;
    WORD e_res[4];
    WORD e_oemid;
    WORD e_oeminfo;
    WORD e_res2[10];
    LONG e_lfanew;        **重要成员 相对该结构的偏移0x3C**
} IMAGE_DOS-HEADER, *PIMAGE_DOS_HEADER;

  当我们用16进制编辑器打开一个PE文件时,就会发现所有PE文件的前两个字节都是MZ,用十六进制表示是4D 5A,这两个字母就是Mark Zbikowski的姓名缩写,他是最初的MS-DOS设计者之一。如果把PE文件的这两个字节修改成其他数据,运行该PE文件就会无法正常运行(跳出黑窗口打印Program too big to fit in memory然后闪退,有兴趣的朋友可以尝试下)。这里可以证明当PE文件运行时,首先就会检测这两个字节,如果不是MZ则会退出运行。

上图
修改MZ后运行

 

  在该结构体中另一个重要成员就是最后一个成员e_lfanew。该成员的大小是LONG类型4个字节。之所以说它重要是因为它保存着IMAGE_NT_HEADERS32这个结构体在PE文件中的偏移地址,PE文件运行时只有通过该成员才能定位到PE签名(也就是IMAGE_NT_HEADERS32结构体的起始位置)。

咱们看图说话
Dos头图解


第三节:IMAGE_DOS_STUB (了解)

  IMAGE_DOS_HEADER结构体后面紧跟着就是IMAGE_DOS_STUB程序,它是运行在MS-DOS下的可执行程序,当可执行文件运行于MS-DOS下时,这个程序会打印This program cannot be run in DOS mode这条消息。用户可以自己更改该程序,MS-DOS程序当前是可有可无的,如果你想使文件大小尽可能的小可以省掉MS-DOS程序,同时把前面的参数都清0。


第四节:IMAGE_NT_HEADERS32

IMAGE_NT_HEADERS32的结构体定义如下:

typedef struct _IMAGE_NT_HEADERS {
  DWORD                   Signature;         **重要成员 PE签名 相对该结构的偏移0x00**
  IMAGE_FILE_HEADER       FileHeader;        **重要成员 结构体 相对该结构的偏移0x04**
  IMAGE_OPTIONAL_HEADER32 OptionalHeader;    **重要成员 结构体 相对该结构的偏移0x18**
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

  这个结构体是整个PE文件的核心,它是由一个Signature、一个IMAGE_FILE_HEADER结构体、一个IMAGE_OPTIONAL_HEADER32结构体组成的。所以从整体看来这个结构比较简单,但实际上其内部结构较为复杂,我将会在下方对两个结构体进行详细的介绍。
  Signature
  也称作PE签名,这个成员和DOS头的MZ标记一样都是一个PE文件的标准特征,只不过这个成员是DWORD类型大小为4字节,如果把这个PE签名修改后,程序也是不会正常运行的(跳出黑窗口打印This program cannot be run in DOS mode然后闪退,可能是因为修改PE签名后无法识别后续内容的关系吧)。

修改PE签名后的运行结果:
修改PE签名

如果把MZ标志和PE签名同时改变的话,其效果和只修改MZ是一样的,可见程序在载入时是先检测MZ标志然后才检测PE签名的:
全部修改


第五节:IMAGE_FILE_HEADER

IMAGE_FILE_HEADER的结构体定义如下:

typedef struct _IMAGE_FILE_HEADER {
  WORD  Machine;                    **        机器号     相对该结构的偏移0x00**
  WORD  NumberOfSections;           **重要成员 节区数量   相对该结构的偏移0x02**
  DWORD TimeDateStamp;              **        时间戳     相对该结构的偏移0x04**
  DWORD PointerToSymbolTable;       **        符号表偏移  相对该结构的偏移0x08**
  DWORD NumberOfSymbols;            **        符号表数量  相对该结构的偏移0x0C**
  WORD  SizeOfOptionalHeader;       **重要成员 可选头大小  相对该结构的偏移0x10**
  WORD  Characteristics;            **重要成员 PE文件属性  相对该结构的偏移0x12**
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

  Machine
  所表示的是计算机的体系结构类型,也就是说这个成员可以指定该PE文件能够在32位还是在64位CPU上执行。如果强行更改该数值程序就会报错。该成员可以是以下的数值:

数值 0x014C 0x8664 0x0200
描述 x86 x64 Intel Itanium
 

  NumberOfSections
  它的含义就是当前PE文件的节区数量,虽然它是大小是两个字节,但是在windows加载程序时会将节区的最大数量限制为96个。

从010 Editor模版上验证节区数量:
NumberOfSecions

 

  TimeDateStamp
  它的含义是时间戳,用于表示该PE文件创建的时间,时间是从国际协调时间也就是1970年1月1日00:00起开始计数的,计数单位是秒。例如0x5CFBB225的计算方法如下:

通过计算,从1970年到2019年一共有12个闰年(通过闰年计算器获得),到6月有151天。

秒:0x5CFBB225 % 60 = 33
分:0x5CFBB225 / 60 % 60 = 3
时:0x5CFBB225 / 3600 % 24 = 13
日:0x5CFBB225 / 3600 / 24 - (365 * 49 + 12) - 151 + 1 = 8 
月:(0x5CFBB225 / 3600 / 24 - (365 * 49 + 12)) / 30 + 1 = 6 
年:0x5CFBB225 / 3600 / 24 / 365 + 1970 = 2019
结果为:2019年6月8日 13:03:33

上图验证计算结果
TimeDateStamp

 

  SizeOfOptionalHeader
  它存储该PE文件的可选PE头的大小,在32位PE文件中可选头大小为0xE0,64位可选头大小为0xF0。正因为如此,所以就必须通过该成员来确定可选PE头的大小。
  Characteristics
  它描述了PE文件的一些属性信息,比如是否可执行,是否是一个动态连接库等。该值可以是一个也可以是多个值的和,具体定义如下:

宏定义 数值 描述
IMAGE_FILE_RELOCS_STRIPPED 0x0001 从文件中删除了重定位信息
IMAGE_FILE_EXECUTABLE_IMAGE 0x0002 该文件是可执行的
IMAGE_FILE_LINE_NUMS_STRIPPED 0x0004 COFF行号从文件中删除
IMAGE_FILE_LOCAL_SYMS_STRIPPED 0x0008 COFF符号表条目从文件中删除
IMAGE_FILE_AGGRESIVE_WS_TRIM 0x0010 废弃
IMAGE_FILE_LARGE_ADDRESS_AWARE 0x0020 该应用程序可以处理大于2GB的地址
IMAGE_FILE_BYTES_REVERSED_LO 0x0080 废弃
IMAGE_FILE_32BIT_MACHINE 0x0100 32位机器
IMAGE_FILE_DEBUG_STRIPPED 0x0200 调试信息已删除并单独存储在另一个文件中
IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0x0400 如果在移动介质中,拷到交换文件中运行
IMAGE_FILE_NET_RUN_FROM_SWAP 0x0800 如果在网络中,拷到交换文件中运行
IMAGE_FILE_SYSTEM 0x1000 该文件是一个系统文件
IMAGE_FILE_DLL 0x2000 该文件是一个文件是一个动态链接库
IMAGE_FILE_UP_SYSTEM_ONLY 0x4000 该文件应仅在单处理器计算机上运行
IMAGE_FILE_BYTES_REVERSED_HI 0x8000 废弃

第六节:IMAGE_OPTIONAL_HEADER32

IMAGE_OPTIONAL_HEADER32的结构体定义如下(大部分成员不重要):

typedef struct _IMAGE_OPTIONAL_HEADER {
  WORD                 Magic;                        **魔术字                     偏移0x00
  BYTE                 MajorLinkerVersion;           **链接器主版本                偏移0x02
  BYTE                 MinorLinkerVersion;           **链接器副版本                偏移0x03
  DWORD                SizeOfCode;                   **所有含代码的节的总大小       偏移0x04
  DWORD                SizeOfInitializedData;        **所有含初始数据的节的总大小    偏移0x08
  DWORD                SizeOfUninitializedData;      **所有含未初始数据的节的总大小  偏移0x0C     
  DWORD                AddressOfEntryPoint;          **程序执行入口地址             偏移0x10   重要
  DWORD                BaseOfCode;                   **代码节的起始地址             偏移0x14
  DWORD                BaseOfData;                   **数据节的起始地址             偏移0x18
  DWORD                ImageBase;                    **程序首选装载地址             偏移0x1C   重要
  DWORD                SectionAlignment;             **内存中节区对齐大小           偏移0x20   重要
  DWORD                FileAlignment;                **文件中节区对齐大小           偏移0x24   重要
  WORD                 MajorOperatingSystemVersion;  **操作系统的主版本号           偏移0x28
  WORD                 MinorOperatingSystemVersion;  **操作系统的副版本号           偏移0x2A
  WORD                 MajorImageVersion;            **镜像的主版本号               偏移0x2C
  WORD                 MinorImageVersion;            **镜像的副版本号               偏移0x2E
  WORD                 MajorSubsystemVersion;        **子系统的主版本号             偏移0x30
  WORD                 MinorSubsystemVersion;        **子系统的副版本号             偏移0x32
  DWORD                Win32VersionValue;            **保留,必须为0               偏移0x34
  DWORD                SizeOfImage;                  **镜像大小                    偏移0x38   重要
  DWORD                SizeOfHeaders;                **PE头大小                    偏移0x3C   重要
  DWORD                CheckSum;                     **校验和                      偏移0x40
  WORD                 Subsystem;                    **子系统类型                   偏移0x44
  WORD                 DllCharacteristics;           **DLL文件特征                  偏移0x46
  DWORD                SizeOfStackReserve;           **栈的保留大小                 偏移0x48
  DWORD                SizeOfStackCommit;            **栈的提交大小                 偏移0x4C
  DWORD                SizeOfHeapReserve;            **堆的保留大小                 偏移0x50
  DWORD                SizeOfHeapCommit;             **堆的提交大小                 偏移0x54
  DWORD                LoaderFlags;                  **保留,必须为0                偏移0x58
  DWORD                NumberOfRvaAndSizes;          **数据目录的项数               偏移0x5C
  IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

下方只对一些相对重要的成员进行讲解:
  Magic
  这个无符号整数指出了镜像文件的状态,此成员可以是以下的值:

宏定义 数值 描述
IMAGE_NT_OPTIONAL_HDR32_MAGIC 0x010B 表明这是一个32位镜像文件。
IMAGE_NT_OPTIONAL_HDR64_MAGIC 0x020B 表明这是一个64位镜像文件。
IMAGE_ROM_OPTIONAL_HDR_MAGIC 0x0107 表明这是一个ROM镜像。
 

  AddressOfEntryPoint
  该成员保存着文件被执行时的入口地址,它是一个RVA。如果想要在一个可执行文件中附加了一段代码并且要让这段代码首先被执行,就可以通过更改入口地址到目标代码上,然后再跳转回原有的入口地址。

 

  ImageBase
  该成员指定了文件被执行时优先被装入的地址,如果这个地址已经被占用,那么程序装载器就会将它载入其他地址。当文件被载入其他地址后,就必须通过重定位表进行资源的重定位,这就会变慢文件的载入速度。而装载到ImageBase指定的地址就不会进行资源重定位。
  对于EXE文件来说,由于每个文件总是使用独立的虚拟地址空间,优先装入地址不可能被其他模块占据,所以EXE总是能够按照这个地址装入,这也意味着EXE文件不再需要重定位信息。对于DLL文件来说,由于多个DLL文件全部使用宿主EXE文件的地址空间,不能保证优先装入地址没有被其他的DLL使用,所以DLL文件中必须包含重定位信息以防万一。因此,在前面介绍的 IMAGE_FILE_HEADER 结构的 Characteristics 成员中,DLL 文件对应的IMAGE_FILE_RELOCS_STRIPPED位总是为0,而EXE文件的这个标志位总是为1。

 

  SectionAlignment
  该成员指定了文件被装入内存时,节区的对齐单位。节区被装入内存的虚拟地址必须是该成员的整数倍,以字节为单位,并且该成员的值必须大于等于FileAlignment的值。该成员的默认大小为系统的页面大小。
  FileAlignment
  该成员指定了文件在硬盘上时,节区的对齐单位。节区在硬盘上的地址必须是该成员的整数倍,以字节为单位,并且该成员的值必须大于等于FileAlignment的值。该值应为200h到10000h(含)之间的2的幂。默认为200h。如果SectionAlignment的值小于系统页面大小,则FileAlignment的值必须等于SectionAlignment的值。

 

  SizeOfImage
  该成员指定了文件载入内存后的总体大小,包含所有的头部信息。并且它的值必须是SectionAlignment的整数倍。
  SizeOfHeaders
  该成员指定了PE文件头的大小,并且向上舍入为FileAlignment的倍数,值的计算方式为:

SizeOfHeaders = (e_lfanew/*DOS头部*/ + 4/*PE签名*/ + 
                sizeof(IMAGE_FILE_HEADER) + 
                SizeOfOptionalHeader + /*NT头*/ 
                sizeof(IMAGE_SECTION_HEADER) * NumberOfSections) / /*节表*/
                FileAlignment  * 
                FileAlignment + 
                FileAlignment;    /*向上舍入 一般该结果不可能是FileAlignment的整数倍,所以直接加上FileAlignment还是没问题的 */

  NumberOfRvaAndSizes
  该成员指定了可选头中目录项的具体数目,由于以前发行的Windows NT的原因,它只能为10h。

该结构体中剩下的那一个成员比较特殊,会在下一章进行详细讲解。


第七节:IMAGE_SECTION_HEADER

IMAGE_SECTION_HEADER的结构体定义如下:

#define IMAGE_SIZEOF_SHORT_NAME              8

typedef struct _IMAGE_SECTION_HEADER {
  BYTE  Name[IMAGE_SIZEOF_SHORT_NAME];         **节区名                 偏移0x00
  union {
    DWORD PhysicalAddress;
    DWORD VirtualSize;                         **节区的虚拟大小          偏移0x08      重要
  } Misc;                                      
  DWORD VirtualAddress;                        **节区的虚拟地址          偏移0x0C      重要   
  DWORD SizeOfRawData;                         **节区在硬盘上的大小       偏移0x10      重要
  DWORD PointerToRawData;                      **节区在硬盘上的地址       偏移0x14      重要
  DWORD PointerToRelocations;                  **指向重定位项开头的地址   偏移0x18
  DWORD PointerToLinenumbers;                  **指向行号项开头的地址     偏移0x1C
  WORD  NumberOfRelocations;                   **节区的重定位项数         偏移0x20
  WORD  NumberOfLinenumbers;                   **节区的行号数            偏移0x22
  DWORD Characteristics;                       **节区的属性              偏移0x24       重要
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

  Name
  这是一个8字节的ASCII字符串,长度不足8字节时用0x00填充,该名称并不遵守必须以"\0"结尾的规律,如果不是以"\0"结尾,系统会截取8个字节的长度进行处理。可执行文件不支持长度超过8字节的节名。对于支持超过字节长度的文件来说,此成员会包含斜杠(/),并在后面跟随一个用ASCII表示的十进制数字,该数字是字符串表的偏移量。
  Misc.VirtualSize
  这个成员在一个共用体中,这个共用体中还有另外一个成员,由于用处不大我们就不讲解了,主要讲解VirtualSize的含义。这个成员指定了该节区装入内存后的总大小,以字节为单位,如果此值大于SizeOfRawData的值,那么大出的部分将用0x00填充。这个成员只对可执行文件有效,如果是obj文件此成员的值为0。
  VirtualAddress
  指定了该节区装入内存虚拟空间后的地址,这个地址是一个相对虚拟地址(RVA),它的值一般是SectionAlignment的整数倍。它加上ImageBase后才是真正的虚拟地址。
  SizeOfRawData
  指定了该节区在硬盘上初始化数据的大小,以字节为单位。它的值必须是FileAlignment的整数倍,如果小于Misc.VirtualSize,那么该部分的其余部分将用0x00填充。如果该部分仅包含未初始化的数据,那么这个值将会为零。
  PointerToRawData
  指出零该节区在硬盘文件中的地址,这个数值是从文件头开始算起的偏移量,也就是说这个地址是一个文件偏移地址(FOA)。它的值必须是FileAlignment的整数倍。如果这个部分仅包含未初始化的数据,则将此成员设置为零。
  Characteristics
  该成员指出了该节区的属性特征。其中的不同数据位代表了不同的属性,这些数据位组合起来就是这个节的属性特征,具体数值定义如下:

宏定义 数值 描述
0x00000001 保留
0x00000002 保留
0x00000004 保留
IMAGE_SCN_TYPE_NO_PAD 0x00000008 废弃 替换为IMAGE_SCN_ALIGN_1BYTES
0x00000010 保留
IMAGE_SCN_CNT_CODE 0x00000020 节中包含可执行代码。
IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 节中包含已初始化数据。
IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 节中包含未初始化数据。
IMAGE_SCN_LNK_OTHER 0x00000100 保留
IMAGE_SCN_LNK_INFO 0x00000200 节中包含注释或其他信息,对目标文件有效。
0x00000400 保留
IMAGE_SCN_LNK_REMOVE 0x00000800 该节不会成为镜像文件的一部分,对目标文件有效。
IMAGE_SCN_LNK_COMDAT 0x00001000 该节包含COMDAT数据,对目标文件有效。
0x00002000 保留
IMAGE_SCN_NO_DEFER_SPEC_EXC 0x00004000 重新计算异常处理TLB项中的位
IMAGE_SCN_GPREL 0x00008000 节中包含通过全局指针引用的数据。
0x00010000 保留
IMAGE_SCN_MEM_PURGEABLE 0x00020000 保留
IMAGE_SCN_MEM_LOCKED 0x00040000 保留
IMAGE_SCN_MEM_PRELOAD 0x00080000 保留
IMAGE_SCN_ALIGN_1BYTES 0x00100000 在1字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_2BYTES 0x00200000 在2字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_4BYTES 0x00300000 在4字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_8BYTES 0x00400000 在8字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_16BYTES 0x00500000 在16字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_32BYTES 0x00600000 在32字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_64BYTES 0x00700000 在64字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_128BYTES 0x00800000 在128字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_256BYTES 0x00900000 在256字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_512BYTES 0x00A00000 在512字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_1024BYTES 0x00B00000 在1024字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_2048BYTES 0x00C00000 在2048字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_4096BYTES 0x00D00000 在4096字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_ALIGN_8192BYTES 0x00E00000 在8192字节边界上对齐数据,对目标文件有效。
IMAGE_SCN_LNK_NRELOC_OVFL 0x01000000 此节包含扩展的重定位信息。
IMAGE_SCN_MEM_DISCARDABLE 0x02000000 此节可以在需要时被丢弃。
IMAGE_SCN_MEM_NOT_CACHED 0x04000000 此节无法缓存。
IMAGE_SCN_MEM_NOT_PAGED 0x08000000 此节无法分页。
IMAGE_SCN_MEM_SHARED 0x10000000 此节可以在内存中共享。
IMAGE_SCN_MEM_EXECUTE 0x20000000 此节可以作为代码执行。
IMAGE_SCN_MEM_READ 0x40000000 此节可读。
IMAGE_SCN_MEM_WRITE 0x80000000 此节可写。
  •   节表在PE文件头中的排列位置比较特殊,节表是紧跟在NT头(也可以说是可选PE头后)后的,它实际上是一个IMAGE_SECTION_HEADER类型的数组,数组的成员个数被定义在IMAGE_FILE_HEADER中的NumberOfSections成员上,需要注意的是在最后一个节表后最好应该有一个与节表同样大小的用0x00填充的空白数据。
 

节表各个成员意义图解:
PE节表


第八节:章节练习

  1.通过编写控制台程序,将一个EXE文件读取到内存,打印出它所有的文件信息。(与LordPE的结果进行对照)
  2.通过编写控制台程序,将一个EXE文件读取到内存(FileBuffer),在内存中将它进行拉伸(ImageBuffer),再压缩(NewFileBuffer),然后将压缩后的NewFileBuffer存盘并可以正常运行,实现PE加载过程。
  3.通过编写控制台程序,将一个EXE文件读取到内存,在它的节表中新增一个节表和节区,存盘后让他可以正常运行。
  4.通过编写控制台程序,将一个EXE文件读取到内存,把该文件的最后一个节扩大1000h,并保证程序的正常运行。
  5.通过编写控制台程序,将一个EXE文件读取到内存,把该文件的所有节进行合并,并保证程序的正常运行。
  6.通过编写控制台程序,将一个EXE文件读取到内存,在它的可执行节(代码节)中加一个弹出对话框(MessgeBox)的ShellCode,通过修改程序执行入口实现文件感染,可以正常运行。

 

练习小提示:

1、
读取文件的关键代码:
//函数声明
//**************************************************************************
//MyReadFile:将文件读取到缓冲区
//参数说明:
//pFileAddress 缓冲区地址
//返回值说明:
//成功返回0
//**************************************************************************
int MyReadFile(void** pFileAddress);
{
    int ret = 0;
    DWORD Length = 0;
    //打开文件
    FILE* pf = fopen(FILE_PATH, "rb");
    if (pf == NULL)
    {
        ret = -1;
        printf("func ReadFile() Error!\n");
        return ret;
    }

    //获取文件长度
    ret = GetFileLength(pf, &Length);
    if (ret != 0 && Length == -1)
    {
        ret = -2;
        printf("func GetFileLength() Error!\n");
        return ret;
    }

    //分配空间
    *pFileAddress = (PVOID)malloc(Length);
    if (*pFileAddress == NULL)
    {
        ret = -3;
        printf("func malloc() Error!\n");
        return ret;
    }
    memset(*pFileAddress, 0, Length);

    //读取文件进入内存
    fread(*pFileAddress, Length, 1, pf);

    fclose(pf);
    return ret;
}

2、
    1)、根据SizeOfImage的大小,开辟一块缓冲区(ImageBuffer).
    2)、根据SizeOfHeader的大小,将头信息从FileBuffer拷贝到ImageBuffer
    3)、根据节表中的信息循环讲FileBuffer中的节拷贝到ImageBuffer中.
读取文件的关键代码:
//函数声明
//**************************************************************************
//MyWriteFile:将缓存写入硬盘
//参数说明:
//pFileAddress 缓冲区地址
//FileSize     写入大小
//FilePath     写入路径
//返回值说明:
//成功返回0
//**************************************************************************
int MyWriteFile(PVOID pFileAddress, DWORD FileSize, LPSTR FilePath)
{
    int ret = 0;

    FILE *pf = fopen(FilePath, "wb");
    if (pf == NULL)
    {
        ret = -5;
        printf("func fopen() error :%d!\n", ret);
        return ret;
    }

    fwrite(pFileAddress, FileSize, 1, pf);

    fclose(pf);

    return ret;
}
3、
    1)、判断是否有足够的空间,可以添加一个节表. 
        判断条件:
            剩余空间 = SizeOfHeader - 
                      (e_lfanew + 4/*PE标记*/ + 
                       sizeof(IMAGE_FILE_HEADER) + 
                       SizeOfOptionalHeader + /*NT头*/ 
                       sizeof(IMAGE_SECTION_HEADER) * NumberOfSections)  /*节表大小*/
                    >= sizeof(IMAGE_SECTION_HEADER) * 2     /*2个节表的大小*/
    2)、需要修改的数据
        1> 添加一个新的节(可以copy一份)
        2> 在新增节后面 填充一个节大小的0x00
        3> 修改PE头中节的数量
        4> 修改sizeOfImage的大小
        5> 再原有数据的最后,新增一个节的数据(内存对齐的整数倍).
        6> 修正新增节表的属性
4、
    1)、拉伸到内存        
    2)、分配一块新的空间:SizeOfImage + 1000h    
    3)、修改最后一个节的SizeOfRawData和VirtualSize    
    4)、修改SizeOfImage的大小        
5、
    1)、拉伸到内存
    2)、将第一个节的内存大小、文件大小改成一样
    3)、将第一个节的属性改为包含所有节的属性
    4)、修改节的数量为1

6、
    1)、获取MessageBox地址,构造ShellCode代码.
    2)、E8 E9计算公式
    3)、在代码区手动添加代码
    4)、修改OEP,指向ShellCode

char shellcode[] = 
{ 
    0x6A, 00, 0x6A, 00, 0x6A, 00, 0x6A, 00, 
    0xE8, 00, 00, 00, 00, 
    0xE9, 00, 00, 00, 00 
};
注:0xE8 是call的机器码;0xE9是jmp的机器码;它们后面跟着的4个字节需要通过计算获得。
计算方式: X = 真正要跳转的地址 - 这条指令的下一行地址, 修改地址时注意小端对齐。
         AddressOfEntryPoint是一个RAV

//RVA与FOA相互转换相关代码:

//函数声明
//**************************************************************************
//FOA_TO_RVA:将FOA转换成RVA
//参数说明:
//FileAddress  缓冲区地址
//FOA          FOA值
//pRVA         RVA地址
//返回值说明:
//成功返回0
//**************************************************************************
int FOA_TO_RVA(PVOID FileAddress, DWORD FOA, PDWORD pRVA)
{
    int ret = 0;

    PIMAGE_DOS_HEADER pDosHeader                = (PIMAGE_DOS_HEADER)(FileAddress);
    PIMAGE_FILE_HEADER pFileHeader                = (PIMAGE_FILE_HEADER)((DWORD)pDosHeader + pDosHeader->e_lfanew + 4);
    PIMAGE_OPTIONAL_HEADER32 pOptionalHeader    = (PIMAGE_OPTIONAL_HEADER32)((DWORD)pFileHeader + sizeof(IMAGE_FILE_HEADER));
    PIMAGE_SECTION_HEADER pSectionGroup            = (PIMAGE_SECTION_HEADER)((DWORD)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);

    //FOA在文件头中 或 SectionAlignment 等于 FileAlignment 时RVA等于FOA
    if (FOA < pOptionalHeader->SizeOfHeaders || pOptionalHeader->SectionAlignment == pOptionalHeader->FileAlignment)
    {
        *pRVA = FOA;
        return ret;
    }

    //FOA在节区中
    for (int i = 0; i < pFileHeader->NumberOfSections; i++)
    {
        if (FOA >= pSectionGroup[i].PointerToRawData && FOA < pSectionGroup[i].PointerToRawData + pSectionGroup[i].SizeOfRawData)
        {
            *pRVA = pSectionGroup[i].VirtualAddress + FOA - pSectionGroup[i].PointerToRawData;
            return ret;
        }
    }

    //没有找到地址
    ret = -4;
    printf("func FOA_TO_RAV() Error: %d 地址转换失败!\n", ret);
    return ret;
}


//函数声明
//**************************************************************************
//RVA_TO_FOA:将RVA转换成FOA
//参数说明:
//FileAddress  缓冲区地址
//RVA          RVA值
//pFOA         FOA地址
//返回值说明:
//成功返回0
//**************************************************************************
int RVA_TO_FOA(PVOID FileAddress, DWORD RVA, PDWORD pFOA)
{
    int ret = 0;

    PIMAGE_DOS_HEADER pDosHeader                = (PIMAGE_DOS_HEADER)(FileAddress);
    PIMAGE_FILE_HEADER pFileHeader                = (PIMAGE_FILE_HEADER)((DWORD)pDosHeader + pDosHeader->e_lfanew + 4);
    PIMAGE_OPTIONAL_HEADER32 pOptionalHeader    = (PIMAGE_OPTIONAL_HEADER32)((DWORD)pFileHeader + sizeof(IMAGE_FILE_HEADER));
    PIMAGE_SECTION_HEADER pSectionGroup            = (PIMAGE_SECTION_HEADER)((DWORD)pOptionalHeader + pFileHeader->SizeOfOptionalHeader);

    //RVA在文件头中 或 SectionAlignment 等于 FileAlignment 时RVA等于FOA
    if (RVA < pOptionalHeader->SizeOfHeaders || pOptionalHeader->SectionAlignment == pOptionalHeader->FileAlignment)
    {
        *pFOA = RVA;
        return ret;
    }

    //RVA在节区中
    for (int i = 0; i < pFileHeader->NumberOfSections; i++)
    {
        if (RVA >= pSectionGroup[i].VirtualAddress && RVA < pSectionGroup[i].VirtualAddress + pSectionGroup[i].Misc.VirtualSize)
        {
            *pFOA = pSectionGroup[i].PointerToRawData + RVA - pSectionGroup[i].VirtualAddress;
            return ret;
        }
    }

    //没有找到地址
    ret = -4;
    printf("func RAV_TO_FOA() Error: %d 地址转换失败!\n", ret);
    return ret;
}

第二章:打造自己的PE解析器——目录信息

  • 本章目的:在上一章中我们留下来一个知识点没有讲解,那就是IMAGE_OPTIONAL_HEADER32中的最后一个成员DataDirectory,虽然他只是一个结构体数组,每个结构体的大小也不过是个字节,但是它却是PE文件中最重要的成员。PE装载器通过查看它才能准确的找到某个函数或某个资源。
  • 重点掌握:
    (1) 0x01 IMAGE_EXPORT_DIRECTORY——导出表
    (2) 0x03 IMAGE_BASE_RELOCATION——重定位表
    (3) 0x05 IMAGE_IMPORT_DESCRIPTOR——导入表
    (4) 0x07 IMAGE_BOUND_IMPORT_DESCRIPTOR——绑定导入表
    (5) 0x09 IMAGE_RESOURCE_DIRECTORY——资源表

第一节:IMAGE_DATA_DIRECTORY——数据目录结构

IMAGE_DATA_DIRECTORY的结构体定义如下:

typedef struct _IMAGE_DATA_DIRECTORY {
  DWORD VirtualAddress;                   /**指向某个数据的相对虚拟地址   RAV  偏移0x00**/
  DWORD Size;                             /**某个数据块的大小                 偏移0x04**/
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

  在这个数据目录结构体中只有两个成员VirtualAddressSize,这两个成员的含义比较简单,VirtualAddress指定了数据块的相对虚拟地址(RVA)。Size则指定了该数据块的大小,有时并不是该类型数据的总大小,可能只是该类型数据一个数据项的大小。这两个成员(主要是VirtualAddress)成为了定位各种表的关键,所以一定要知道每个数组元素所指向的数据块类型,以下表格就是它的对应关系:

英文描述 数组下标 中文描述
Export table address and size 0x00 导出表的地址和大小
Import table address and size 0x01 导入表的地址和大小
Resource table address and size 0x02 资源表的地址和大小
Exception table address and size 0x03 异常表的地址和大小
Certificate table address and size 0x04 证书表的地址和大小
Base relocation table address and size 0x05 基址重定位表的地址和大小
Debugging information starting address and size 0x06 调试信息的起始地址和大小
Architecture-specific data address and size 0x07 特定于体系结构数据的地址和大小
Global pointer register relative virtual address 0x08 全局指针寄存器相对虚拟地址
Thread local storage (TLS) table address and size 0x09 (线程本地存储)TLS表的地址和大小
Load configuration table address and size 0x0A 加载配置表地址和大小
Bound import table address and size 0x0B 绑定导入表的地址和大小
Import address table address and size 0x0C 导入地址表的地址和大小
Delay import descriptor address and size 0x0D 延迟导入表的地址和大小
The CLR header address and size 0x0E CLR运行时头部数据地址和大小
Reserved 0x0F 保留
//定位目录项的方法(以导出表为例):    所有操作都在FileBuffer状态下完成

//1、指向相关内容
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)(FileAddress);
PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)((DWORD)pDosHeader + pDosHeader->e_lfanew + 4);
PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)((DWORD)pFileHeader + sizeof(IMAGE_FILE_HEADER));

//2、获取导出表的地址(目录项的第0个成员)
DWORD ExportDirectory_RAVAdd = pOptionalHeader->DataDirectory[0].VirtualAddress;
DWORD ExportDirectory_FOAAdd = 0;
//    (1)、判断导出表是否存在
if (ExportDirectory_RAVAdd == 0)
{
    printf("ExportDirectory 不存在!\n");
    return ret;
}
//    (2)、获取导出表的FOA地址    转换函数看上一章作业提示
ret = RVA_TO_FOA(FileAddress, ExportDirectory_RAVAdd, &ExportDirectory_FOAAdd);
if (ret != 0)
{
    printf("func RVA_TO_FOA() Error!\n");
    return ret;
}

//3、指向导出表
PIMAGE_EXPORT_DIRECTORY ExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((DWORD)FileAddress + ExportDirectory_FOAAdd);

第二节:IMAGE_EXPORT_DIRECTORY——导出表

IMAGE_EXPORT_DIRECTORY的结构体定义如下:

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;        //         未使用,总为0 
    DWORD   TimeDateStamp;          //         文件创建时间戳
    WORD    MajorVersion;           //         未使用,总为0 
    WORD    MinorVersion;           //         未使用,总为0
    DWORD   Name;                   // **重要   指向一个代表此 DLL名字的 ASCII字符串的 RVA
    DWORD   Base;                   // **重要   函数的起始序号
    DWORD   NumberOfFunctions;      // **重要   导出函数地址表的个数
    DWORD   NumberOfNames;          // **重要   以函数名字导出的函数个数
    DWORD   AddressOfFunctions;     // **重要   导出函数地址表RVA
    DWORD   AddressOfNames;         // **重要   导出函数名称表RVA
    DWORD   AddressOfNameOrdinals;  // **重要   导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

  导出表简介:在导出表中前四个成员基本没有用,我们就不用去管他,但是剩下的成员都是非常重要的,我们会通过讲解导出表的结构时顺带介绍。现在我们来说说导出表的作用,简单来说导出表就是用来描述模块中的导出函数的结构,导出函数就是将功能的提供给外部使用的函数,如果一个PE文件导出了函数,那么这个函数的信息就会记录PE文件的导出表中,方便外部程序加载该文件进行动态调用。可能有时函数在导出表中只有一个序号而没有名字,也就造成了导出表中有了三个子表的存在,分别是:函数地址表、函数名称表和函数序号表。使得外部程序可以通过函数名称和函数序号两种方式获取该函数的地址。

//系统中获取函数地址的两种方法:
HMODULE hModule = LoadLibraryA("User32.dll");
//1、函数名获取
DWORD FuncAddress = GetProcAddress(hModule, "MessageBoxA");
//2、序号获取
DWORD FuncAddress = GetProcAddress(hModule, 12);

  AddressOfFunctions
  这个值是一个4字节的RVA地址,他可以用来定位导出表中所有函数的地址表,这个地址表可以当作一个成员宽度为4的数组进行处理,它的长度由NumberOfFunctions进行限定,地址表中的成员也是一个RVA地址,在内存中加上ImageBase后才是函数真正的地址。
  AddressOfNames
  这个值是一个4字节的RVA地址,他可以用来定位导出表中所有函数的名称表,这个名称表也可以当作一个成员宽度为4的数组进行处理,它的长度由NumberOfNames进行限定,名称表的成员也是一个RVA地址,在FIleBuffer状态下需要进行RVA到FOA的转换才能真正找到函数名称。
  AddressOfNameOrdinals
  这个值是一个4字节的RVA地址,他可以用来定位导出表中所有函数的序号表,这个序号表可以当作一个成员宽度为2的数组进行处理,它的长度由NumberOfNames进行限定,名称表的成员是一个函数序号,该序号用于通过名称获取函数地址。
  NumberOfFunctions
  注意,这个值并不是真的函数数量,他是通过函数序号表中最大的序号减去最小的序号再加上一得到的,例如:一共导出了3个函数,序号分别是:0、2、4,NumberOfFunctions = 4 - 0 + 1 = 5个。

 

导出表结构图:
导出表结构图

 

通过导出表查找函数地址的两种方法:

 

  1、通过函数名查找函数地址:
按函数名查找函数地址
    (1)、首先定位函数名表,然后通过函数名表中的RVA地址定位函数名,通过比对函数名获取目标函数名的在函数名表中的索引。
    (2)、通过获取函数名表的索引获取函数序号表中对应索引中的函数序号。
    (3)、通过把该序号当作函数地址表的下标,就可以得到该下标中的函数地址。

 

  2、通过函数序号查找函数地址:
通过函数序号查找函数地址
    (1)、首先计算函数地址表的索引:index = 目标函数的函数序号 - 导出表的Base。
    (2)、通过计算出的索引就可以在函数地址表中获取到目标序号的函数地址。
    注:通过序号获取函数地址不需要使用函数名称表和函数序号表就可以直接获取函数地址,实现上相对来说比较方便。


第三节:导出表小练习

  1.通过编写控制台程序,打印导出表信息,并打印出函数地址表、函数名表、序号表。
  2.写出按名字查找函数地址、按序号查找函数地址相关函数。
  3.在PE文件中创建一个新节,然后将导出表的所有信息移动到新节中。最后将文件写入硬盘,并可以正确解析导出表。

 

练习小提示:

1、
//    1)定位导出表
//    2)打印导出表所有信息
//    3)定位函数地址表,并打印相关信息    
//    4)定位函数序号表,并打印相关信息  
//    5)定位函数名称表,并打印相关信息  

2、可以通过上一节的图片提示编程
//参考代码声明
//**************************************************************************
//GetProcAddressByName:按名字查找函数地址
//参数说明:
//FileAddress    缓冲区地址
//pFuncName      要查找的函数名
//FuncAddressRVA 查找出的函数地址指针
//返回值说明:
//成功返回0
//**************************************************************************
int GetProcAddressByName(PVOID FileAddress, PCHAR pFuncName, PDWORD FuncAddressRVA);

//**************************************************************************
//GetProcAddressByOrdinal:按序号查找函数地址
//参数说明:
//FileAddress    缓冲区地址
//wFuncOrdinal   要查找的函数序号
//FuncAddressRVA 查找出的函数地址指针
//返回值说明:
//成功返回0
//**************************************************************************
int GetProcAddressByOrdinal(PVOID FileAddress, WORD wFuncOrdinal, PDWORD FuncAddressRVA);

3、移动导出表的步骤:
    1)创建一个新节
    2)移动函数地址表到新节区
    3)移动函数序号表
    4)移动函数名称表
    5)将函数名称移动到函数名称表之后,并修正函数名表中的数据
    6)将文件名移动到函数名后
    7)将整个导出表移动到文件名后
    8)修复导出表数据:Name、AddressOfFunctions、AddressOfNames、NumberOfFunctions
    9)修正目录项的RVA地址

第四节:IMAGE_BASE_RELOCATION——重定位表

IMAGE_EXPORT_DIRECTORY的结构体定义如下:

typedef struct _IMAGE_BASE_RELOCATION {
    DWORD   VirtualAddress;            重定位数据所在页的RVA
    DWORD   SizeOfBlock;               当前页中重定位数据块的大小
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;

  重定位表简介:正如我们所知,在程序运行时系统首先会给程序分配一个4GB的虚拟内存空间,低2G空间用于放置EXE文件和DLL文件,高2G空间则是用于取得程序使用(这个空间所有程序共享)。系统随后就会将EXE文件第一个贴入低2G空间占据文件指定的ImageBase,所以EXE文件有时会没有重定位表,因为ImageBase区域大多数情况是可以使用的,也就不需要重定位。贴完EXE文件后接下来就会将大量程序使用的DLL文件贴入虚拟空间,然而这些DLL文件的ImageBase可能会发生冲突,所以有些DLL文件就不会被贴入指定的地址,但是为了让程序正常运行就只能将这些DLL贴入其他的地址。但是在PE文件中很多地址都是被编译器写死固定的(例子在下方代码块),如果基址改变这些地址就会无法使用,为了避免这样的事情发生就需要修正这些固定的地址,所以就有了重定位表。重定位表就是记录了这些需要修正的地址,在ImageBase发生改变时就会进行修正重定位表。
  修正方法:需要重定位的地址 - 以前的基址 + 当前的基址。

//需要重定位的值013EBC98h和013ED49Ch
    printf("Helloworld %s", "hahaha");
013E64B2 68 98 BC 3E 01       push        offset string "hahaha" (013EBC98h)  
013E64B7 68 9C D4 3E 01       push        offset string "Helloworld %s" (013ED49Ch)  
013E64BC E8 A3 AB FF FF       call        _printf (013E1064h)  
013E64C1 83 C4 08             add         esp,8

  VirtualAddress
  这个虚拟地址是一组重定位数据的开始RVA地址,只有重定位项的有效数据加上这个值才是重定位数据真正的RVA地址。
  SizeOfBlock
  它是当前重定位块的总大小,因为VirtualAddress和SizeOfBlock都是4字节的,所以(SizeOfBlock - 8)才是该块所有重定位项的大小,(SizeOfBlock - 8) / 2就是该块所有重定位项的数目。
  重定位项
  重定位项在该结构中没有体现出来,他的位置是紧挨着这个结构的,可以把他当作一个数组,宽度为2字节,每一个重定位项分为两个部分:高4位和低12位。高4位表示了重定位数据的类型(0x00没有任何作用仅仅用作数据填充,为了4字节对齐。0x03表示这个数据是重定位数据,需要修正。0x0A出现在64位程序中,也是需要修正的地址),低12位就是重定位数据相对于VirtualAddress的偏移,也就是上面所说的有效数据。之所以是12位,是因为12位的大小足够表示该块中的所有地址(每一个数据块表示一个页中的所有重定位数据,一个页的大小位0x1000)。

 

注:如果修改了EXE文件的ImageBase,就要手动修复它的重定位表,因为系统会判断程序载入地址和ImageBase是否一致,如果一致就不会自动修复重定位表,双击运行时就会报错。

 

重定位表结构:
重定位表结构

 

通过重定位表找到需要修正的数据:
修复重定位数据


第五节:重定位表小练习

  1.通过编写控制台程序,打印出重定位表所有信息以及重定位项,同时找到需要修正的数据。
  2.在PE文件中创建一个新节,然后将重定位表的所有信息移动到新节中。最后将文件写入硬盘,并可以正确解析重定位表。
  3.改变EXE文件中的ImageBase,然后手动修复重定位表,使其能够正常运行。(EXE文件必须包含重定位表,否则会失败)

 

练习小提示:

1、代码比较简单,参考图片可以轻松完成,提供下参考打印格式:
重定位表参考打印格式
2、移动重定位表的步骤:
  1)在PE文件中创建一个新节
  2)将重定位表的数据块循环拷贝到新的节区
  3)修复目录项对应的虚拟地址
3、修复重定位表的方式:
  修复结果 = 需要重定位的数据 - 以前的ImageBase + 现在的ImageBase;


第六节:IMAGE_IMPORT_DESCRIPTOR——导入表

IMAGE_IMPORT_DESCRIPTOR——导入表的结构体定义如下:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;
        DWORD   OriginalFirstThunk;             //导入名称表(INT)的RVA地址
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;                      //时间戳多数情况可忽略  如果是0xFFFFFFFF表示IAT表被绑定为函数地址
    DWORD   ForwarderChain;
    DWORD   Name;                               //导入DLL文件名的RVA地址
    DWORD   FirstThunk;                         //导入地址表(IAT)的RVA地址
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

  导入表简介:PE文件使用来自于其他DLL的代码或数据是,称作导入(或者输入)。当PE文件装入时,Windows装载器的工作之一就是定位所有被输入的函数和数据,并且让正在被装入的问渐渐可以使用这些地址。这个过程就是通过PE文件的导入表来完成的,导入表中保存的是函数名和其驻留的DLL名等动态链接所需的信息。

 

  OriginalFirstThunk
  这个值是一个4字节的RVA地址,这个地址指向了导入名称表(INT),INT是一个IMAGE_THUNK_DATA结构体数组,这个结构体的最后一个成员内容为0时数组结束。这个数组的每一个成员又指向了一个IMAGE_IMPORT_BY_NAME结构体,这个结构体包含了两个成员函数序号和函数名,不过这个序号一般没什么用,所以有的编译器会把函数序号置0。函数名可以当作一个以0结尾的字符串。(注:这个表不在目录项中。)

  Name
  DLL名字的指针,是一个RVA地址,指向了一个以0结尾的ASCII字符串。

  FirstThunk
  这个值是一个4字节的RVA地址,这个地址指向了导入地址表(IAT),这个IAT和INT一样,也是一个IMAGE_THUNK_DATA结构体数组,不过它在程序载入前和载入后由两种状态,在程序载入前它的结构和内容和INT表完全一样,但却是两个不同的表,指向了IMAGE_IMPORT_BY_NAME结构体。在程序载入后,他的结构和INT表一样,但内容就不一样了,里面存放的都是导入函数的地址。(注:这个表在目录项中,需要注意。)

IMAGE_THUNK_DATA——INT、IAT的结构体定义如下:

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;

//注:这个结构体是联合类型的,每一个成员都是4字节,所以为了编程方便,完全可以用一个4字节的数组取代它。

IMAGE_IMPORT_BY_NAME 结构体定义如下:

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;
    CHAR   Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

//注:这个结构体由两个成员组成,大致一看它的大小是3个字节,其实它的大小是不固定的,
//    因为无法判断函数名的长度,所以最后一个成员是一个以0结尾的字符串。

EXE文件载入后IAT表的状态:
EXE文件载入后IAT表的状态

 

  注:我们随便用OD载入一个EXE文件,找到一个Kernel32.DLL的函数GetStartupInfoA,双击这条反汇编看看它的指令,发现call的是0x41D034中存放的内容,接着我们搜索这个地址发现里面存放了一个函数的地址,而这个函数正好就是GetStartupInfoA。于是我们得知在程序载入后,IAT表中存放的是函数的地址,而不是一个RVA地址。

 

EXE文件载入后对应的导入表结构图:
载入后导入表结构图

 

EXE文件载入前IAT表的状态:
EXE文件载入前IAT表的状态
  注:为了查看0x41D034这个地址在程序载入前存放的内容,我们就要将这个地址减去ImageBase得到一个RAV地址:0x01D034,由于这个PE文件的FileAlignment和SectionAlignment是一样的(都是0x1000),用16进制编辑器打开这个文件直接跳转到0x01D034这个地址就可以获得里面的内容了。跳转到这个地址后发现里面存储的是一个RVA地址,并不是函数地址。我们就进行跳转到0x23256这个RVA地址,我们就可以发现它指向了IMAGE_IMPORT_BY_NAME结构体,这个结构体存储的函数名刚好就是GetStartupInfoA。所以我们就可以断定载入前和载入后的IAT表是不一样的。

 

EXE文件载入前对应的导入表结构图:
载入前导入表结构图


第七节:导入表小练习

  1.通过编写控制台程序,打印导入表的导入文件名、INT表和IAT表。
  2.在PE文件中创建一个新节,然后将导入表、INT表以及函数名、文件名移动到新节中。最后将文件写入硬盘,并可以执行。(有点难度)

 

练习小提示:

1、打印流程:
    (1)定位导入表
    (2)打印导入文件名
    (3)遍历INT表,打印出序号和函数名
    (4)遍历IAT表,打印出序号和函数名(不要使用notepad.exe等系统程序练习,等学完绑定导入表后进行修改代码)

2、移动流程:
    (1)增加新的节区,注意节区的大小等于导入表、INT表和各种名字的总大小,如果觉得计算麻烦可以直接在导入表大小的基础上加上0x5000并对齐。
    (2)将所有的导入表移动到新节
    (3)获取INT表中项目的个数
    (4)将INT表移动到新节,同时移入函数名结构,修正数据。
    (5)将导入文件名移入新节
    (6)依次修复导入表数据信息(OriginalFirstThunk和Name)
    (7)修复目录项中导入表的RVA地址

注:大家可能注意到我们没有移动IAT表,这是因为IAT表在程序加载后存储的是函数地址。程序通过访问IAT表才能获取函数的地址,如果移动IAT表,程序就无法获取函数地址,要想解决这个问题由两种方法:一是把程序中所有访问IAT表的地方进行修正,事实上目前来说无法实现。二是放弃移动IAT表。

第八节:IMAGE_BOUND_IMPORT_DESCRIPTOR——绑定导入表

IMAGE_BOUND_IMPORT_DESCRIPTOR的结构体定义如下:

typedef struct _IMAGE_BOUND_IMPORT_DESCRIPTOR {
    DWORD   TimeDateStamp;                    //时间戳
    WORD    OffsetModuleName;                 //DLL名的地址偏移
    WORD    NumberOfModuleForwarderRefs;      //该结构后IMAGE_BOUND_FORWARDER_REF数组的数量
// Array of zero or more IMAGE_BOUND_FORWARDER_REF follows
} IMAGE_BOUND_IMPORT_DESCRIPTOR,  *PIMAGE_BOUND_IMPORT_DESCRIPTOR;

  绑定导入表简介:绑定导入是一个文件快速启动的技术,但是只能起到辅助的效果,它的存在只会影响到PE文件的加载过程,并不会影响PE文件的运行结果,这也就是说把绑定导入的信息从PE文件中清除后对这个PE文件的运行结果没有任何影响。从导入表部分我们可以知道,FirstThunk这个成员指向了IAT表,在程序加载时加载器会通过INT表来修复IAT表,使里面存放上对应函数的地址信息,但是如果导入的函数太多在加载过程中就会使程序启动变慢,绑定导入就是为了减少IAT表的修复时间。它会在程序加载前修复IAT表,然后在PE文件中声明绑定导入的数据信息,让操作系统知道这些事情已经提前完成。这就是绑定导入表的作用。

 

  TimeDateStamp
  这个时间戳相对来说还是比较重要的,因为这个值只有和导入DLL的IMAGE_FILE_HEADER中的TimeDateStamp值相同才能起到绑定导入的效果,如果不一致加载器就会重新计算IAT表中的函数地址。(由于DLL文件的版本不同或者DLL文件的ImageBase被重定位时,IAT绑定的函数的地址就会发生变化)

 

  OffsetModuleName
  这个偏移不是RVA页不是FOA,所以模块名的定位与之前的方法不同,它的定位方式是以第一个IMAGE_BOUND_IMPORT_DESCRIPTOR的地址为基址,加上OffsetModuleName的值就是模块名所在的地址了,这个模块名是以0结尾的ASCII字符串。

 

  NumberOfModuleForwarderRefs
  这个值是在IMAGE_BOUND_IMPORT_DESCRIPTOR结构后跟随的IMAGE_BOUND_FORWARDER_REF结构的数量。在每一个IMAGE_BOUND_IMPORT_DESCRIPTOR结构后都会跟随着大于等于0个IMAGE_BOUND_FORWARDER_REF结构,然后在其后面又会跟上绑定表结构体,直至全部用0填充的绑定表结构。

IMAGE_BOUND_IMPORT_DESCRIPTOR的结构体定义如下:

typedef struct _IMAGE_BOUND_FORWARDER_REF {
    DWORD   TimeDateStamp;               //时间戳
    WORD    OffsetModuleName;            //DLL名的地址偏移
    WORD    Reserved;                    //保留
} IMAGE_BOUND_FORWARDER_REF, *PIMAGE_BOUND_FORWARDER_REF;
//注:
//    该结构中的成员和绑定导入表的成员含含义一致,所以不再过多叙述。
//    由于IMAGE_BOUND_IMPORT_DESCRIPTOR和IMAGE_BOUND_FORWARDER_REF的大小结构相同,所以可以相互转型,方便编程。

绑定导入表结构图:
绑定导入表结构图


第九节:绑定导入表小练习

  1.通过编写控制台程序,打印绑定导入表的信息。(很多文件没有绑定导入表,需要判断)

 

练习小提示:

1、代码比较简单,参考图片可以轻松完成,提供下参考打印格式:
打印绑定导入表


第十节:IMAGE_RESOURCE_DIRECTORY——资源表

IMAGE_RESOURCE_DIRECTORY的结构体定义如下:(由四种结构体组成)

//资源目录头
typedef struct _IMAGE_RESOURCE_DIRECTORY {
    DWORD   Characteristics;        //资源属性        一般为0
    DWORD   TimeDateStamp;          //资源创建时间戳   一般为0
    WORD    MajorVersion;            
    WORD    MinorVersion;
    WORD    NumberOfNamedEntries;   //以名称命名的目录项数量  重要
    WORD    NumberOfIdEntries;      //以ID命名的目录项数量   重要
//  IMAGE_RESOURCE_DIRECTORY_ENTRY DirectoryEntries[];
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;

//资源目录项
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
    union {
        struct {
            DWORD NameOffset:31;         //字符串的偏移(不是RVA、FOA,相对特殊)
            DWORD NameIsString:1;        //判断名字是否是字符串    1:是  0:不是
        } DUMMYSTRUCTNAME;
        DWORD   Name;
        WORD    Id;                      //目录项的ID(在一级目录指资源类型,二级目录指资源编号,三级目录指代码的页号)
    } DUMMYUNIONNAME;                    
    union {
        DWORD   OffsetToData;            //如果不是目录,这里指数据的偏移(不是RVA、FOA,相对特殊)
        struct {
            DWORD   OffsetToDirectory:31;//目录的偏移(不是RVA、FOA,相对特殊)
            DWORD   DataIsDirectory:1;   //判断子资源项是否是目录    1:是  0:不是
        } DUMMYSTRUCTNAME2;
    } DUMMYUNIONNAME2;
} IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;

//数据项
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
    DWORD   OffsetToData;        //数据的偏移    重要
    DWORD   Size;                //数据的大小    重要
    DWORD   CodePage;            //代码页(一般为0)
    DWORD   Reserved;            //保留
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;

//名字字符串结构
typedef struct _IMAGE_RESOURCE_DIR_STRING_U {
    WORD    Length;                //Unicode字符串长度
    WCHAR   NameString[ 1 ];       //Unicode字符串
} IMAGE_RESOURCE_DIR_STRING_U, *PIMAGE_RESOURCE_DIR_STRING_U;

  资源表简介:在Windows程序中其各种界面被称作为资源,其中被系统预先定义的资源类型包括:鼠标指针,位图, 图标,菜单,对话框, 字符串列表,字体目录, 字体,加速键,非格式化资源,消息列表,鼠标指针组,图标组,版本信息。当然还有用户自定义的资源类型,这些资源的就不举例了。这些资源都是以二进制的形式保存到PE文件中,而保存资源信息的结构就是资源表,它位于目录项的第三位。在PE文件的所有结构中,资源表的结构最为复杂,这是因为资源表用类似于文件目录结构的方式进行保存的,从根目录开始,下设一级目录、二级目录和三级目录,三级目录下才是资源文件的信息,而且资源表的结构定位也是最为特殊的,希望重点掌握。
  一级目录是按照资源类型分类的,如位图资源、光标资源、图标资源。
  二级目录是按照资源编号分类的,同样是菜单资源,其子目录通过资源ID编号分类,例如:IDM_OPEN的ID号是2001h,IDM_EXIT的ID号是2002h等多个菜单编号。
  三级目录是按照资源的代码页分类的,即不同语言的代码页对应不同的代码页编号,例如:简体中文代码页编号是2052。
  三级目录下是节点,也称为资源数据,这是一个IMAGE_RESOURCE_DATA_ENTRY的数据结构,里面保存了资源的RVA地址、资源的大小,对所有资源数据块的访问都是从这里开始的。

注:资源表的一级目录、二级目录、三级目录的目录结构是相同的都是由一个资源目录头加上一个资源目录项数组组成的,可以将这个结构称作资源目录结构单元。

 

  IMAGE_RESOURCE_DIRECTORY.NumberOfNamedEntriesIMAGE_RESOURCE_DIRECTORY.NumberOfIdEntries
  在资源目录头结构中这两个字段是最为重要的,其他字段大部分为0。NumberOfNamedEntries表示在该资源目录头后跟随的资源目录项中以IMAGE_RESOURCE_DIR_STRING_U结构命名的资源目录项数量。NumberOfIdEntries表示在该资源目录头后跟随的资源目录项中以ID命名的资源目录项数量。两个字段加起来就是本资源目录头后的资源目录项的数量总和。也就是后面IMAGE_RESOURCE_DIRECTORY_ENTRY结构的总数量。

 

  IMAGE_RESOURCE_DIRECTORY_ENTRY.DUMMYUNIONNAME
  在资源目录项中该字段是一个联合体类型,大小为4个字节,它决定这个资源目录的名字是字符串还是ID号。如果这个字段的最高位是1,则表示该资源的名字是字符串类型,该字段的低31位是IMAGE_RESOURCE_DIR_STRING_U结构的偏移,但这个偏移既不是FOA也不是RVA,它是以首个资源表的地址为基址,加上低31位的值才是字符串结构的地址。如果最高位为0,则表示该资源的名字是一个ID号,整个字段的值就是该资源的ID。(如果是一级目录的资源项,该ID有14个号码被预先定义了)

 

一级目录中预定义的资源ID:

资源ID 含义 资源ID 含义
0x01 鼠标指针(Cursor) 0x08 字体(Font)
0x02 位图(Bitmap) 0x09 加速键(Accelerators)
0x03 图标(Icon) 0x0A 非格式化资源(Unformatted)
0x04 菜单(Menu) 0x0B 消息列表(Message Table)
0x05 对话框(Dialog) 0x0C 鼠标指针组(Group Cursor)
0x06 字符串列表(String) 0x0E 图标组(Group Icon)
0x07 字体目录(Font Directory) 0x10 版本信息(Version Information)
 

  IMAGE_RESOURCE_DIRECTORY_ENTRY.DUMMYUNIONNAME2
  在资源目录项中该字段是一个联合体类型,大小为4个字节,它决定这个资源目录的目录中子节点的类型(是目录还是节点)。如果这个字段的最高位是1,则表示该资源的子节点是一个目录类型,该字段的低31位是子目录的资源目录头结构的偏移,但这个偏移既不是FOA也不是RVA,它是以首个资源表的地址为基址,加上低31位的值才是资源目录头结构的地址。如果最高位为0,则表示该资源的子节点是一个节点,它也以首个资源表的地址为基址,整个字段的值就是该资源节点的偏移。这个节点是IMAGE_RESOURCE_DATA_ENTRY类型的结构体。(一般在三级目录中该字段的最高位位0,而在其他两个目录中该字段的最高位为1)

注:为了编程方便,IMAGE_RESOURCE_DIRECTORY_ENTRY的联合体中出现了一组特殊的struct结构体,其成员声明格式为:[类型] [变量名] : [位宽表达式], 这个格式就是C语言中位段的声明格式。NameOffset字段的值等于该联合体的低31位,NameIsString字段的值等于该联合体的最高位。将一个4字节的类型拆成这样两个字段就可以方便的避免了繁琐的位操作了,而且该结构的总大小不会发生变化。

 

  IMAGE_RESOURCE_DATA_ENTRY
  这个结构体就是目录资源的三级目录下的子目录,里面存储的就是资源文件的信息,如OffsetToData字段存储的就是资源文件的RVA地址,它指向了资源的二进制信息,Size字段存储的就是资源文件的大小,CodePage字段存储资源的代码页但大多数情况为0。
  注:在其指向的资源数据中,字符串都是Unicode的编码方式,每个字符都是由一个16位(一个单字)的值表示,并且都是以UNICODE_NULL结束(其实就是两个0x00)。

  IMAGE_RESOURCE_DIR_STRING_U
  该结构体就是目录资源的名称结构,里面存在两个字段,都是2个字节,Length字段存储的是目录资源名称的长度,以2个字节为单位。NameString字段是一个Unicode字符串的第一个字符,并不以0结尾,其长度是由Length字段限制。该结构的总大小并不是表面上的4个字节,而是根据名字长度变化的,计算方式为:Size = SizeOf(WCHAR) * (Length + 1); 这里的1是Length字段的大小。

 

资源表的结构图:
资源表结构图

 

资源表的结构图(简图):
资源表结构简图

 

手动寻找资源数据(为了方便程序的两个对齐方式的值都是0x1000):

 

1、通过可选PE头定位资源表,解析第一个资源目录项:
资源表1
  (1)、可以得到资源目录头中一共有9个目录项。
  (2)、在第一个目录项中以ID号命名,资源类型位2也就是位图资源。
  (3)、在目录项的二个字段可以得知该目录的子节点也是目录,偏移是0x58,RVA = 0x2B000 + 0x58 = 0x2B058。

 

2、定位二级资源目录并解析:
资源表2
  (1)、可以得到资源目录头中一共有1个目录项。
  (2)、目录项以ID号命名,ID号为0x80。
  (3)、在目录项的二个字段可以得知该目录的子节点也是目录,偏移是0x268,RVA = 0x2B000 + 0x268 = 0x2B268。

 

3、定位三级资源目录并解析:
资源表3
  (1)、可以得到资源目录头中一共有1个目录项。
  (2)、目录项以ID号命名,ID号为0x804,表示使用简体中文的代码页。
  (3)、在目录项的二个字段可以得知该目录的子节点是数据项,偏移是0x6E8,RVA = 0x2B000 + 0x6E8 = 0x2B6E8。

 

4、定位数据项:
资源表4
  (1)、资源数据的RVA地址:0x2C260.
  (2)、资源数据的大小:0x680.


第十一节:资源表小练习

  1.通过编写控制台程序,打印资源表的信息。(可以考虑递归实现)
  2.在PE文件中创建一个新节,然后将资源表移动到新节中。最后将文件写入硬盘,并可以正确解析资源表。

 

练习小提示:

1、打印流程:
    (1)定位资源表
    (2)打印资源表目录头信息,并循环打印目录项。(一级目录)
    (3)定位二级目录头,并打印资源表目录头信息,循环打印目录项。(二级目录)
    (4)定位三级目录头,并打印资源表目录头信息,循环打印目录项。(三级目录)
    (5)定位资源数据项,并打印资源数据项信息。
2、移动资源表的步骤:
  (1)在PE文件中创建一个新节
  (2)将资源表的一级目录全部copy到新节
  (3)循环一级资源表信息,定位二级目录。
  (4)将资源表的二级目录全部copy到指定位置。
  (5)循环二级资源表信息,定位三级目录。
  (6)将资源表的三级目录全部copy到指定位置。
  (7)定位资源数据项,将资源数据项全部copy到指定位置。
  (8)将资源数据copy到新节中,并修复数据项。
  (9)修复目录项信息。

注:由于资源表都是通过首个资源目录头定位数据的,而且都是在资源目录头之后,所以可以直接按照目录项中的资源大小,将资源从首个资源目录头开始全部copy到新节中。但是不推荐这样完成。

第三章:打造自己的PE解析器——全文总结

  文章写到这里,我们打造PE解析器的基础理论已经全部介绍完毕了,相信大家也都跟着文章完成了其中的练习题,那么接下来的事情就非常简单了,我们只要把所有的代码整合起来就可以实现一个简单PE解析器了,当然只是一个控制台程序。如果想要打造窗口程序就要具备一定的Windows编程经验了,然后将对应的内容输出出来就大功告成了。
  希望同学们完成这最后一个任务!

 

写了一个月的文章完成,第一次发帖语言上有点生涩,如果文章有错误的地方希望能够指出,谢谢!




[公告]安全服务和外包项目请将项目需求发到看雪企服平台:https://qifu.kanxue.com

最后于 2019-8-12 10:10 被QiuJYu编辑 ,原因:
上传的附件:
打赏 + 15.00
打赏次数 2 金额 + 15.00
收起 
赞赏  chengqiyan   +10.00 2019/08/27
赞赏  Crakme   +5.00 2019/07/25
最新回复 (32)
Lǎn māo 2019-7-24 10:37
2
0
 火速留名
Lixinist 1 2019-7-24 10:53
3
0
点赞&收藏
pxhb 2 2019-7-24 11:02
4
0
收藏备用
woaizhoulu 2019-7-24 11:23
5
0
mark
小调调 2019-7-24 15:20
6
0
很详细
少尉 2019-7-24 16:51
7
0
很好很详细,建议PDF化。
xmhwws 2019-7-24 19:51
8
0
这就是强者的世界吗?
小木鱼 2019-7-24 21:54
9
0
谢谢分享PE知识,已保存备看!
kingswb 2019-7-25 06:37
10
0
挺不错
wcfq 2019-7-25 09:28
11
0
太齐全了
JerryOne 2019-7-25 09:38
12
0
非常适合学习的文章,thx
请问这个是怎么定制出来的
C:¥Users¥RJP001>
QiuJYu 4 2019-7-25 10:00
13
0
JerryOne 非常适合学习的文章,thx 请问这个是怎么定制出来的 C:¥Users¥RJP001>
不是定制的,当时实习使用的日语操作系统
JerryOne 2019-7-25 10:19
14
0
QiuJYu 不是定制的,当时实习使用的日语操作系统
我说呢,prompt中找不到只替换路径中'\'符号的选项,话说日语操作系统提示符这么有个性,¥符号在日语中代表什么意义,不知道和money有没有关系,要是的话,那就满屏的money
JerryOne 2019-7-25 10:19
15
0
QiuJYu 不是定制的,当时实习使用的日语操作系统
我说呢,prompt中找不到只替换路径中'\'符号的选项,话说日语操作系统提示符这么有个性,¥符号在日语中代表什么意义,不知道和money有没有关系,要是的话,那就满屏的money
JerryOne 2019-7-25 10:27
16
1
第二个图中应该是ImageBase = VA - RVA吧
QiuJYu 4 2019-7-25 10:32
17
0
JerryOne 第二个图中应该是ImageBase = VA - RVA吧
嗯,我改下
zxxxxxxr 2019-7-25 20:35
18
0
感谢分享,今天刚好在研究加密与解密这一部分,结合理解
金奔腾 2019-7-26 10:48
19
0
还没看完就先赞一个,非常棒!
xxos 2019-7-29 19:25
20
0
我今天正想写一个帖呢~ 尽然发现有人写了~
天水姜伯约 3 2019-7-30 17:07
21
0
VirtualSize应该是区段的实际使用大小吧。
QiuJYu 4 2019-7-30 17:23
22
0
三十二变 VirtualSize应该是区段的实际使用大小吧。
对,只有文件载入内存后,才会使用这个字段
齐格弗里德 2019-7-31 13:07
23
0
非常感谢!
shuyangzjg 2019-8-2 16:26
24
0
够细
Kiopler 2019-8-3 06:16
25
0
感谢分享
天地豪迈 2019-8-3 08:35
26
0
写得很详细,容易看懂,谢谢分享。
wjllz 3 2019-8-3 09:19
27
0
非常感谢楼主的分享, 读的时候想把所有的赞都给楼主点上. thx
乙名 2019-8-11 19:37
28
0
感谢分享
starsli 2019-8-12 18:02
29
0
感谢分享
niuzuoquan 2019-8-12 22:02
30
0
mark
xingbing 2019-8-15 14:23
31
0
写的太详细了,图文并茂。有文档电子版吗?
chengqiyan 2019-8-27 22:52
32
0
写的好 支持支持支持 学习学习学习学习
黑冰Lisa 2019-9-5 20:53
33
0
顶一个,
游客
登录 | 注册 方可回帖
返回