回顾前文 PE文件和COFF文件格式分析(1),并以典型的 msvcp80.dll 来分析。
文件最开始是一个0x40字节的结构。
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
根据该结构中最后的 e_lfanew 知道下一个结构的地址是 0xf0。简单的可以从文件开头找PE 两个字符也可以。该结构体是:
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
可以看到前面2个成员是一样的,不区分32.64位,一共0x18个字节。
再次,按上图看到2个红色框出来的标记,0x010b 说明这是一个32位结构,也就是IMAGE_OPTIONAL_HEADER32 ( 如果是 0x020b 就是 IMAGE_OPTIONAL_HEADER64 )。0x00e0 是SizeOfOptionalHeader 的值。其中 IMAGE_OPTIONAL_HEADER32 如下:
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.
//
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
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;
可以看到大小是 96 字节+ 一个数组,如下图红色线之间就是96字节,绿色后就是连续 8 * 16 个结构。数组长度就是16。为什么是16个?因为前面说到了:(0x00e0 是SizeOfOptionalHeader 的值)
根据如下定义,知道绿色部分就是导出表的位置信息:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (X86 usage)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 // COM Runtime descriptor
VirtualAddress = 0x03efa0;(其实这里是虚拟地址,并不是文件的地址,但是太巧了,我学习时候用的这个DLL地址恰好虚拟地址和文件地址是一样的!!,因此就数据分析是没错的,先分析数据,稍后再说地址应该怎么寻找!)
如下图,发现 msvcp80.dll 导出表的虚拟地址和实际地址是一样的!
Size = 03ea4c
跳到位置 0x03efa0 , 如下:
导出表的结构如下:
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;
Characteristics是保留字段,要求为0。
TimeDataStamp保存的生成导出信息的时间。
MajorVersion和MinorVersion分别是主版本号和此版本号。这些信息是我们可以决定的。
Name字段保存的该导出文件的名称的偏移。这儿要注意一点,这个地址是系统不关心的,我们可以将其指向的地址设置为违法的地址,这样会干扰部分PE分析工具的分析结果。
Base是导出函数的起始序数值,该值一般为1。如我们用View dependencies打开一个文件,红色部分就是Base字段相关的
NumberOfFunctions标志导出函数的函数地址数。该数据是非常重要的,我们要知道该文件导出了多少个函数就是要依据这个信息。我们之后会详细说的。
NumberOfNames标志导出函数的函数名数量。
AddressOfFunctions标志导出函数的函数地址表的RVA。
AddressOfNames标志导出函数的函数名表的RVA。
AddressOfNameOrdinals标志导出函数的导出序数表的RVA。
上面的数据图中可以看到绿色的划线,就是如下两个数据的值。都是 0x0c6a (3178)。这是导出函数的总数。用 dependency 也可以确认这个数量。
DWORD NumberOfFunctions;
DWORD NumberOfNames;
标出各个字段的值(按顺序对应):
上面说到虚拟地址和文件地址恰好一样的问题,但往往他们是不一样的。又如何查找呢?这里插入查找的方法:
================================================================================
以 msvcp120.dll 为例,先找到PE头后面的 .rdata 节描述
用PEView.exe 查看更直观
贴上 seciton 的定义:
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;// 这里看的就是这三个 0x55000
DWORD SizeOfRawData;//
DWORD PointerToRawData;// 0x54400
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
观察两个数据 0x55000 0x54400 可以知道。该文件虚拟地址相对文件地址大 (0x55000 - 0x54400) = 0xc00
上面图中标红了 DataDirectory[0] 的数据,也就是导出表的信息。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
可以看到导出表的 VirtualAddress = 0x075570 Size = 0x01dd20。
上面说到有一个 0xc00 的偏差(对本文件来说)。因此实际地址就是 0x075570 - 0xc00 = ?
可以看到完全一致!
跳过去看看:
总结下如何查找导出表实际地址。通过 .rdata 基本段信息知道虚拟地址和文件地址的偏差数。然后就知道导出表的虚拟地址和文件地址如何换算了。参考 PE文件和COFF文件格式分析——RVA和RA相互计算
同时也不难想象,其他节一样存在类似的转换运算,也就是上面的”偏差数“不是针对文件里面的所有偏差的。而是分类型。
================================================================================
用自己写的一个有2个导出函数的DLL看看数据:
下图可以看出来”差值“是 0x1000。
针对该DLl,对应结构体把数据写出来:
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 0x1a78e8 ->0x1a68e8
DWORD AddressOfNames; // RVA from base of image 0x1a78f0 ->0x1a68f0
DWORD AddressOfNameOrdinals; // RVA from base of image 0x1a78f8 ->0x1a68f8
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
上面 0x1a78e8 ->0x1a68e8 分别是虚拟地址和文件地址。差值是 0x1000。(记住这里,稍后使用)
初次研究这个结构的同学可能会注意一个问题,该结构中有三个表的RVA(AddressOfFunctions,AddressOfNames,AddressOfNameOrdinals),而只给出了其中前两个表的元素个数(NumberOfFunctions,NumberOfNames)。那第三个表——导出序数表的个数是多少?是按导出函数地址表(AddressOfFunctions)中元素个数(NumberOfFunctions)还是按导出函数名称表(AddressOfNames)中元素个数(NumberOfNames)?还有个问题:为什么要设置Base属性?这些问题我们先Mark下。我们先来详细介绍这三个表。
导出地址表(AddressOfFunctions)。
顾名思义,该表中保存了函数入口RVA。但是如果仅仅是如此简单就好了,这个地方保存的还可能是一个指向字符串的RVA!其结构是以下结构体的一个集合。
// 导出表信息
typedef struct _IMAGE_Export_Address_Table_
{
union {
DWORD dwExportRVA;
DWORD dwForwarderRVA;
};
}IMAGE_Export_Address_Table, *pIMAGE_Export_Address_Table;
上面说到:DWORD AddressOfFunctions; // RVA from base of image 0x1a78e8 ->0x1a68e8
如果它保存的是导出函数入口地址,那没什么好说的。我们说下它保存的是指向一个字符串的偏移的情况。在我的XP系统下Kernel32.dll中AddVectoredExceptionHandler函数的导出函数地址指向的字符串是NTDLL.RtlAddVectoredExceptionHandler。看到这样的名字组合,我想你大概能猜出个眉目。AddVectoredExceptionHandler函数,在Kernel32.dll文件内部是没有实现的。但是如果有程序需要加载Kernel32.dll并需要调用这个函数,则这样的写法会告诉加载器在加载Kernel32.dll时,要将AddVectoredExceptionHandler函数的地址直接改成Ntdll.dll中的RtlAddVectoredExceptionHandler函数地址(即自动加载Ntdll.dll)。这个特性非常有趣吧!我想做加壳的朋友应该对这个场景很熟悉。我之后会介绍利用这个特性去隐性自动加载DLL。最后说一下,我们如何辨别这个字段保存的是函数的入口地址的RVA还是字符串呢?只要判断该偏移不在导出表节中即可:指向的地址在节中就是字符串的RVA;在节外是函数入口的RVA。
看实际数据 0x1a68e8 地址的数据
得到两个函数的地址 0x00056820 0x00056840。
导出名称表(AddressOfNames)。
计算机做出来是给人用的,如果给人一堆010101这样的数据,我想没谁会有太多兴趣去看的。于是出于人性化考虑,人们发明了别名,比如发明了汇编映射二进制指令,从而帮助理解程序逻辑。导出名称表就是出于这样的考虑而设计的。其结构是以下结构体的一个集合。
typedef struct _IMAGE_Export_Name_Pointer_Table_ {
DWORD dwPointer;
}IMAGE_Export_Name_Pointer_Table,*pIMAGE_Export_Name_Pointer_Table; 0x1a78f0 ->0x1a68f0
它是指向字符串的RVA,该字符串是以\0结尾的。一共有2个字符串(NumberOfNames = 2)。前面提到有导出的总数。
如上分别有红色和绿色的两个地址,减去偏差指向的就是导出函数名称。
同时发现者附近有一个ProxyDll.dll。也就是前面说到的 Name 。这个字符串的地址是: 0x1a68fc。加 0x1000就是 0x1a78fc。
如下图:
再以 msvcp80.dll 为例:
前面提到过 msvcp80.dll 这个地址没有偏差,直接就是 0x046bec。
说到这儿,我觉得我们可以停下思考一个问题,是不是只要有这两个表就够了?如果对于我们自己编写的且非常标准的DLL,只要有这两个表的确是够了。你想,当我们调用GetProcAddress时,我们在导入名称表中找到该名称对应的index,然后再返回导出函数地址表中该index的数据即可。
lpFunc = ExportAddressTable[ExportNameTable.find(FuncName)]
但是,PE文件设计的远没有这么简单。如果如此简单,那很多事都好办了。举一个特殊的例子来推翻这种简单的场景: 函数入口地址和函数名之间的关系是1对N(0~n)。我们程序运行起来后,很多时候是要调用其他逻辑,即函数入口。可以说一个函数入口可以唯一标注一个逻辑。而我们经常说的某某API,其实只是某个函数过程的一个名字。比如我们一个实现XML解析的函数,我们可以叫做ParseXML,也可以叫XMLParse。不管是叫哪个名字,该函数的功能是不变的,它的入口地址是不变的。如果入口地址变了,那就是另外一个函数了。这就是为什么说函数入口地址和函数名之间是1对N的关系。
针对以上问题,可能有人会想到,有多少个导出函数名(以导出函数名的数量为标准)就设置多少个导出地址,导出地址表中数据可以重复,比如上图中ParseXML和XMLParse函数名对应的导出地址都设置成0xXXXXXXXX就行了嘛。如
但是还有个场景:windows平台可以通过序数导入一个函数地址(GerProcAddress的第二个参数传序数),那么这就意味着函数可以没有函数名!!因为序数也可以看成一个函数的编号嘛,虽然这样非常不友好,但是仍然是一种可行的方法。那么如果在这种场景下,我们还能以导出函数名的数量为标准么?不可以了吧,因为函数名表元素数量可能是0!其实这类文件挺多,如mfc40u.dll,见下图
通过以上分析,我们可以得出,我们还是要一个能在导出函数地址表和导出函数名称表建立纽带的结构体。这个我们期待的辅助结构体就是我们下面介绍的导出序数表。
总结一下为什么两个表不够,还需要导出序数表:第一可能N个地址,N+M个名称,于是需要一个明确的N+M对应到N个地址的方法。第二,Windows 允许直接用序数访问。
导出序数表(AddressOfNameOrdinals)。
该表保存的是导出地址表的序数偏移!切记这个重要的概念。那这个偏移是相对什么偏移的呢?是针对IMAGE_EXPORT_DIRECTORY::Base属性的。即这个表中保存的值加上Base,就是导出地址表的序数。其结构是以下结构体的一个集合(也就是 WORD[N])。
typedef struct _IMAGE_Export_Ordinal_Table_ {
WORD dwOrdinal;
}IMAGE_Export_Ordinal_Table,*pIMAGE_Export_Ordinal_Table;
从这个表的命名(AddressOfNameOrdinals )看,应该可以发现这个表应该和导出名称表存在一定的关系!是的,它的元素的数量和导出名称表的元素数量是一样的。可能有人会疑问,什么这个表元素的个数不是和导出地址表元素个数一致呢?因为如上面所说,一个函数过程可以对应多个函数名,如果导出序数表元素个数和导出函数地址表元素个数一样,则无法让地址与函数名对应上。比如我们导出地址表有1个函数入口,而我们有2个函数名都指向这个地址,那么导出序数表个数如果是1,则如何表示这两个名称与函数入口的对应呢?如果导出序数表格式是2个,则我们可以让这两个元素都“指向”同一个导出函数入口即可。OK,这儿我就解答了上面我们Mark过的那个问题:导出序数表个数和导出名称表个数一致。
那么这三个表之间具体什么关系呢?我首先以一个简单的、常规的文件为例,这个文件是上面提到的msvcp80.dll。我们看一下View Dependencies的分析结果:
我们再把它的PE文件拿出来看下
我们把各个信息提取出来看下:
Characteristics; 0x00000000
TimeDateStamp; 0x457122c8
MajorVersion; 0x0000
MinorVersion; 0x0000
Name; 0x00046bec
Base; 0x00000001
NumberOfFunctions; 0x00000c6a = 3178
NumberOfNames; 0x00000c6a
AddressOfFunctions; 0x0003efc8
AddressOfNames; 0x00042170
AddressOfNameOrdinals; 0x00045318
可以看到这个Dll的导出地址表有3178个元素,导出名称表和导出序数表也是有3178个元素的。用之前《PE文件和COFF文件格式分析——RVA和RA相互计算》介绍的算法(或前文说到的偏差值,这里msvcp80.dll 这些数据的偏差位0,请注意),我们可以得出
导出地址表RVA(0x0003efc8)对应的RA是0x0003efc8。一共连续3178个元素分别为{ {0,0x01e1dc},{1,0x01e0c4}}…和View Dependencis分析结果对比发现,这组数据是一致的。如下图:
导出名称表上面分析 pryxodll.dll 已经分析了,此处省略。
导出序数表RVA(0x45318)对应的RA是 0x45318,其数据是{{0,0x0000},{1,0x0001}}…但是这并不是最终数据,刚才我在介绍导出序数表时,说过这个表保存的是相对Base的偏移,该文件的Base是1,于是真实的数据是{{0,0x0001},{1,0x0002}}。
如下图:
可以看到从 1.2.3…再看最后一个,理论上是 3178 - Base(1) = 3177 = 0x0c69
如图:
可以看到0c69 之后就是 msvcp80.dll。(这个名称前面有提到)
我们用图来说一下这三者的关系。
有关 Base 作用及相关问题,可以参考 PE文件和COFF文件格式分析——导出表