在上一篇文章里,我使用一个 TreeList 控件,展示了 PE 文件的内容。在那里可充分了解PE的文件头的信息,但是对 section(备注:常见译文为节,段,块)的一些信息我们还没有涉及。比如全局变量等数据,代码,资源,导入表等信息都位于相应的 section 中,有些 section 通常具有特定的名字,例如资源通常位于 .rsrc,代码通常位于 .text,导入表通常位于 .idata 段,等等。文本讲述的是把一个PE文件的导入表打印出来。我注意到 MS 提供了一个比较有用的函数,ImageRvaToVa,我们稍后主要借助这个函数去从RVA定位我们的目标数据。
在开始之前,我们先做一些概念的定义和说明。
Image: PE格式镜像文件,这通常就是我们的exe,dll文件。
下面我们定义一些地址相关的概念,因为PE文件位于磁盘上,同时文件又可以被映射到虚拟内存中,在运行PE文件时它也被系统的Loader加载到内存中。所以这里就有了三个空间,如果我们不做一个清楚的说明,在后面我们很容易混淆。
(1)磁盘空间:这里我们使用的地址叫做文件地址(距离文件头部的偏移)。在PE头的相关属性名称中,文件中的数据称为原始数据 (rawData),文件中的数据使用的对齐称为 FileAlignment。
(2)虚拟内存空间:在这里的地址称为虚拟地址(Virtual Address)。同时PE文件的数据装载/映射到内存中后又分以下两种情况:
(a)PE文件的内存视图,即PE文件被映射到内存(MapViewOfFile):
BaseAddress:内存映射文件的基础地址,从这里看过去,就和从编辑器打开看到的文件内容完全一致。
内存映射通常是处理大文件的一种有效方式。映射后我们在内存中看到的内容就是磁盘文件的一个视图。
因此我们吧PE文件映射到内存以后,通过某个数据的RVA,调用 ImageRvaToVa 可得到某个数据的VA,再减去映射文件的起始地址,就是文件地址。
(b) 进程空间,即在被调度之前,被loader装载到内存的时刻(例如双击执行一个exe)。
这里和前者的视图方式不同,属于一种地址映射关系,文件中的节内容根据NT文件头的信息被加载到进程空间的相应位置。
ImageBase:映射到进程空间的基础地址。
RVA:相对ImageOfBase的偏移。它加上ImageBase就是进程空间的VA。
在PE文件中的DataEntry,Section表中的VirutalAddress基本都属于RVA。
下面介绍以下这个函数:ImageRvaToVa:(注意这个函数要求XP和win2000系统以上,在VC6自带的SDK中没有。。。)
PVOID ImageRvaToVa(
PIMAGE_NT_HEADERS NtHeaders,
PVOID Base,
ULONG Rva,
PIMAGE_SECTION_HEADER* LastRvaSection
);
这个函数在(2).(a)即内存映射文件后使用,它把RVA根据NT头的信息,换算成内存映射文件中的实际VA。看起来不是在进程空间使用的,因为在进程空间中,ImageBase + Rva 就是VA了,装载后NT头等信息也不再重要了。最后一个参数是可选的,或许是因为如果调用方主动提供,此 API 可提高一定效率(可以直接在节中的地址信息去判断)。第二个参数也可以提供一个假的地址给这个函数,这个函数也能计算。即这个函数不去校验Base是否是一个有效地址,因此实际上我们可以读取出NtHeader的信息后,用一个假地址传给这个函数,再把结果减去这个假地址,即可换算出文件地址。
好了,下面介绍下导入表的定位,这方面的资料和文章在看雪论坛的文集里面有很多。我再这里只做一个比较简洁的介绍。导入表本质上就是位于某个节中的一些数据,这些数据主要是一些C字符串(以0字符为结尾的dll和函数名称)以及一些指针(RVA地址),所以我们主要是需要了解如何定位到导入表,从而打印出导入表的信息。
首先导入表的RVA地址,就在optional Header的DataDirectory的第二个元素中。通过它我们定位到导入表。
导入表类似一个二级索引。一级是一个模块目录(IMAGE_IMPORT_DESCRIPTOR数组,这里把目录理解为一个以全0字节为结束的数组),它的每个元素代表了一个DLL。二级是导入地址表IAT(即 IMAGE_THUNK_DATA 数组,一个指针数组),每个元素指向一个 IMAGE_IMPORT_BY_NAME结构(该结构含有一个函数序号和一个函数名称字符串)。
总结一下,我们的定位过程:
(1)通过 NtHeaders.OptionalHeader.DataDirectory[1].VirtualBase --> 定位到导入表(IID Table)。
(2)遍历每个 IID,直到遇到全0为止。
通过 IID.Name -> 定位到 DLL 名字。
通过 IID.OriginalFirstThunk 或者 FirstThunk -> 定位到IAT ( image_thunk_data32[] );
遍历指针目录,知道遇到NULL为止。
通过 thunk_data.AddressOfData -> 定位到一个 IMAGE_IMPORT_BY_NAME 的地址,再根据它寻址到真正的函数序号和函数名称。
为什么存在二级指针呢,这是很容易解释的。所以我们需要一个DLL目录,由于DLL名称的长度和函数个数不固定,所以向下扩展了一级,而每个函数的函数名称又是不固定的,所以又要向下扩展一级,这样要找到真正的函数名称必须经过这样两级定向。
因此这个二级索引的导入表就是这样的定位方式(每个数组都是一个高地址方向半开口的样子, C字符串也是这样的字符数组),如下图所示(注意每个数组的元素的size是固定的,但由于数组是半开口,所以数组本身属于size不固定):
特点就是,每次遇到长度无法预测的成员,就用指针把它从元素中扩展出去(用一个指针指向它),这样我们就保证每个数组的元素都是固定的size,这样它才能成为线性表结构(满足用指针的加减或者[]操作符进行元素读取)。例如DLL的名称是可变长度的,因此它被扔到元素定义的外面去,在元素中保留为一个指针。每个DLL的函数目录也是可变长度的(函数个数是不确定的),因此它在元素中也是一个指针。而函数目录中函数信息又被扔出去,用指针指向它。
下面看一下相关代码,很短,通过上面的讲解,下面的代码就很明了,很简单了:
【注意】在上面的代码中我标示为高亮红色字体的部分,我使用的是 OriginalFirstThunk ,对于没有事先绑定的PE文件来说, OriginalFirstThunk 和 FirstThunk 是并行的内容相同的数组。但是对于已经经过绑定的PE文件来说, FirstThunk 数组中的元素会被设置成真正的函数地址(VA)!因此如果这时用 FirstThunk 数组尝试获取函数名称是得不到的。所以上面的代码应该使用 OriginalFirstThunk ,这样无论对于绑定还是未绑定的PE文件,都能够定向到相应的函数名称。
【注意】:
(1)请在项目属性中的链接选项中,添加 Dbghelp.lib;
(2)上面对PE文件的名称采用了硬编码,如果为了灵活期间,可以从命令行获取PE文件的路径。
下面是我用某个PE文件所产生的输出:
到这里,我们基本算了解了import table。不过还需要额外解释一下的是,在iid中含有两个指向thunk数组(即IAT:导入地址表)的指针(orignal first thunk 和 first thunk),两个指针指向不同的地址,但这两个thunk数组的元素值是相同的,最终指向的是同一个地址(函数序号和名称元素)。也就是说,thunk数组在PE文件中有两份,他们的地址不同,但最终指向完全相同。画出图形的话是类似下面这样(注意最终指向相同,4个指针画出来类似一个菱形):
IID.OrignalFirstThunk---> thunk1[] -------\
--------------------------------------------- > IMAGE_IMPORT_BY_NAME ( hint, name [] )
IID.FirstThunk-----------> thunk2[] -------/
即在上面的代码里,用 iid 的无论哪一个thunk指针最终得到的都是相同的结果。PE文件中OrignalFirstThunk是一个对thunk表的原始备份,它始终是不变的。而FirstThunk在系统装载PE以后,将会修改,把他们替换成函数的真正地址(即进行绑定),这时它们就不再是指向函数序号和名称了,而是真正的函数地址(映射到进程空间之内的),你对某个DLL函数的调用在代码中本质上是调用一个存根(因为编译器不知道导入函数的实际地址),在存根处跳转到 FirstThunk 数组指明的实际函数地址。程序一旦运行起来,导入表将不再那么关键了,重要的是实际IAT(FirstThunk)。这时OrignalFirstThunk 数组就成为了一个被替换前的原始备份,当需要重新绑定时(例如DLL版本不对)需要用到它,这是“Orignal” 的含义。First的含义是,指向的是IAT的第一个元素(thunk)。Thunk可能是新造出来的词,没有确切的解释,大概是是被加载后被系统替换掉了值,有真真假假,虚虚实实的语义,同时也有一种说法是有“预先想到”的含义(备注:在ATL中也有Thunk这个词)。
现在我们回头在看下IID和Thunk的定义,可以很好的理解那些注释,IID的OriginalFirstThunk指向的是未绑定之前的IAT(导入地址表),这个IAT表示的是应该导入该DLL的哪些名称(序号)的函数。而FirstThunk在绑定(被替换成实际函数地址)后指向的实际的函数地址。所以Thunk里面是一个union,里面的 AddressOfData 指是绑定之前它指向函数的hint和名称,Function 指的得是绑定后它就成了实际函数地址。
假如引用的 DLL 是确定的,那么在加载时的函数绑定工作就可以事先完成,在加载时只需要核对以下DLL的时间戳是否吻合(如果不能吻合则和没有绑定一样处理,再次人工绑定),这样就可以节省程序加载的时间,随操作系统提供的一些工具比如记事本,计算器等工具都经过了事先绑定。通过 VC 提供的 Dependency 工具可以查看到你链接的 DLL 是否经过了事先绑定。如果没有绑定,那些导入的函数的地址将显示未绑定(not bound)。借助 IDE 提供的 Bind 工具可以进行预先绑定,从而减少你的程序启动时间。或者通过编程方式,用 BindImageEx 函数去绑定一个 Image 文件。
例如,如果你开发好了应用程序,在打包安装程序之前,可以用这两个命令行工具进行优化,从而加快程序启动的速度。
(1)优化DLL的首选ImageBase:
ReBase.EXE -b 10000000 *.dll
(2)事先绑定DLL:(在客户端上,系统DLL未必是有效的绑定,但是对于你自己提供的DLL,该绑定一定有效)
Bind.EXE -o -u -v 你的可执行文件.exe
经过 Rebase 和 Bind 以后,可以提高你的程序的启动速度。系统提供的DLL都经过了Rebase,这样它们在被加载时几乎总能成功加载到首选的ImageBase。系统提供的工具也基本都经过事先绑定。
IID中的ForwardChain,是函数转发器,即一个DLL文件可以导出一个假定的函数,这个函数是实际上位于另一个DLL中的函数,这样在逻辑上就成为一个“链式结构”,系统在加载时需要分析转发器,把引用的实际DLL映射到进程空间。在操作系统的 Kernel32.dll 中可以看到转发器,比如 Kernel32.dll 中的 HeapAlloc 被转发到 NTDLL 中的 RtlAllocateHeap。但转发器在实际应用中则比较少见。如果要指定一个转发器,需要在代码中使用形式如下面的语句:
#pragma comment ( linker, "/export:FuncName = DllName.SomeOtherFuncName" )
【参考资料】
关于PE文件相关的知识,主要参考:
(1)《看雪论坛五周年精华》(1~9)。
关于函数转发器部分,主要参考:
(2)《Window核心编程》第20章:DLL的高级操作技术。
使用 Rebase 命令行工具重设 DLL 的首选ImageBase:
(3)ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.WIN32COM.v10.en/tools/tools/rebase.htm
使用 Bind 命令工具进行预先绑定:
(4)ms-help://MS.VSCC.v80/MS.MSDN.v80/MS.WIN32COM.v10.en/tools/tools/bind.htm
【修订历史】
(1)把文中范例代码中的 FirstThunk 替换为 OriginalFirstThunk,并给出原因。2010-12-28 0:18:53。