五、Section Table(节表)
节表是紧挨着 PE Header 的一结构数组。该数组成员的数目由 File Header (IMAGE_FILE_HEADER) 结构中 NumberOfSections 域的域值来决定。节表成员结构又命名为 IMAGE_SECTION_HEADER(四十字节)。其结构定义:
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
IMAGE_SECTION_HEADER 结构成员含义:
1.IMAGE_SIZEOF_SHORT_NAME:不超过8字节的节名。节名仅是个标记,我们选择任何名字甚至空着也行,不能用null结束。命名不是一个ASCIIZ字符串,所以不用null结尾。
2.PhysicalAddress:指定文件地址。
3.VirtualSize:这个域的意义与程序类型有关。如果是EXE,代表当节被装入内存之后的大小总和,这是在它们被调整为最接近文件对齐粒度的倍数之前的大小。稍后的SizeOfRawData则是调整后的大小。对于OBJ文档,这个域没有意义。
4.VirtualAddress:本节的RVA(相对虚拟地址)。PE装载器将节映射至内存时会读取本值,因此如果域值是1000h,而PE文件装在地址400000h处,那么本节就被载到401000h。微软把第一个Section 的此域值设为0x1000h。对于OBJ文档,此域没意义,总为0。
5.SizeOfRawData:经过文件对齐处理后节尺寸,PE装载器提取本域值了解需映射入内存的节字节数。 假设一个文件的文件对齐尺寸是0x200,如果前面的 VirtualSize 域指示本节长度是0x388字节,则本域值为0x400,表示本节是0x400字节长。在obj中,这个与表示有编译器或 組譯器 指定的真正的section 大小。
6.PointerToRawData:这是本节基于文件的偏移量,PE装载器通过本域值找到节数据在文件中的位置。 如果你自己以内存映射的方式应设了一个PE程序(而不是由操作系统的装载器载入),那么你就必须根据此值找到本节的信息,而不是根据VirtualAddress 中的RVA值。
7.PointerToRelocations:在OBJs中,这是以程序开始为基准的偏移量,用来指向section 的重定位信息。每个OBJ section 的重定位信息紧跟在section 信息之后。在EXEs中,这个域(一记下一个域)没有意义,总是为0。但链接器产生一个EXE,它会决定大部分的待修正纪录(fixups),只剩下基址的重定位地址以及 imported 函数的重定位地址,留待载入时在解决。两份相同的信息放在 base relocation section 和imported function section 之中,所以EXEs 不需要在每一个 section 之后又有重定位信息。
8.PointerToLinenumbers:行号表的偏移量(以程序开始为基准)。行号表与源代码行号和其被映射到内存中的位置有关。在EXE文件中,行号信息被放在程序的最尾端。如果没有COFF行好,设为0。
9.NumberOfRelocations:重定位表格(由PointerToRelocations 指向)中的重定位项目的个数。此域只用于OBJ中。EXE中为0。
10.NumberOfLinenumbers:行号表格(由PointerToLinenumbers 指向)中的行号个数。
11.Characteristics:包含标记以指示节属性,比如节是否含有可执行代码、初始化数据、未初始数据,是否可写、可读等。 下面是一些标记:
IMAGE_SCN_TYPE_REG Reserved.
IMAGE_SCN_TYPE_DSECT Reserved.
IMAGE_SCN_TYPE_NOLOAD Reserved.
IMAGE_SCN_TYPE_GROUP Reserved.
IMAGE_SCN_TYPE_NO_PAD Reserved.
IMAGE_SCN_TYPE_COPY Reserved.
IMAGE_SCN_CNT_CODE Section contains executable code.
IMAGE_SCN_CNT_INITIALIZED_DATA Section contains initialized data.
IMAGE_SCN_CNT_UNINITIALIZED_DATA Section contains uninitialized data.
IMAGE_SCN_LNK_OTHER Reserved.
IMAGE_SCN_LNK_INFO Reserved.
IMAGE_SCN_TYPE_OVER Reserved.
IMAGE_SCN_LNK_COMDAT Section contains COMDAT data.
IMAGE_SCN_MEM_FARDATA Reserved.
IMAGE_SCN_MEM_PURGEABLE Reserved.
IMAGE_SCN_MEM_16BIT Reserved.
IMAGE_SCN_MEM_LOCKED Reserved.
IMAGE_SCN_MEM_PRELOAD Reserved.
IMAGE_SCN_ALIGN_1BYTES Align data on a 1-byte boundary.
IMAGE_SCN_ALIGN_2BYTES Align data on a 2-byte boundary.
IMAGE_SCN_ALIGN_4BYTES Align data on a 4-byte boundary.
IMAGE_SCN_ALIGN_8BYTES Align data on a 8-byte boundary.
IMAGE_SCN_ALIGN_16BYTES Align data on a 16-byte boundary.
IMAGE_SCN_ALIGN_32BYTES Align data on a 32-byte boundary.
IMAGE_SCN_ALIGN_64BYTES Align data on a 64-byte boundary.
IMAGE_SCN_LNK_NRELOC_OVFL Section contains extended relocations.
IMAGE_SCN_MEM_DISCARDABLE Section can be discarded as needed.
IMAGE_SCN_MEM_NOT_CACHED Section cannot be cached.
IMAGE_SCN_MEM_NOT_PAGED Section cannot be paged.
IMAGE_SCN_MEM_SHARED Section can be shared in memory.
IMAGE_SCN_MEM_EXECUTE Section can be executed as code.
IMAGE_SCN_MEM_READ Section can be read.
IMAGE_SCN_MEM_WRITE Section can be written to.
遍历节表的步骤:
1.PE文件有效性校验。
2.定位到 PE Header 的起始地址。
3.从 file Header 的 NumberOfSections域获取节数。
4.通过两种方法定位节表: ImageBase+SizeOfHeaders 或者 PE header的起始地址+ PE header结构大小。 (节表紧随 PE Header)。如果不是使用文件映射的方法,可以用SetFilePointer 直接将文件指针定位到节表。节表的文件偏移量存放在 SizeOfHeaders域里(SizeOfHeaders 是 IMAGE_OPTIONAL_HEADER 的结构成员) 。
5.处理每个 IMAGE_SECTION_HEADER 结构。
六、Import Table(导入表)
6.1、导入函数:
一个导入函数是被某模块调用的但又不在调用者模块中的函数,因而命名为"import(导入)"。导入函数实际位于一个或者更多的DLL里。调用者模块里只保留一些函数信息,包括函数名及其驻留的DLL名。
PE 程序被载入到内存之前,存放在 PE 文件的 .data 中的内容是给装载器用来决定函数位置并修补它们以便完成image 用的。而在被载入之后,.idata内含有的是指向 EXE/DLL 的导入函数的指针。
6.2、Data Directory:
Data Directory 是一个 IMAGE_DATA_DIRECTORY 结构数组,共有16个成员。Data Directory 包含了PE文件中各重要数据结构的位置和尺寸信息。 每个成员包含了一个重要数据结构的信息。
Data Directory 的每个成员都是 IMAGE_DATA_DIRECTORY 结构类型的,其定义如下所示:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
IMAGE_DATA_DIRECTORY 结构成员含义:
1.VirtualAddress: 实际上是数据结构的相对虚拟地址(RVA)。比如,如果该结构是关于Import Symbols的,该域就包含指向IMAGE_IMPORT_DESCRIPTOR 数组的RVA。
2.Size: 含有VirtualAddress所指向数据结构的字节数。
6.3、找寻PE文件中重要数据结构的一般方法:
1、从 DOS Header 定位到 PE Header。
2、从 Optional Header 读取 Data Directory 的地址。
3、IMAGE_DATA_DIRECTORY 结构尺寸乘上找寻结构的索引号:比如您要找寻Import Symbols的位置信息,必须用IMAGE_DATA_DIRECTORY 结构尺寸(8 bytes)乘上1(Import Symbols 在 Data Diectory 中的索引号)。
4、将上面的结果加上 Data Diectory 地址,我们就得到包含所查询数据结构信息的 IMAGE_DATA_DIRECTORY 结构项。
6.4、导入表:
Data Directory 数组第一项的 VirtualAddress 包含导入表地址。导入表实际上是一个 IMAGE_IMPORT_DESCRIPTOR 结构数组。每个结构包含PE文件导入函数的一个相关DLL的信息。该数组以一个全0的成员结尾。
IMAGE_IMPORT_DESCRIPTOR结构组成:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
};
DWORD TimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)
DWORD ForwarderChain; // -1 if no forwarders
DWORD Name;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
IMAGE_IMPORT_DESCRIPTOR 结构成员含义:
1.结构第一项是一个union子结构。事实上,这个union子结构只是给 OriginalFirstThunk 增添了个别名,您也可以称其为"Characteristics"。该成员项含有指向一个 IMAGE_THUNK_DATA 结构数组的RVA。
2.TimeDateStamp:程序生成的时刻。此域通常为0。微软的 BIND 程序可以将此 IMAGE_IMPORT_DESCRIPTOR 所对应的dll的生成时刻写到这里来。
3.ForwarderChain:此域涉及到 forwarding(转交),意味着一个dll 函数在调用另一个 dll。例如,在 WINNT 中,Kernel32.dll 将它的某些输出函数转交给 NTDLL.dll。应用程序可能以为它调用 Kernel32.dll,而事实上它调用的事NTDLL.dll。这个域中含有一个索引,指向 FirstThunk 数组。被这个索引所指定的函数就是一个转交函数。
3.Name:含有指向DLL名字的RVA,即指向DLL名字的指针,也是一个ASCII字符串。
4.FirstThunk:与 OriginalFirstThunk 非常相似,它也包含指向一个 IMAGE_THUNK_DATA 结构数组的RVA(当然这是另外一个IMAGE_THUNK_DATA 结构数组)。
IMAGE_IMPORT_DESCRIPTOR 数组中,最重要的部分是 imported DLL 的名称以及两个 IMAGE_THUNK_DATA 数组。每个 IMAGE_THUNK_DATA 对应一个导入函数。在exe中,两个数组(分别由 Characteristics 和 FirstThunk 域指向)平行存在,并且都以 NULL 位结束符。
为什么需要两个平行数组?第一个数组(由 Characteristics 指向)从不被修改,有时它被称为 hint-name table。第二个数组(由 FirstThunk 指向)则被装载器改写。装载器一一检查每一个 IMAGE_THUNK_DATA 并且找出它所记录的函数的地址,然后把地址写入 IMAGE_THUNK_DATA 这个 DWORD 之中。由于这个 IMAGE_THUNK_DATA 数组内容已经被装载器改写为输入函数的地址,所以它又被叫做 Import Address Table(IAT)。IAT 是一个可写区域。API Hook 就利用到这一特性。PE装载器载入PE后,FirstThunk 指向的 IMAGE_THUNK_DATA 被改写,而 Characteristics 所指向的 IMAGE_THUNK_DATA 没有被改写。所以若还反过头来查找导入函数名,PE装载器还能够根据 Characteristics 所指向的 IMAGE_THUNK_DATA 找寻到函数名。
6.4、IMAGE_THUNK_DATA:
IMAGE_THUNK_DATA是一个DWORD类型的集合。通常我们将其解释为指向一个 IMAGE_IMPORT_BY_NAME 结构的指针。注意 IMAGE_THUNK_DATA 包含了指向一个 IMAGE_IMPORT_BY_NAME 结构的指针,而不是结构本身。
IMAGE_THUNK_DATA 结构定义:
typedef struct _IMAGE_THUNK_DATA32 {
union {
PBYTE ForwarderString;
PDWORD Function;
DWORD Ordinal;
PIMAGE_IMPORT_BY_NAME AddressOfData;
} u1;
} IMAGE_THUNK_DATA32;
IMAGE_THUNK_DATA 实在PE被载入之后才被决定的。WIN32装载器使用 IMAGE_THUNK_DATA 的初始内容(可能是函数名称也可能是函数序号)来寻找输入函数的位置。然后装载器就以获得的地址改写 IMAGE_THUNK_DATA 的内容。
6.5、IMAGE_IMPORT_BY_NAME:
IMAGE_IMPORT_BY_NAME 结构定义:
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
1.Hint:指示本函数在其所驻留DLL的导出表中的索引号。该域被PE装载器用来在DLL的导出表里快速查询函数。该值不是必须的,一些连接器将此值设为0。
2.Name:含有导入函数的函数名。函数名是一个ASCII字符串。注意这里虽然将Name的大小定义成字节,其实它是可变尺寸域,只不过我们没有更好方法来表示结构中的可变尺寸域。这个结构被提供用于查阅描述名字的结构。
有些情况下一些函数仅由序数导出,也就是说不能用函数名来调用它们,只能用它们的位置来调用。此时,调用者模块中就不存在该函数的 IMAGE_IMPORT_BY_NAME 结构。不同的,对应该函数的 IMAGE_THUNK_DATA 值的低位字指示函数序数,而最高二进位 (MSB)设为1。例如,如果一个函数只由序数导出且其序数是1234h,那么对应该函数的 IMAGE_THUNK_DATA 值是80001234h。Microsoft提供了一个方便的常量来测试dword值的MSB位,就是 IMAGE_ORDINAL_FLAG32,其值为80000000h。
6.6、列出某个PE文件的所有导入函数步骤:
1、校验文件是否是有效的PE。
2、从 DOS Header 定位到 PE Header。
3、获取位于 OptionalHeader 数据目录地址。
4、转至数据目录的第二个成员提取其VirtualAddress值。
5、利用上值定位第一个 IMAGE_IMPORT_DESCRIPTOR 结构。
6、检查 OriginalFirstThunk值。若不为0,顺着 OriginalFirstThunk 里的RVA值转入那个RVA数组。若 OriginalFirstThunk 为0,就改用FirstThunk值。有些连接器生成PE文件时会置OriginalFirstThunk值为0,这应该算是个bug。不过为了安全起见,我们还是检查 OriginalFirstThunk值先。
7、对于每个数组元素,我们比对元素值是否等于IMAGE_ORDINAL_FLAG32。如果该元素值的最高二进位为1,那么函数是由序数导入的,可以从该值的低字节提取序数。
8、如果元素值的最高二进位为0,就可将该值作为RVA转入 IMAGE_IMPORT_BY_NAME 数组,跳过 Hint 就是函数名字了。
9、再跳至下一个数组元素提取函数名一直到数组底部(它以null结尾)。现在我们已遍历完一个DLL的导入函数,接下去处理下一个DLL。
10、即跳转到下一个 IMAGE_IMPORT_DESCRIPTOR 并处理之,如此这般循环直到数组见底。(IMAGE_IMPORT_DESCRIPTOR 数组以一个全0域元素结尾)。
6.7、Bound Import:
当PE装载器装入PE文件时,检查导入表并将相关DLLs映射到进程地址空间。然后象我们这样遍历IMAGE_THUNK_DATA 数组并用导入函数的真实地址替换IMAGE_THUNK_DATAs 值。这一步需要很多时间。如果程序员能事先正确预测函数地址,PE装载器就不用每次装入PE文件时都去修正IMAGE_THUNK_DATAs 值了。Bound import就是这种思想的产物。
Microsoft 出品的类似Visual Studio的编译器多提供了bind.exe这样的工具,由它检查PE文件的导入表并用导入函数的真实地址替换IMAGE_THUNK_DATA 值。当文件装入时,PE装载器必定检查地址的有效性,如果DLL版本不同于PE文件存放的相关信息,或则DLLs需要重定位,那么装载器认为原先计算的地址是无效的,它必定遍历OriginalFirstThunk指向的数组以获取导入函数新地址。
七、Export Table(导出表)
当PE装载器执行一个程序,它将相关DLLs都装入该进程的地址空间。然后根据主程序的导入函数信息,查找相关DLLs中的真实函数地址来修正主程序。PE装载器搜寻的是DLLs中的导出函数。PE 程序把它的导出函数相关信息放在.edata 中。
DLL/EXE要导出一个函数给其他DLL/EXE使用,有两种实现方法: 通过函数名导出或者仅仅通过序数导出。比如某个DLL要导出名为"GetSysConfig"的函数,如果它以函数名导出,那么其他DLLs/EXEs若要调用这个函数,必须通过函数名,就是GetSysConfig。另外一个办法就是通过序数导出。序数是唯一指定DLL中某个函数的16位数字,在所指向的DLL里是独一无二的。例如在上例中,DLL可以选择通过序数导出,假设是16,那么其他DLLs/EXEs若要调用这个函数必须以该值作为GetProcAddress调用参数。这就是所谓的仅仅靠序数导出。
7.1 导出表是数据目录的第一个成员,又可称为 IMAGE_EXPORT_DIRECTORY。结构定义:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
IMAGE_EXPORT_DIRECTORY 结构成员含义:
1.Characteristics:此域没有用途,总是为0。
2.TimeDateStamp:程序被生成的时刻。
3.MajorVersion/MinorVersion:无实际用途,0。
4.Name:一个 RVA 值,指向一个 ASCIIZ 字串(dll 名称,如MYDLL.dll)。模块的真实名称。本域是必须的,因为文件名可能会改变。这种情况下,PE装载器将使用这个内部名字。
3.Base:基数,加上序数就是函数地址数组的索引值了。
4.NumberOfFunctions:模块导出的函数/符号总数。
5.NumberOfNames:通过名字导出的函数/符号数目。该值不是模块导出的函数/符号总数,这是由上面的NumberOfFunctions给出。本域可以为0,表示模块可能仅仅通过序数导出。如果模块根本不导出任何函数/符号,那么数据目录中导出表的RVA为0。
6.AddressOfFunctions:模块中有一个指向所有函数/符号的RVAs数组,本域就是指向该RVAs数组的RVA。简言之,模块中所有函数的RVAs都保存在一个数组里,本域就指向这个数组的首地址。
7.AddressOfNames:类似上个域,模块中有一个指向所有函数名的RVAs数组,本域就是指向该RVAs数组的RVA。
9.AddressOfNameOrdinals:RVA,指向包含上述 AddressOfNames数组中相关函数之序数的16位数组。
导出表的设计是为了方便PE装载器工作。
首先,模块必须保存所有导出函数的地址以供PE装载器查询。模块将这些信息保存在AddressOfFunctions域指向的数组中,而数组元素数目存放在NumberOfFunctions域中。 因此,如果模块导出40个函数,则AddressOfFunctions指向的数组必定有40个元素,而NumberOfFunctions值为40。
现在如果有一些函数是通过名字导出的,那么模块必定也在文件中保留了这些信息。这些名字的RVAs存放在一数组中以供PE装载器查询。该数组由AddressOfNames指向,NumberOfNames包含名字数目。考虑一下PE装载器的工作机制,它知道函数名,并想以此获取这些函数的地址。至今为止,模块已有两个模块: 名字数组和地址数组,但两者之间还没有联系的纽带。因此我们还需要一些联系函数名及其地址的东东。PE参考指出使用到地址数组的索引作为联接,因此PE装载器在名字数组中找到匹配名字的同时,它也获取了指向地址表中对应元素的索引。而这些索引保存在由AddressOfNameOrdinals域指向的另一个数组(最后一个)中。由于该数组是起了联系名字和地址的作用,所以其元素数目必定和名字数组相同,比如,每个名字有且仅有一个相关地址,反过来则不一定: 每个地址可以有好几个名字来对应。因此我们给同一个地址取"别名"。为了起到连接作用,名字数组和索引数组必须并行地成对使用,譬如,索引数组的第一个元素必定含有第一个名字的索引,以此类推。
7.2 如果我们有了导出函数名并想以此获取地址,可以这么做:
1、定位到PE Header。
2、从数据目录读取导出表的虚拟地址。
3、定位导出表获取名字数目(NumberOfNames)。
4、并行遍历AddressOfNames和AddressOfNameOrdinals指向的数组匹配名字。如果在AddressOfNames 指向的数组中找到匹配名字,从AddressOfNameOrdinals 指向的数组中提取索引值。例如,若发现匹配名字的RVA存放在AddressOfNames 数组的第77个元素,那就提取AddressOfNameOrdinals数组的第77个元素作为索引值。如果遍历完NumberOfNames 个元素,说明当前模块没有所要的名字。
5、从AddressOfNameOrdinals 数组提取的数值作为AddressOfFunctions 数组的索引。也就是说,如果值是5,就必须读取AddressOfFunctions 数组的第5个元素,此值就是所要函数的RVA。
7.3 假设我们只有函数的序数,那么怎样获取函数地址呢,可以这么做:
1、定位到PE Header。
2、从数据目录读取导出表的虚拟地址。
3、定位导出表获取nBase值。
4、减掉nBase值得到指向AddressOfFunctions 数组的索引。
5、将该值与NumberOfFunctions作比较,大于等于后者则序数无效。
6、通过上面的索引就可以获取AddressOfFunctions 数组中的RVA了。