上一篇文章主要给大家介绍了一下PE文件的各个结构的数据结构,以及了解PE结构中的几个重要概念,下面我重点给大家讲解区块表中的四个非常重要的区块:输入表,输出表,重定位表,资源,我会一个一个详解给大家讲解的,请跟随我的进度慢慢消化学习!!如果你对PE中的这四个区块都很了解,请跳过PE总复习二,谢谢~~以免浪费您保贵的时间!
输入表
可执行文件使用来自于其他DLL的代码或数据时,称为输入。当PE文件装载时,Windows加载器的工作之一就是定位所有被输入函数和数据,并且让正在被装载入的文件可以使用那些地址。这个过程通过PE文件的输入表(Import Table)来完成的,输入表中保存的是函数名和其驻留的DLL名等动态链接所需要的信息,输入表在软件外壳技术上的地位非常重要,我这里会重点讲解的!!!!
当应用程序调用一个DLL的代码和数据时,那它正在隐含链接到DLL,这个过程完全由Windows加载器完成,另外一种是运行期的显示链接,这意味着必须确定目标DLL已经被加载,然后寻找API地址,这几乎总是通过调用LoadLibrary和GetProcAddress来完成的。
当隐含地链接一个API 时,类似LoadLibrary和GetProcAddress的代码始终在执行,只不过这是Windows装载器自动完成的。装载器还保证PE文件所需要的任何附加的DLL都已载入。
在PE文件内,有一组数据结构,它们分别对应着每个被输入的DLL。每一个这样结构都给出了被输入的DLL的名称并指向一组函数指针。这组函数指针被称为输入地址表(Import Address Table)简称IAT,每一个被引入的API在IAT里都有它自己保留的位置,在那里它将被Windows加载器写入输入函数的地址,最后一点是特别重要的:一旦模块被装入,IAT中包含所要调用输入函数的地址。
把所有输入函数放在IAT中同一个地方是很有意义的,这样无论代码中多少次调用一个输入函数,都会通过IAT中的同一个函数指针来完成。
调用输入表函数方法:
高效:CALL DWORD PTR [00402010]
直接调用[00402010]中的函数,地址00402010h位于IAT里
低效:CALL 00401164
........................................
:00401164
jmp dword ptr [00402010]
这种情况,CALL把控制权转到一个子程序,子程序中的JMP指令跳转到位于IAT中的00402010h。简单的说它使用5个字节的额外代码,并且由额外的JMP将花费更多的时间去执行。
为什么要使用这种低效的方法?因为编译器无法区别输入函数的调用与普通函数调用,对于每一个函数调用,编译器使用同样形式的CALL指令:CALL XXXXXXXX
XXXXXXXX是一个由链接器填充的实际的地址。注意指令不是从函数指针而是代码中实际地址而来的,为了因果平衡,链接器必须表示产生一块代码来取代XXXXXXXX,简单位的方法是像上面那样调用一个JMP Stub。
我们可以通过使用修饰来优化我们的低效调用方式,可以用修饰函数的_declspec(dllimport)来告诉编译器,这个函数来自另一个DLL中,这样编译器就会产生这样的指令:
CALL DWORD PTR [XXXXXXXX]
而不是CALL XXXXXXXX,编译器将给函数加上_imp_前缀,然后直接送给链接器,这样可以直接把_imp_xxx送到IAT,就不需要JMP Stub了。
下面简单分析一个实例,看看是怎么回事?
程序被执行的时候是怎样使用导入函数的呢?先写个简单的Hello World程序反汇编一把,看看调用导入函数的指令都是什么样子的,需要反汇编的两句源代码如下(呵呵,这个代码我就不写了):
invoke MessageBox,NULL,offset szText,offset szCaption,MB_OK
invoke ExitProcess,NULL
当使用W32Dasm反汇编以后,这两句代码变成了以下的指令,如图所示:
反汇编后,对MessageBox和ExitProcess函数的调用变成了对0040101A和00401020地址的调用,但是这两个地址显然是位于程序自身模块而不是在DLL模块中的,实际上,这是由编译器在程序所有代码的后面自动加上的Jmp dword ptr [xxxxxxxx]类型的指令,这个指令是一个间接寻址的跳转指令,xxxxxxxx地址中存放的才是真正的导入函数的地址。在这个例子中,00402000地址处存放的就是ExitProcess函数的地址。
那么在没有装载到内存之前,PE文件中的00402000地址处的内容是什么呢?
用PEID工具查得结果如图所示
由于镜像基址为00400000h,所以00402000h地址实际上处于RVA为2000h的地方,再看看各个节的虚拟地址,可以发现2000h开始的地方位于.rdata节内,而这个节的Raw_偏移项目为600h,也就是说00402000h地址内容实际上对应PE文件偏移600h处的数据。
我们就看看文件0600h处的内容是什么?用UE打开程序,如图所示:
查看的结果是00002076h,这显然不会是内存中的ExitProcess函数的地址,慢着!将它作为RVA看会怎么样呢?查看节表可以发现RVA地址00002076h也处于.rdata节内,减去节的起始地址00002000h后得到这个RVA相对于节首的偏移是76h,也就是说它对应文件0676h开始的地方,接下来可以惊奇地发现,0676h再过去两个字节的内容正是函数名字符串“ExitProcess”!
这都有点搞糊涂了,Call ExitProcess指令被编译成了Call aaaaaaaa类型的指令,而aaaaaaaa处的指令是Jmp dword ptr [xxxxxxxx],而xxxxxxxx地址的地方只是一个似乎是指向函数名字符串的RVA地址,这一系列的指令显然是无法正确执行的!
但如果告诉你,当PE文件被装载的时候,Windows装载器会根据xxxxxxxx处的RVA得到函数名,再根据函数名在内存中找到函数地址,并且用函数地址将xxxxxxxx处的内容替换成真正的函数地址,那么所有的疑惑就迎刃而解了。
呵呵,这样讲解之后,你对输入表是否有的更新的认识呢?
怎样获取输入表呢?
导入表的位置和大小可以从PE文件头中IMAGE_OPTIONAL_HEADER32结构的数据目录字段中获取,对应的项目是DataDirectory字段的第2个IMAGE_DATA_DIRECTORY结构
从IMAGE_DATA_DIRECTORY结构的VirtualAddress字段得到的是导入表的RVA值,如果在内存中查找导入表,那么将RVA值加上PE文件装入的基址就是实际的地址;如果在PE文件中查找导入表,需要将RVA转换成File Offset。
导入表由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成,结构的数量取决于程序要使用的DLL文件的数量,每个结构对应一个DLL文件,例如,如果一个PE文件从10个不同的DLL文件中引入了函数,那么就存在10个IMAGE_IMPORT_DESCRIPTOR结构来描述这些DLL文件,在所有这些结构的最后,由一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束。
每个被PE文件隐式地链接进来的DLL都有一个IID。在这个数组中,没有字段指出该结构数组的项数,但它的最后一个单元是NULL,可以由此计算出该数组的项数。
IMAGE_IMPORT_DESCRIPTOR STRUCT
union
Characteristics dd ?
OriginalFirstThunk dd ?
ends
TimeDateStamp dd ?
ForwarderChain dd ?
Name1 dd ?
FirstThunk dd ?
IMAGE_IMPORT_DESCRIPTOR ENDS
结构中的Name1字段(使用Name1作为字段名同样是因为Name一词和MASM的关键字冲突)是一个RVA,它指向此结构所对应的DLL文件的名称,这个文件名是一个以NULL结尾的字符串。
OriginalFirstThunk字段和FirstThunk字段的含义现在可以看成是相同的(使用“现在”一词的含义马上会见分晓),它们都指向一个包含一系列IMAGE_THUNK_DATA结构的数组,数组中的每个IMAGE_THUNK_DATA结构定义了一个导入函数的信息,数组的最后以一个内容为0的IMAGE_THUNK_DATA结构作为结束。
一个IMAGE_THUNK_DATA结构实际上就是一个双字,之所以把它定义成结构,是因为它在不同的时刻有不同的含义,结构的定义如下:
IMAGE_THUNK_DATA STRUCT
union u1
ForwarderString dd ?
Function dd ?
Ordinal dd ?
AddressOfData dd ?
ends
IMAGE_THUNK_DATA ENDS
一个IMAGE_THUNK_DATA结构如何用来指定一个导入函数呢?当双字(就是指结构!)的最高位为1时,表示函数是以序号的方式导入的,这时双字的低位就是函数的序号。读者可以用预定义值IMAGE_ORDINAL_FLAG32(或80000000h)来对最高位进行测试,当双字的最高位为0时,表示函数以字符串类型的函数名方式导入,这时双字的值是一个RVA,指向一个用来定义导入函数名称的IMAGE_IMPORT_BY_NAME结构,此结构的定义如下:
IMAGE_IMPORT_BY_NAME STRUCT
Hint dw ?
Name1 db ?
IMAGE_IMPORT_BY_NAME ENDS
结构中的Hint字段也表示函数的序号,不过这个字段是可选的,有些编译器总是将它设置为0,Name1字段定义了导入函数的名称字符串,这是一个以0为结尾的字符串。
整个过程听起来很复杂,其实看下面的图示,可执行文件导入了Kernel32.dll中的ExitProcess,ReadFile,WriteFile和lstcmp函数的情况,其中,前面3个函数按照名称方式导入,最后lstrcmp函数按照序号导入,这四个函数分别是02f6h,0111h,002bh和0010h。
导入表中IMAGE_IMPORT_DESCRIPTOR结构的Name1字段指向字符串“Kernel32.dll”,表明当前要从Kernel32.dll文件中导入函数,OriginalFirstThunk和FirstThunk字段指向两个同样的IMAGE_THUNK_DATA数组,由于要导入的是4个函数,所以数组中包含4个有效项目并以最后一个内容为0的项目作为结束。
第4个函数lstrcmp函数是以序号导入的,与其对应的IMAGE_THUNK_DATA结构的最高位等于1,和函数的序号0010h组合起来的数值就是80000010h,其余的3个函数采用的是以函数名导入的方式,所以IMAGE_THUNK_DATA结构的数值是一个RVA,分别指向3个IMAGE_IMPORT_BY_NAME结构,每个IMAGE_IMPORT_BY_NAME结构的第一个字段是函数的序号,后面就是函数的字符串名称了,一切就是这么简单!
这里有个问题:为什么要用两个并行的指针数组指向IMAGE_IMPORT_BY_NAME结构呢?
答安:当PE文件被装入内存的时候,其中一个数组的值将被改作他用,还记得前面分析Hello World程序时提到的,Windows装载器会将指令JMP DWORD PTR [XXXXXXXX]指定的XXXXXXXX处的RVA替换成真正的函数地址,其实XXXXXXXX地址正是由FirstThunk字段指向的那个数组中的一员。
实际上,当PE文件被装载入内存后,内存中的映像就被Windows装载器修正成了如下图所示的样子,其中由FirstThunk字段指向的那个数组中的每个双字都被替换成了真正的函数入口地址,之所以在PE文件中使用两份IMAGE_THUNK_DATA数组的拷贝并修改其中的一份,是为了最后还可以留下一份拷贝用来反过来查询地址所对应的导入函数名。
输入表实例分析
这里我就用老罗书上的那个FirstWindows作为实例进行分析,因为看雪上那个PE.EXE被我的360删了,我也不知道,不管了,反正也要重新分析一遍,这样更加深记忆!!
数据目录表第二成员指向输入表,该指针具体位置在PE文件头的80h偏移处。该文件的PE文件头起始位置是C0h,输入表地址就是整个文件的C0h+80h=140h处,因此在140h处可以发现四字节指针90200000,倒过来是00002090,即输入表在内存中偏移量是为2090h的地方,当然,这个2090h是RVA值,需要将其转换为磁盘文件的绝对偏移量,才能够在十六进制编辑器中找到输入表。具体如下图所示:
大家可以通过计算来实现RVA到File Offset的转换,这里我了节省篇幅,我就直接用LoadPE来计算了,如图所示,我们要计算的RVA地址为00002090h,得到File Offset的地址为690h,如下图所示:
得到文件偏移地址之后,我们用UE打开FirstWindow程序,跳到偏移为690h处,这里就是输入表的内容,每个IID包含5个双字,用来描述一个引入的DLL文件,最后以NULL结束。如图所示:
将图中所列的输入表的IID数组整理到下面的表中。每个IID包含了一个DLL的描述信息,现在有两个IID,因此这里引入了两个DLL,第三个IID全为0,作为结束标志。
每个IID中的第四个字段是指向DLL名称的指针,这里第一个IID中的第四个字段是0E220000,翻转过来也就是RVA地址0000220Eh,用上面的方法转换得到File Offset为80Eh,还有下面那个IID中的第四个字段也是指向DLL名称的指,RVA地址为0000224Ch,转换得到File Offset为84Ch,这样我们就得到输入表中所使用的两个DLL的名称,所图所示:
由上图可知EXE文件中偏移量为80Eh处的字符是user32.dll,84Ch处的字符是kernel32.dll,所以此程序调用了两个DLL。
上面的表格转换成RVA地址,如下所示:
再查找USER32.dll中被调用的函数,在第一个IID中,查看第一个字段OrignalFirstThunk,它指向一个数据,这个数组的元素都是指针,分别指向引入函数名的ASCII字符串。有些程序的OriginalFirstThunk的值为0,所以这时就要看FirstThunk,它在程序运行时初始化。
这时我就分析一个IID,第二个IID留着读者自己去分析!
USER32.DLL这个IID结构中的OriginalFirstThunk的字段的值为000020DC,转化为File Offset为:6DC,所以在偏移6DCh处就是IMAGE_THUNK_DATA数组,它存储的是指向IMAGE_IMPORT_BY_NAME结构的地址,以一串00结束。
可得到如下表所示的IMAGE_THUNK_DATA的数组。
具体的位置如下图所示:
再来看看同一个IID结构中FirstThunk情况,USER32.dll所在IID的FirstThunk字段值是2010h,然后转换得到File Offset为610h,在偏移610h处就是IMAGE_THUNK_DATA数组,其数据与OrignalFirstThunk字段所指的完全一样,如下图所示:
通常一个完整的程序就这些,现在有15个IMAGE_THUNK_DATA,表示有15个函数调用,先选择一个分析一下:
4E210000翻转后为0000214E,然后转换为File Offset为74Eh,会发现在偏移74Eh处的字符串为DestroyWindow。你也许注意到了,计算出来的偏移量并不刚好指向函数名的ASCII字符串,而是前面还有两个字节的空缺,这是作为函数名(Hint)引用的,可以为0。
第一个IID指向的API函数表如下:
如上图是FirstWindow文件运行前第一个IID的结构示意图,在程序运行前,它的FirstThunk字段值是指向一个地址串,而且和OrignalFirstThunk字段值指向的INT是重复的,系统在程序初始化时根据OrignalFirstThunk的值找到函数名,调用GetProcAddress函数(或类似功能的系统代码)且根据函数名取得函数的入口地址,然后用函数入口地址取代FirstThunk指向的地址串中对应的值(IAT)。
其内部结构如下图所示,图片来源《加密与解密》第三版
下面利用《加密与解密》第三版上的实例dumped.exe讲解PE文件映射到内存的状态,找开映象文件,由于在内存中区块的对齐值与内存页相同,因此此时其文件偏移地址与相对虚拟地址(RVA)的值相等。输入表的RVA地址是2040h,具体见下图:
由于00002040处的值为8C200000,翻转为0000208C,再看208C处的IMAGE_THUNK_DATA和值为没有映射到内存中的是一样的,但是FirstThunk的值为2010h,,该处指向的输入表IAT,将这张表与没有映射之前的比较,可以发现完全不同了。
具体情况如下图所示:
内存中第一个IID结构的输入地址表(IAT)
表中各地址都是USER32.dl链连库的相关输出函数,反汇编USER32.dll,跳到77D216DDh地址处,显示代码如下:
Exproted fn():LoadIconA -Ord:01BCh
:77D216DD 8BC0 mov eax,eax
:77D216DF 55 push ebp
:77D216E0 8BEC mov ebp,esp
:77D216E2 66F7450EFFFF test [ebp+0E],FFFF
:77D216E8 0F8529170200 jne 77D42E17
:77D216EE 5D pop ebp
:77D216EF EBB6 jmp 77D216A7
:77D216F1 90 nop
:77D216F2 90 nop
不过我刚才用反汇编工具查的时候好像不是这个地址了,可能是版本不同吧,呵呵
原来,77D216DD指向的是USER32.dll中LoadIconA函数代码处,如下图反应了PE.EXE文件装载到内存里的结构示意图
程序装载进内存后,只与IAT交换信息,输入表的其他部分不需要了,例如:程序需要调用LoadIconA函数的指针是指向IAT的,而IAT已指向系统USER32.dll的LoadIconA函数代码里。调用LoadIconA函数的相关代码如下:
CALL 00401164
:00401164
JMP DWORD PTR [00402010] ;跳到77D216DD,此处是USER32.dll指向的LoadIconA
具体细节请参考《加密与解密》第三版~~
好了,今天就先讲到这里吧,不好意思,这么晚才传上来,今天出去和朋友玩了一天,到晚上十二点才回家
,一直写到现在,本来想把输出表,资源,重定位也在今天讲了,可是我看了输入表内容太多,又非常重要,所以我还是把输入表作为单独来讲解,还有一个原因就可能是我太想睡觉了,晚上会花时间把后面三个部分也传上来,希望大家阅读了上面的文章会有一点小小的帮助,呵呵,晚安~~
上传的附件: