罗云彬书里有写~
1. 调用导入函数的指令
程序被执行的时候是怎样使用导入函数的呢?先将第3章中那个简单的Hello World程序反汇编一把,看看调用导入函数的指令都是什么样子的,需要反汇编的两句源代码如下:
invoke
MessageBox,NULL,offset szText,offset szCaption,MB_OK
invoke
ExitProcess,NULL
当使用W32Dasm反汇编以后,这两句代码变成了以下的指令:
:00401000 6A00
push 00000000
:00401002 6800304000
push 00403000
:00401007 680F304000
push 0040300F
:0040100C 6A00
push 00000000
:0040100E E807000000
Call 0040101A ;MessageBox
:00401013 6A00
push 00000000
:00401015 E806000000
Call 00401020 ;ExitProcess
:0040101A FF2508204000
Jmp dword ptr [00402008]
:00401020 FF2500204000
Jmp dword ptr [00402000]
反汇编后,对MessageBox和ExitProcess函数的调用变成了对0040101A和00401020地址的调用,但是这两个地址显然是位于程序自身模块而不是在DLL模块中的,实际上,这是由编译器在程序所有代码的后面自动加上的Jmp dword ptr [xxxxxxxx]类型的指令,这个指令是一个间接寻址的跳转指令,xxxxxxxx地址中存放的才是真正的导入函数的地址。在这个例子中,00402000地址处存放的就是ExitProcess函数的地址。
那么在没有装载到内存之前,PE文件中的00402000地址处的内容是什么呢?使用在17.1.4节中了解的方法来查看一下。
首先,使用17.1.4节的例子文件PEInfo.exe去查看一下Hello.exe文件,会得到以下的信息:
文件名:C:\Documents and Settings\Administrator\桌面\Hello.exe
----------------------------------------------------------
运行平台:
0x014C
节区数量:
3
文件标记:
0x010F
建议装入地址:
0x00400000
----------------------------------------------------------
节区名称
节区大小
虚拟地址
Raw_尺寸
Raw_偏移
节区属性
----------------------------------------------------------
.text 00000026
00001000
00000200
00000400
60000020
.rdata
00000092
00002000
00000200
00000600
40000040
.data 00000022
00003000
00000200
00000800
C0000040
由于建议装入地址是00400000h,所以00402000h地址实际上处于RVA为2000h的地方,再看看各个节的虚拟地址,可以发现2000h开始的地方位于.rdata节内,而这个节的Raw_偏移项目为600h,也就是说00402000h地址的内容实际上对应PE文件中偏移600h处的数据。
现在随便找一个16进制编辑器来看看文件0600h处的内容是什么:
0600
76 20 00 00 00 00 00 00-5C 20 00 00 00 00 00 00 v ......\ ......
0610
54 20 00 00 00 00 00 00-00 00 00 00 6A 20 00 00 T ..........j ..
0620
08 20 00 00 4C 20 00 00-00 00 00 00 00 00 00 00 . ..L ..........
0630
84 20 00 00 00 20 00 00-00 00 00 00 00 00 00 00 . ... ..........
0640
00 00 00 00 00 00 00 00-00 00 00 00 76 20 00 00 ............v ..
0650
00 00 00 00 5C 20 00 00-00 00 00 00 BB 01 4D 65 ....\ ........Me
0660
73 73 61 67 65 42 6F 78-41 00 55 53 45 52 33 32 ssageBoxA.USER32
0670
2E 64 6C 6C 00 00 75 00-45 78 69 74 50 72 6F 63 .dll..u.ExitProc
0680
65 73 73 00 4B 45 52 4E-45 4C 33 32 2E 64 6C 6C ess.KERNEL32.dll
0690
00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
查看的结果是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处的内容替换成真正的函数地址,那么所有的疑惑就迎刃而解了。
接下来看看如何去获取导入表的位置,以及导入表中的数据是如何组织以便Windows装载器能够顺利地进行上面的转换工作的。
2. 获取导入表的位置
导入表的位置和大小可以从PE文件头中IMAGE_OPTIONAL_HEADER32结构的数据目录字段中获取,对应的项目是DataDirectory字段的第2个IMAGE_DATA_DIRECTORY结构(见表17.4)。
从IMAGE_DATA_DIRECTORY结构的VirtualAddress字段得到的是导入表的RVA值,如果在内存中查找导入表,那么将RVA值加上PE文件装入的基址就是实际的地址;如果在PE文件中查找导入表,那么需要首先使用17.1.4节中例举的_RVAToOffset子程序将RVA首先转换成文件偏移。
17.2.2
导入表的结构
1. PE文件中的导入表
现在得到了包含导入表的数据块,导入表由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成,结构的数量取决于程序要使用的DLL文件的数量,每个结构对应一个DLL文件,例如,如果一个PE文件从10个不同的DLL文件中引入了函数,那么就存在10个IMAGE_IMPORT_DESCRIPTOR结构来描述这些DLL文件,在所有这些结构的最后,由一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束。
IMAGE_IMPORT_DESCRIPTOR结构的定义如下:
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为结尾的字符串。
整个过程听起来有些复杂,其实再看一下图17.5就很清楚了,图中示意了可执行文件导入了Kernel32.dll中的ExitProcess,ReadFile,WriteFile和lstrcmp函数的情况,其中,前面3个函数按照名称方式导入,最后的lstrcmp函数按照序号导入,这4个函数的序号分别是02f6h,0111h,002bh和0010h。
图17.5
函数的导入方法举例
现在来分析一下图17.5中的示例,导入表中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结构的第一个字段是函数的序号,后面就是函数的字符串名称了,一切就是这么简单!
2. 内存中的导入表
为什么需要两个一模一样的IMAGE_THUNK_DATA数组呢?答案是当PE文件被装入内存的时候,其中一个数组的值将被改作他用,还记得前面分析Hello World程序时提到的吗?Windows装载器会将指令Jmp dword ptr [xxxxxxxx]指定的xxxxxxxx处的RVA替换成真正的函数地址,其实xxxxxxxx地址正是由FirstThunk字段指向的那个数组中的一员。
实际上,当PE文件被装入内存后,内存中的映像就被Windows装载器修正成了图17.6所示的样子,其中由FirstThunk字段指向的那个数组中的每个双字都被替换成了真正的函数入口地址,之所以在PE文件中使用两份IMAGE_THUNK_DATA数组的拷贝并修改其中的一份,是为了最后还可以留下一份拷贝用来反过来查询地址所对应的导入函数名。
图17.6
导入表被装入内存后的样子
3. 导入地址表(IAT)
IMAGE_IMPORT_DESCRIPTOR结构中FirstThunk字段指向的数组最后会被替换成导入函数的真正入口地址,暂且把这个数组称为导入地址数组。在PE文件中,所有DLL对应的导入地址数组在位置上是被排列在一起的,全部这些数组的组合也被称为导入地址表(Import Address Table,或者简称为IAT),导入表中第一个IMAGE_IMPORT_DESCRIPTOR结构的FirstThunk字段指向的就是IAT的起始地址。
还有一个方法可以更方便地找到IAT的地址,那就是通过数据目录表。数据目录表中的第13项(索引值为12/ IMAGE_DIRECTORY_ENTRY_IAT)直接用来定义IAT数据块的位置和大小。
17.2.3
查看PE文件导入表举例
有一个查看PE文件所有导入函数信息的例子,例子的源代码在本书所附光盘的Chapter17\Import目录中,为了节省篇幅,例子的界面处理代码使用PEInfo例子中的Main.asm和Main.rc文件,只修改了其中的_ProcessPeFile.asm文件,另外,还使用了17.1.4节中例举的_RvaToFileOffset.asm文件。
ProcessPeFile.asm文件的内容如下:
.const
szMsg
db
~文件名: %s~,0dh,0ah
db
~--------------------------------------~,0dh,0ah
db
~导入表所处的节:%s~,0dh,0ah,0
szMsgImport db
0dh,0ah
db
~--------------------------------------~,0dh,0ah
db
~导入库: %s~,0dh,0ah
db
~--------------------------------------~,0dh,0ah
db
~OriginalFirstThunk %08X~,0dh,0ah
db
~TimeDateStamp
%08X~,0dh,0ah
db
~ForwarderChain %08X~,0dh,0ah
db
~FirstThunk %08X~,0dh,0ah
db
~--------------------------------------~,0dh,0ah
db
~导入序号
导入函数名称~,0dh,0ah
db
~------------------------------------~,0dh,0ah,0
szMsgName db
~%8u
%s~,0dh,0ah,0
szMsgOrdinal
db
~%8u
(按序号导入)~,0dh,0ah,0
szErrNoImport db
~这个文件不使用任何导入函数~,0
.code
include _RvaToFileOffset.asm
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_ProcessPeFile
proc
_lpFile,_lpPeHead,_dwSize
local @szBuffer[1024]:byte,@szSectionName[16]:byte
pushad
mov edi,_lpPeHead
assume
edi:ptr IMAGE_NT_HEADERS
;********************************************************************
mov eax,[edi].OptionalHeader.DataDirectory[8].VirtualAddress
.if ! eax
invoke
MessageBox,hWinMain,addr szErrNoImport,NULL,MB_OK
jmp _Ret
.endif
invoke
_RVAToOffset,_lpFile,eax
add eax,_lpFile
mov edi,eax
assume
edi:ptr IMAGE_IMPORT_DESCRIPTOR
;********************************************************************
; 显示 PE 文件名
;********************************************************************
invoke
_GetRVASection,_lpFile,[edi].OriginalFirstThunk
invoke wsprintf,addr @szBuffer,addr szMsg,addr szFileName,eax
invoke
SetWindowText,hWinEdit,addr @szBuffer
;********************************************************************
; 循环处理 IMAGE_IMPORT_DESCRIPTOR 直到遇到全零的则结束
;********************************************************************
.while
[edi].OriginalFirstThunk || [edi].TimeDateStamp || \
[edi].ForwarderChain || [edi].Name1 || [edi].FirstThunk
invoke
_RVAToOffset,_lpFile,[edi].Name1
add eax,_lpFile
invoke wsprintf,addr @szBuffer,addr szMsgImport,eax,\
[edi].OriginalFirstThunk,[edi].TimeDateStamp,\
[edi].ForwarderChain,[edi].FirstThunk
invoke
_AppendInfo,addr @szBuffer
;********************************************************************
; 获取 IMAGE_THUNK_DATA 列表地址 ---> ebx
;********************************************************************
.if [edi].OriginalFirstThunk
mov eax,[edi].OriginalFirstThunk
.else
mov eax,[edi].FirstThunk
.endif
invoke
_RVAToOffset,_lpFile,eax
add eax,_lpFile
mov ebx,eax
;********************************************************************
; 循环处理所有的 IMAGE_THUNK_DATA
;********************************************************************
.while
dword ptr [ebx]
;********************************************************************
; 按序号导入
;********************************************************************
.if dword ptr [ebx] & IMAGE_ORDINAL_FLAG32
mov eax,dword ptr [ebx]
and eax,0FFFFh
invoke
wsprintf,addr @szBuffer,addr szMsgOrdinal,eax
.else
;********************************************************************
; 按函数名导入
;********************************************************************
invoke
_RVAToOffset,_lpFile,dword ptr [ebx]
add eax,_lpFile
assume
eax:ptr IMAGE_IMPORT_BY_NAME
movzx ecx,[eax].Hint
invoke
wsprintf,addr @szBuffer,\
addr szMsgName,ecx,addr [eax].Name1
assume
eax:nothing
.endif
invoke
_AppendInfo,addr @szBuffer
add ebx,4
.endw
add edi,sizeof IMAGE_IMPORT_DESCRIPTOR
.endw
;********************************************************************
_Ret:
assume
edi:nothing
popad
ret
_ProcessPeFile
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
如果读者已经理解了17.2.1和17.2.2节中的内容,那么这段源程序是很容易看懂的,程序首先查找数据目录表并得到导入表的地址,然后循环处理导入表中的每个IMAGE_IMPORT_DESCRIPTOR结构。
对于每个IMAGE_IMPORT_DESCRIPTOR结构,程序首先显示Name1字段指向的DLL文件名;然后继续构造一个循环来处理OriginalFirstThunk指向的IMAGE_THUNK_DATA数组,程序使用预定义值IMAGE_ORDINAL_FLAG32来测试IMAGE_THUNK_DATA结构的最高位,当最高位为1时则显示导入函数的序号,当最高位为0时则显示导入函数的Hint值和函数名称。