摘要:根据PE文件导入表的结构及系统加载导入表的原理,在外壳中自定义了导入表,将外壳中的导入表与PE文件的导入表合并,并用Win32汇编编程实现。利用Windows加载PE文件时,将PE文件和外壳的导入表初始化,从而实现了在PE文件和外壳中正常调用API函数。
关键词:PE文件;外壳;导入表;合并导入表
中图分类号:TP309 文献标识码:A 文章编号:1009-3044(2015)03-0117-04
The Use of Windows to Load the Import Table of the PE File and Shell
ZHANG Zhong
(Chongqing University of Technology, Chongqing 400054,China)
Abstract: Based on the import table structure of the PE file and the principle of system loading the import table,it is defined in the shell. the import table of the shell and that of the PE file are combined in one by using Win32 assembly program. Which is initialized while windows loads PE file, so as to realize normal calling API functions in the PE file and shell.
Key words: PE file; shell; import table; merging import table
PE文件(EXE)经加壳后,由于程序功能上的需要,在外壳中或多或少地会用到API函数,而要调用API函数就需要知道API函数的地址。由于外壳代码是附加在编译链接好的PE文件上,因此,外壳中调用的API函数地址只能自己想法解决。在外壳中获得API函数地址常用的方法有二种:一种方法是在PE文件被加载的进程中由外壳中的代码自己动态的获取API函数的地址;另一种方法是在外壳中自定义一个所用API函数的导入表,利用Windows加载PE文件时,由系统加载外壳的导入表,从而获得API函数的地址。而PE文件的导入表就用外壳中的相关代码来初始化或外壳中的相关代码为PE文件重新构造还原一个导入表并初始化。那么有没有方法让系统加载PE文件时同时完成对PE文件和外壳自定义的导入表进行初始化呢?这就是本文所要讨论的问题。
1 PE文件8导入表和外壳导入表合并的基本思路
1.1 PE文件导入表的结构
图1 PE文件磁盘映像中的导入表结构(部分)
要自定义导入表和实现导入表的合并,首先要了解熟悉导入表的基本结构组成。PE文件的导入表是由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成的数组,每一个IMAGE_IMPORT_DESCRIPTOR结构对应一个DLL,导入表的最后由一个内容全为0 的IMAGE_IMPORT_DESCRIPTOR结构结束。该结构的定义如下:
IMAGE_IMPORT_DESCRIPTOR STRUCT
union
Characteristics dd ?
OriginalFirstThunk dd ? ;指向导入名称表(INT)的RVA
ends
TimeDateStamp dd ?
ForwarderChain dd ?
Name1 dd ? ;指向DLL名称(ANSI字符串,以0结尾)的RVA
FirstThunk dd ? ;指向导入地址表(IAT)的RVA
IMAGE_IMPORT_DESCRIPTOR ENDS
字段OriginalFirstThunk所指的导入名称表(Import Name Table,简称INT)由若干个IMAGE_THUNK_DATA结构组成的数组,每一个IMAGE_THUNK_DATA结构对应一个API导入函数,数组的最后由一个内容全为0的IMAGE_THUNK_DATA结构结束。该结构的定义如下:
IMAGE_THUNK_DATA STRUCT
union u1
ForwarderString dd ?
Function dd ? ;导入函数的入口地址
Ordinal dd ? ;导入API的序号
AddressOfData dd ? ;指向IMAGE_IMPORT_BY_NAME的RVA
ends
IMAGE_THUNK_DATA ENDS
从这个结构的定义可看到,该结构是一个共用体,实际上就是一个双字。当双字的最高位是1时,表示函数是以序号导入的,低31位就是函数的序号值;当最高位是0时,表示函数是以函数名称(ANSI字符串,以0结尾)导入的,双字表示是一个RVA,此时指向一个IMAGE_IMPORT_BY_NAME结构。IMAGE_IMPORT_BY_NAME结构定义如下所示。
IMAGE_IMPORT_BY_NAME STRUCT
Hint dw ? ;指示API函数在DLL导出表中的序号,有些编译器设为0
Name1 db ? ;导入函数的函数名(ANSI字符串,以0结尾)
IMAGE_IMPORT_BY_NAME ENDS
Windows在装入PE文件时,其工作之一是定位到导入表,根据导入表中说明的DLL,将DLL装入内存,在DLL中搜索导入表记录的API函数,找到后将对应的函数地址(指针)写入IAT,以方便程序正确调用API函数。
1.2 外壳中自定义的导入表(示例)
根据前面所叙的PE文件导入表结构,外壳中自定义的导入表如下(部分):
APPEND_CODE equ this byte ;外壳开始
ImportTableHeader label dword
;-----IMAGE_IMPORT_DESCRIPTOR结构数组-----
ImportTable dd _MessageBox-ImportTable ;OriginalFirstThunk
dd 0,0
dd DllUser32-ImportTable ;Name1
dd _MessageBox-ImportTable ;FirstThunk
dd 0,0,0,0,0 ;导入表结束符
;-------DLL名称字符串---------
DllUser32 db 'user32.dll'
dw 0
;---------IMAGE_THUNK_DATA结构数组------- _MessageBox dd Func1-ImportTable ;IMAGE_THUNK_DATA
_DialogBoxIndirectParam dd Func2-ImportTable
_EndDialog dd Func3-ImportTable
_GetDlgItemText dd Func4-ImportTable
_SetWindowText dd Func5-ImportTable
_SendDlgItemMessage dd Func6-ImportTable
_LoadIcon dd Func7-ImportTable
_SendMessage dd Func8-ImportTable
dd 0 ;结束符
;------IMAGE_IMPORT_BY_NAME结构数组------
Func1 dw 0
db 'MessageBoxA',0
Func2 dw 0
db 'DialogBoxIndirectParamA',0
Func3 dw 0
db 'EndDialog',0
Func4 dw 0
db 'GetDlgItemTextA',0
Func5 dw 0
db 'SetWindowTextA',0
Func6 dw 0
db 'SendDlgItemMessageA',0
Func7 dw 0
db 'LoadIconW',0
Func8 dw 0
db 'SendMessageW',0
…………
ImportTableEnd label dword
1.3 PE文件的导入表和外壳的导入表的合并思路
为了让Windows加载PE文件和外壳中的导入表,首先在外壳中要严格按照PE文件的导入表格式定义,然后将PE文件和外壳的导入表合并成一个IMAGE_IMPORT_DESCRIPTOR数组。由于在PE文件中的.idata节或.rdata节中空隙空间有限,不一定能装下外壳中的整个导入表,所以在这里是将PE文件的导入表移动到PE文件的原来最后一个节区的末尾处(新增加的.zzcode节区的开始处),然后再接上外壳的导入表,这样就合并成了一个完整的导入表。这又有二种拼接方法:(a)PE文件的导入表放在前面,外壳的导入表放在后面;(b)外壳导入表放在前面,PE文件的导入表放在后面。如下图2所示:
(a) (b)
图2 PE文件导入表与外壳导入表合并后的磁盘映像
2 利用Windows加载PE文件和外壳的导入表的程序实现
2.1 合并PE文件和外壳的导入表
这里用图2(a)中所示的导入表合并方案来说明如何编程实现导入表的合并。
1)首先由API函数CreateFile、CreateFileMapping、MapViewOfFile创建PE文件的内存映像,从PE开头定位到NT映像头IMAGE_NT_HEADERS,这里用ebx指向IMAGE_NT_HEADERS结构,由字段OptionalHeader、DataDirectory通过变量VirtualAddress,也就是 [ebx].OptionalHeader.DataDirectory[8].VirtualAddress定位到PE文件的导入表,然后用如下代码片段计算出导入表的字节长度。
xor ecx,ecx
assume esi:ptr IMAGE_IMPORT_DESCRIPTOR
.while [esi].OriginalFirstThunk || [esi].TimeDateStamp || \
[esi].ForwarderChain || [esi].Name1 || [esi].FirstThunk
inc ecx
add esi,sizeof IMAGE_IMPORT_DESCRIPTOR
.endw
mov eax,sizeof IMAGE_IMPORT_DESCRIPTOR
mul ecx
mov @IIDlength,eax
2)在PE文件中新增加一个节区如.zzcode,将PE文件的导入表写入该文件的新增加节区的开头处,然后将整个外壳(注意:要求外壳的导入表要放在外壳的最前面)写在紧接PE文件的导入表的后面,这样就实现了PE文件的导入表和外壳导入表的合并。其后就可用任意字节代码履盖掉PE文件原来位置的导入表。
3)修改PE文件导入表的指针使其指向合并后的导入表头部,同时修改合并导入表的大小,以确保系统加载PE文件时初始化合并后的导入表。代码片段如下:
mov eax,[ebx].VirtualAddress ;ebx指向PE文件新增加的节区
mov [edi].OptionalHeader.DataDirectory[8].VirtualAddress,eax
mov ecx,offset ImportTableEnd - offset ImportTableHeader
add ecx,@IIDlength
mov [edi].OptionalHeader.DataDirectory[8].isize,ecx
4)将外壳自定义的整个导入表读入由函数GlobalAlloc申请的内存块中,然后对导入表IMAGE_IMPORT_DESCRIPTOR结构中的OriginalFirstThunk、Name1、FirstThunk字段的双字地址进行修改,同时对IMAGE_THUNK_DATA结构中共用体u1中的AddressOfData字段的地址进行修改,将字段中的相对于外壳导入表头部的偏移offset转换为RVA。这样,当Windows加载外壳导入表时,通过内存PE文件的映像基地址+字段的RVA,就能准确定位需要查找DLL中的函数,并将函数地址填写入IAT,从而保证外壳中调用API函数时找到所对应的函数地址。偏移地址修改为RVA完成后,再将内存块中的整个导入表写回原来位置将原来的外壳导入表覆盖掉,至此,合并导入表的工作就完成了。进行这个地址转换的程序代码如下:
;修正外壳自定义导入表RVA子程序
DisposeImportTab proc _lphFile,_dwAddCodeFile,_dwAddCodeVirt
;_lphFile——文件句柄,_dwAddCodeFile——被加壳PE文件添加代码的位置,_dwAddCodeVirt——内存中添加代码的位置
local @lpAlloc1,@dwReadByte,@ImpTablength
pushad
mov esi,offset ImportTableEnd-offset ImportTableHeader
mov @ImpTablength,esi
invoke GlobalAlloc,GPTR,@ImpTablength
mov @lpAlloc1,eax
mov edi,eax ;指向自定义的导入表头部
mov ecx,_dwAddCodeFile
invoke SetFilePointer,_lphFile,ecx,NULL,FILE_BEGIN
invoke ReadFile,_lphFile,edi,esi,addr @dwReadByte,NULL
.if @dwReadByte
;在此修正导入表RVA地址的代码
assume edi:ptr IMAGE_IMPORT_DESCRIPTOR
mov eax,_dwAddCodeVirt
.while [edi].FirstThunk
add [edi].OriginalFirstThunk,eax
mov esi,@lpAlloc1
add esi,[edi].FirstThunk
add [edi].FirstThunk,eax
add [edi].Name1,eax
assume esi: ptr IMAGE_THUNK_DATA
.while [esi].u1.AddressOfData
add [esi].u1.Ordinal,eax
add esi,4
.endw
add edi,14h
.endw
assume edi:nothing,esi:nothing
mov ecx,_dwAddCodeFile
mov ebx,offset ImportTableEnd-offset ImportTableHeader
invoke SetFilePointer,_lphFile,ecx,NULL,FILE_BEGIN
invoke WriteFile,_lphFile,@lpAlloc1,ebx,addr @dwReadByte,NULL
invoke GlobalFree,@lpAlloc1
popad
mov eax,1
.else
invoke MessageBox,NULL,addr szImportTabErr,addr szCaptionTip,MB_OK
popad
mov eax,0
.endif
ret
DisposeImportTab endp
2.2 合并导入表的测试与分析
1)在Windos 7 和Windows XP SP2环境下,对示例PE文件和多个PE文件进行了加壳,对合并后的导入表进行了测试,程序原有各项功能运行正常,这说明PE文件的API函数调用,外壳中API函数调用工作正常,合并导入表达到预期目的。
2)用导入表查看工具软件查看加壳后的示例PE文件导入表,如图3所示是PE文件导入表(部分)磁盘映像,导入表字段OriginalFirstThunk指向INT,字段FirstThunk指向IAT;图4所示是外壳导入表(部分)磁盘映像,导入表字段OriginalFirstThunk和字段FirstThunk指向同一个IMAGE_THUNK_DATA,当被系统载入内存后它就转变成IAT了。
3 结束语
1)本文示例中外壳中的导入表和PE文件的导入表合并后放在外壳的最前面,其实合并后的导入表还可放置在外壳的最后面或外壳中的任意位置,只是这样编程实现时要复杂一些。
2)测试和分析表明:除了可把PE文件的导入表IMAGE_IMPORT_DESCRIPTOR结构数组移动到外壳中,实际上还可以把IMAGE_THUNK_DATA结构也移动到外壳中,不过这里就需要修正IMAGE_IMPORT_DESCRIPTOR结构中字段OriginalFirstThunk、FirstThunk的RVA值,以便正确的指向IMAGE_THUNK_DATA结构数组,保证Windows加载PE文件导入表时正确寻址找到INT和IAT,但IMAGE_THUNK_DATA结构中的共用体u1中的字段AddressOfData不必修正,因为IMAGE_IMPORT_BY_NAME结构的位置没有变动。同样,PE文件的导入表IMAGE_IMPORT_DESCRIPTOR结构数组虽然移动到外壳中,但由于IMAGE_THUNK_DATA结构数组的位置没有变化,所以不必修改其中的字段OriginalFirstThunk、FirstThunk的RVA值。
3)将PE文件的导入表IMAGE_IMPORT_DESCRIPTOR结构数组移动到外壳中,而将IMAGE_THUNK_DATA结构和IMAGE_IMPORT_BY_NAME结构留在PE文件中,也就是把整个导入表分割成了二部分,这样可以加强外壳与原程序的联系,如果简单地把PE文件的外壳脱去会导致系统初始化PE文件的导入表失败,从而使PE文件不能正常调用API函数而引发异常。
参考文献:
[1] 段钢. 加密与解密[M]. 3版. 北京: 电子工业出版社, 2008.
[2] 罗云彬. Windows环境下32位汇编语言程序设计[M]. 2版. 北京: 电子工业出版社, 2006.
[3] 张钟. 在远程进程中注入DLL钩挂IAT的方法[J]. 计算机与现代化, 2014(4).
[4] 戚利. WindowsPE权威指南[M]. 北京: 机械工业出版社, 2012.