在上一篇文章里,我使用一个 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的函数目录也是可变长度的(函数个数是不确定的),因此它在元素中也是一个指针。而函数目录中函数信息又被扔出去,用指针指向它。
下面看一下相关代码,很短,通过上面的讲解,下面的代码就很明了,很简单了:
code_print_import_table
//
ImageRvaToVa.cpp : Defines the entry point for the console application.
//
#include
"
stdafx.h
"
#include
<
stdio.h
>
#include
<
windows.h
>
#include
<
Dbghelp.h
>
//
ImageRvaToVa
int
main(
int
argc,
char
*
argv[])
{
int
i, j;
HANDLE hFile
=
CreateFile(
"
E://RfCard.exe
"
,
//
PE文件名
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if
(hFile
==
INVALID_HANDLE_VALUE)
{
printf(
"
Create File Failed./n
"
);
return
0
;
}
HANDLE hFileMapping
=
CreateFileMapping(hFile, NULL, PAGE_READONLY,
0
,
0
, NULL);
if
(hFileMapping
==
NULL
||
hFileMapping
==
INVALID_HANDLE_VALUE)
{
printf(
"
Could not create file mapping object (%d)./n
"
, GetLastError());
return
0
;
}
LPBYTE lpBaseAddress
=
(LPBYTE)MapViewOfFile(hFileMapping,
//
handle to map object
FILE_MAP_READ,
0
,
0
,
0
);
if
(lpBaseAddress
==
NULL)
{
printf(
"
Could not map view of file (%d)./n
"
, GetLastError());
return
0
;
}
PIMAGE_DOS_HEADER pDosHeader
=
(PIMAGE_DOS_HEADER)lpBaseAddress;
PIMAGE_NT_HEADERS pNtHeaders
=
(PIMAGE_NT_HEADERS)(lpBaseAddress
+
pDosHeader
->
e_lfanew);
//
导入表的rva:0x2a000;
DWORD Rva_import_table
=
pNtHeaders
->
OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
if
(Rva_import_table
==
0
)
{
printf(
"
no import table!
"
);
goto
UNMAP_AND_EXIT;
}
//
这个虽然是内存地址,但是减去文件开头的地址,就是文件地址了
//
这个地址可以直接从里面读取你想要的东西了
PIMAGE_IMPORT_DESCRIPTOR pImportTable
=
(PIMAGE_IMPORT_DESCRIPTOR)ImageRvaToVa(
pNtHeaders,
lpBaseAddress,
Rva_import_table,
NULL
);
//
减去内存映射的首地址,就是文件地址了。。(很简单吧)
printf(
"
FileAddress Of ImportTable: %p/n
"
, ((DWORD)pImportTable
-
(DWORD)lpBaseAddress));
//
现在来到了导入表的面前:IMAGE_IMPORT_DESCRIPTOR 数组(以0元素为终止)
//
定义表示数组结尾的null元素!
IMAGE_IMPORT_DESCRIPTOR null_iid;
IMAGE_THUNK_DATA null_thunk;
memset(
&
null_iid,
0
,
sizeof
(null_iid));
memset(
&
null_thunk,
0
,
sizeof
(null_thunk));
//
每个元素代表了一个引入的DLL。
for
(i
=
0
; memcmp(pImportTable
+
i,
&
null_iid,
sizeof
(null_iid))
!=
0
; i
++
)
{
//
LPCSTR: 就是 const char*
LPCSTR szDllName
=
(LPCSTR)ImageRvaToVa(
pNtHeaders, lpBaseAddress,
pImportTable[i].Name,
//
DLL名称的RVA
NULL);
//
拿到了DLL的名字
printf(
"
-----------------------------------------/n
"
);
printf(
"
[%d]: %s/n
"
, i, szDllName);
printf(
"
-----------------------------------------/n
"
);
//
现在去看看从该DLL中引入了哪些函数
//
我们来到该DLL的 IMAGE_TRUNK_DATA 数组(IAT:导入地址表)前面
PIMAGE_THUNK_DATA32 pThunk
=
(PIMAGE_THUNK_DATA32)ImageRvaToVa(
pNtHeaders, lpBaseAddress,
pImportTable[i].FirstThunk,
NULL);
for
(j
=
0
; memcmp(pThunk
+
j,
&
null_thunk,
sizeof
(null_thunk))
!=
0
; j
++
)
{
//
这里通过RVA的最高位判断函数的导入方式,
//
如果最高位为1,按序号导入,否则按名称导入
if
(pThunk[j].u1.AddressOfData
&
IMAGE_ORDINAL_FLAG32
)
{
printf(
"
/t [%d] /t %ld /t 按序号导入/n
"
, j, pThunk[j].u1.AddressOfData
&
0xffff
);
}
else
{
//
按名称导入,我们再次定向到函数序号和名称
//
注意其地址不能直接用,因为仍然是RVA!
PIMAGE_IMPORT_BY_NAME pFuncName
=
(PIMAGE_IMPORT_BY_NAME)ImageRvaToVa(
pNtHeaders, lpBaseAddress,
pThunk[j].u1.AddressOfData,
NULL);
printf(
"
/t [%d] /t %ld /t %s/n
"
, j, pFuncName
->
Hint, pFuncName
->
Name);
}
}
}
UNMAP_AND_EXIT:
//
关闭文件,句柄。。
UnmapViewOfFile(lpBaseAddress);
CloseHandle(hFileMapping);
CloseHandle(hFile);
getchar();
return
0
;
}
【注意】:
(1)请在项目属性中的链接选项中,添加 Dbghelp.lib;
(2)上面对PE文件的名称采用了硬编码,如果为了灵活期间,可以从命令行获取PE文件的路径。
下面是我用某个PE文件所产生的输出:
output_result
FileAddress Of ImportTable:
00028000
-----------------------------------------
[
0
]: USER32.dll
-----------------------------------------
[
0
]
198
EndDialog
[
1
]
158
DialogBoxParamA
-----------------------------------------
[
1
]: KERNEL32.dll
-----------------------------------------
[
0
]
265
GetEnvironmentVariableA
[
1
]
27
CloseHandle
[
2
]
170
FlushFileBuffers
[
3
]
294
GetModuleHandleA
[
4
]
336
GetStartupInfoA
[
5
]
202
GetCommandLineA
[
6
]
372
GetVersion
[
7
]
125
ExitProcess
[
8
]
81
DebugBreak
[
9
]
338
GetStdHandle
[
10
]
735
WriteFile
[
11
]
429
InterlockedDecrement
[
12
]
501
OutputDebugStringA
[
13
]
318
GetProcAddress
[
14
]
450
LoadLibraryA
[
15
]
432
InterlockedIncrement
[
16
]
292
GetModuleFileNameA
[
17
]
670
TerminateProcess
[
18
]
247
GetCurrentProcess
[
19
]
685
UnhandledExceptionFilter
[
20
]
178
FreeEnvironmentStringsA
[
21
]
179
FreeEnvironmentStringsW
[
22
]
722
WideCharToMultiByte
[
23
]
262
GetEnvironmentStrings
[
24
]
264
GetEnvironmentStringsW
[
25
]
621
SetHandleCount
[
26
]
277
GetFileType
[
27
]
373
GetVersionExA
[
28
]
413
HeapDestroy
[
29
]
411
HeapCreate
[
30
]
415
HeapFree
[
31
]
703
VirtualFree
[
32
]
559
RtlUnwind
[
33
]
282
GetLastError
[
34
]
577
SetConsoleCtrlHandler
[
35
]
440
IsBadWritePtr
[
36
]
437
IsBadReadPtr
[
37
]
423
HeapValidate
[
38
]
191
GetCPInfo
[
39
]
185
GetACP
[
40
]
305
GetOEMCP
[
41
]
409
HeapAlloc
[
42
]
699
VirtualAlloc
[
43
]
418
HeapReAlloc
[
44
]
484
MultiByteToWideChar
[
45
]
447
LCMapStringA
[
46
]
448
LCMapStringW
[
47
]
339
GetStringTypeA
[
48
]
342
GetStringTypeW
[
49
]
618
SetFilePointer
[
50
]
636
SetStdHandle
到这里,我们基本算了解了import table。不过还需要额外解释一下的是,在iid中含有两个指向thunk数组(即IAT:导入地址表)的指针(orignal first thunk 和 first thunk),两个指针指向不同的地址,但这两个thunk数组的元素值是相同的,最终指向的是同一个地址(函数序号和名称元素)。也就是说,thunk数组在PE文件中有两份,他们的地址不同,但最终指向完全相同。画出图形的话是类似下面这样(注意最终指向相同,4个指针画出来类似一个菱形):
iid.OrignalFirstThunk--->thunk1[] --------|
|----> IMAGE_IMPORT_BY_NAME(hint, name[])
.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 指的得是绑定后它就成了实际函数地址。
typedef_iid_and_thunk
typedef
struct
_IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
//
0 for terminating null import descriptor
DWORD OriginalFirstThunk;
//
RVA to original unbound IAT (PIMAGE_THUNK_DATA)
};
DWORD TimeDateStamp;
//
0 if not bound,
//
-1 if bound, and real date/time stamp
//
in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
//
O.W. date/time stamp of DLL bound to (Old BIND)
DWORD ForwarderChain;
//
-1 if no forwarders
DWORD Name;
DWORD FirstThunk;
//
RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED
*
PIMAGE_IMPORT_DESCRIPTOR;
typedef
struct
_IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString;
//
PBYTE
DWORD Function;
//
PDWORD
DWORD Ordinal;
DWORD AddressOfData;
//
PIMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
假如引用的 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