一、相关意义及说明
当PE文件被执行的时候,Windows装载器将文件装入内存并将导入表中登记的DLL文件一并装入,再根据DLL文件中的函数导出信息对被执行文件的IAT表进行修正。在这些包含导出函数的DLL文件中,导出信息被保存在导出表中,通过导出表,DLL文件向系统提供导出函数的名称、序号和入口地址等信息,以便Windows装载器通过这些信息来完成动态链接的过程。
二、函数导出表结构和关系
(一)首先来看导出示意图:
上图左边是导出表结构IMAGE_EXPORT_DIRECTORY的各个字段,下面来说说各个字段间的关系:
1. nBase:导出函数序号的起始值。将AddressOfFunctions字段指向的入口地址表的索引号加上这个起始值就是对应函数的导出序号。假如nBase字段的值为x,那么入口地址表指定的第一个导出函数的序号就是x,第二个导出函数的序号就是x+1。
2. AddressOfFunctions:指向函数入口地址表,这张表包含了所有的导出函数,无论是有函数名的还是没有函数名的,它的每个字段内容是函数的入口RVA,这是一个双字表,也就是每个RVA都是双字。
3. AddressOfNames:指向一个双字RVA表,这个表中的每个RVA指向一个函数名称字符串。
4. AddressOfNameOrdinals:与函数名称表一一对应的索引表,也就是每一个有函数名的函数都能在这张表中找到对应的索引。也就是说AddressOfNameOrdinals表是一个中间表,一边对应着全部的函数名称,另一边对应着部分的函数入口地址。之所以这样是因为另外有一部分函数是没有名称的,而这部分函数的索引并不在AddressOfNameOrdinals表中。
(二)关于序号和索引
从书本的描述和之前的图中可以看出,无论是AddressOfFunctions还是AddressOfNameOrdinals,索引都是从零开始的,而序号=索引值+nBase。
三、分析书本实例(CHA17-Export文件夹—_ProcessPeFile)
1. _ProcessPeFile子程序参数的意义
(1) 子程序参数1:已经读取到内存中的文件头的地址。通过内存映射读取到整个PE文件在内存中的起始位置。
(2) 子程序参数2:PE文件头在内存中的偏移地址。也就是在MAGE_DOS_HEADER 结构中取出的e_lfanew的值。
(3) 子程序参数3:整个PE文件的长度。通过打开文件后拿到的句柄,用GetFileSize函数拿到文件的长度。
2. 获取导出表数据块的文件偏移地址。用子程序参数2得到IMAGE_NT_HEADERS结构的地址,从而得到数据目录中第一个IMAGE_DATA_DIRECTORY结构的VirtualAddress值——对应导出表数据块的起始RVA并将其转换为文件偏移地址。
_ProcessPeFile proc _lpFile,_lpPeHead,_dwSize
local @szBuffer[1024]:byte,@szSectionName[16]:byte
local @dwIndex,@lpAddressOfNames,@lpAddressOfNameOrdinals
pushad
mov esi,_lpPeHead
assume esi:ptr IMAGE_NT_HEADERS
;********************************************************************
; 从数据目录中获取导出表的位置
;********************************************************************
mov eax,[esi].OptionalHeader.DataDirectory.VirtualAddress
.if ! eax
invoke MessageBox,hWinMain,addr szErrNoExport,NULL,MB_OK
jmp _Ret
.endif
invoke _RVAToOffset,_lpFile,eax
add eax,_lpFile
mov edi,eax
3. (1) 从获取的导出表文件偏移地址得到导出表中的原始文件名、导出表所处的节等一些列信息并显示在Richedit控件中。
;********************************************************************
; 显示一些常用的信息
;********************************************************************
assume edi:ptr IMAGE_EXPORT_DIRECTORY
invoke _RVAToOffset,_lpFile,[edi].nName
add eax,_lpFile
mov ecx,eax
invoke _GetRVASection,_lpFile,[edi].nName
invoke wsprintf,addr @szBuffer,addr szMsg,addr szFileName,eax,ecx,[edi].nBase,\
[edi].NumberOfFunctions,[edi].NumberOfNames,[edi].AddressOfFunctions,\
[edi].AddressOfNames,[edi].AddressOfNameOrdinals
invoke SetWindowText,hWinEdit,addr @szBuffer
4. 获取AddressOfNames、AddressOfNameOrdinals、AddressOfFunctions这三个字段的内容在文件中的地址。
;********************************************************************
invoke _RVAToOffset,_lpFile,[edi].AddressOfNames
add eax,_lpFile
mov @lpAddressOfNames,eax
invoke _RVAToOffset,_lpFile,[edi].AddressOfNameOrdinals
add eax,_lpFile
mov @lpAddressOfNameOrdinals,eax
invoke _RVAToOffset,_lpFile,[edi].AddressOfFunctions
add eax,_lpFile
mov esi,eax ;esi --> 函数地址表
5. 显示所有导出函数的导出序号、 虚拟地址(函数入口地址RVA) 、导出函数名称。为了完成前述功能,程序构建了一个循环,在这个循环中,以导出函数的数量NumberOfFunctions为循环数量ecx以便处理所有函数。在循环开始前,程序定义了一个局部变量@dwIndex为全部索引,并初始化为零。[1]
在每个循环中:
(1)首先判别该函数是否有函数名。这个过程是这样:
以索引值@dwIndex来校验AddressOfNameOrdinals表中的索引值 [2] 。
① 如果表中的索引值与局部变量的索引值相等 [3],则意味着该索引值对应的函数有函数名 [4],获取索引值对应的偏移值[5],并通过该偏移值计算得到映射到AddressOfNames表对应字段的偏移值 [6],然后用修正后的偏移值 + AddressOfNames的PE文件地址得到AddressOfNames对应字段的PE文件地址,接下来调用_RVAToOffset函数将前述PE文件地址指向的RVA转化为PE文件偏移地址,再加上PE文件头的地址就转化为了指向函数文件名的PE文件地址。
② 如果表中的索引值与局部变量的索引值不相等,说明索引值对应的函数没有函数名,在此就用一个固定的字符串“(按照序号导出)”来代替函数名称。
;********************************************************************
; 循环显示导出函数的信息
;********************************************************************
mov ecx,[edi].NumberOfFunctions
mov @dwIndex,0
@@:
pushad
;********************************************************************
; 在按名称导出的索引表中
;********************************************************************
mov eax,@dwIndex
push edi
mov ecx,[edi].NumberOfNames
cld ;设置完成后DI与SI的增减方向是SI+,DI+
mov edi,@lpAddressOfNameOrdinals ;edi是一个从RVA转化来的PE文件地址,指向AddressOfNameOrdinals,该表是一个word类型数组,所以下面用scasw指令
repnz scasw ;scasw用AX中的数据 - es:edi位置的数据,完成后EDI+2,并用结果设置标志位。repnz会重复scasw指令直到cx=0或ZF=1
.if ZERO? ;找到函数名称 ;ZERO?是检测ZF是否置位的,置位为1
sub edi,@lpAddressOfNameOrdinals ;现在edi是AddressOfNameOrdinals中某一项的地址,用它减去AddressOfNameOrdinals的基址得到一个偏移地址
sub edi,2 ;这时edi的值指向找到的项目后面一个word的位置,因为scasw执行完成后EDI+2。所以将edi减去2就是找到的位置的偏移地址
shl edi,1 ;这里还要将偏移乘以2来修正一下,因为前面EDI+2就跳到了AddressOfNameOrdinals表的下一项,但下面要找的是AddressOfNames表的对应项,而AddressOfNames表是双字表,对于AddressOfNames表来说EDI+2还没跳过一项
add edi,@lpAddressOfNames ;用修正后的偏移值 + AddressOfNames的文件地址,这个地址是AddressOfNames中某一项的PE文件地址
invoke _RVAToOffset,_lpFile,dword ptr [edi] ;将AddressOfNames中某一项中的RVA转化为PE文件偏移,该偏移地址指向函数名称所在的位置
add eax,_lpFile ;将函数名称所在位置的偏移地址转化为PE文件地址
.else
mov eax,offset szExportByOrd
.endif
pop edi
(2) 获得导出序号:将索引号+导出函数的起始值——nBase字段的值。
;********************************************************************
; 序号 --> ecx
;********************************************************************
mov ecx,@dwIndex
add ecx,[edi].nBase
(3)获得虚拟地址(函数入口地址RVA):前面获取了函数地址表头部在PE文件中的地址esi,而函数地址表指向一系列RVA,每个RVA指向了每个不同的函数入口,现在要获取每一个函数的RVA,只要取到函数地址表的内容即可,也就是说只要获取esi的内容,就获取了指向第一个函数入口的RVA,因为函数地址表是一个双字表,所以在每次循环中将esi+4即可获取下一个函数入口的RVA。也就是说像现在这种获取全部函数入口的时,并不需要通过索引或者序号来查询,直接遍历获取函数入口就行了。
;********************************************************************
; 序号 --> ecx
;********************************************************************
xxx xxxxxxxxxxxxxx
xxx xxxxxxxxxxxxxx
XXXXX xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,\
xxxxxxxxxxxxxxxxxxxxx
XXXXX xxxxxxxxxxxxxxxxxxxxxxxxxxx
XXXXX
add esi,4
xxx xxxxxxxxxx
xxxx xxx
注释:
[1] 此处之所以说局部变量的索引值覆盖了所有函数的索引值,因为局部变量将在每个循环中+1,直到满足NumberOfFunctions的数量。而从上方的导出示意图可以看出,AddressOfFunctions的所有索引都是从零开始,然后是0+1、0+2……0+N-1,N=NumberOfFunctions。
[2] 这个校验是以 repnz scasw来实现的。这是一种特殊的遍历,当@dwIndex中的索引值与AddressOfNameOrdinals表中的某一字段相等,则ZF=1,repnz scasw的执行中止,并不需要遍历完AddressOfNameOrdinals表中的所有字段。如果没有任何一个字段相等,则当ecx为零时,repnz scasw的执行终止,此时遍历完了AddressOfNameOrdinals表中的所有字段。而这个ecx是以有名称的函数总数——NumberOfNames为数量设定的,因为NumberOfNames表示有名称的函数数量,而AddressOfNameOrdinals表作为一个中间表,对应着全部有名称的函数。
[3] 代码中用if ZERO?来校验这种情况。
[4] 因为AddressOfNameOrdinals中的每一个索引都有对应的函数名称。
[5] 也就是用AddressOfNameOrdinals当前字段的地址减去AddressOfNameOrdinals表的基值
[6] 从AddressOfNameOrdinals表的偏移位置对应到AddressOfNames表的过程中这样一个指令:shl edi,1,也就是乘以2,这是因为前面的AddressOfNameOrdinals表是word类型,所以EDI+2就跳到了的下一项,但要找的是AddressOfNames表的对应项,而AddressOfNames表是双字表,所以当EDI+4才表示跳过了AddressOfName表的一项,所以要乘以2。