读取PE文件的导入表

    在上一篇文章里,我使用一个 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].OriginalFirstThunk, //【注意】这里使用的是OriginalFirstThunk
            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 ;
}

 

     【注意】在上面的代码中我标示为高亮红色字体的部分,我使用的是 OriginalFirstThunk ,对于没有事先绑定的PE文件来说, OriginalFirstThunk 和 FirstThunk 是并行的内容相同的数组。但是对于已经经过绑定的PE文件来说, FirstThunk 数组中的元素会被设置成真正的函数地址(VA)!因此如果这时用 FirstThunk 数组尝试获取函数名称是得不到的。所以上面的代码应该使用 OriginalFirstThunk ,这样无论对于绑定还是未绑定的PE文件,都能够定向到相应的函数名称。

 

    【注意】:

    (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 [] )

    IID.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

 

    【修订历史】

    (1)把文中范例代码中的 FirstThunk 替换为 OriginalFirstThunk,并给出原因。2010-12-28 0:18:53。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
这个源码没记错的话是去年9月份写的,当时本来是打算写一个功能齐全一点的PE工具,例如解析导入、导出、重定位及节等功能,后来只写了一个解析导出就放弃了,为什么呢,主要是因为声明结构体比较烦,是一个体力活,所以后面转用VS开发这款工具去了,毕竟头文件里都将结构体定义好了,省事,现在把这个 易语言 版本的解析导出代码开源。 它可以解析出导出里正常导出的函数,还能解析出导出里的中转函数,见下图: 写这个工具的过程中发现PEID解析导出有点问题,见下图 注意这个图里3个红框里的内容,第一个是LORDPE解析的,中间是我自己写的代码解析的,最右边是PEID解析的,可以发现PEID解析导出时只能正常解析以符号名导出的函数,一旦遇到以序号导出的函数就会导致错位,图中user32.dll导出的前两个函数是以序号0x5DC和0x5DD导出的并没有导出函数名,而PEID不能正常解析,LORDPE、STUDYPE还有我自己写的代码都能正常解析,至于为什么导出序号那里,我的是从0开始的,而LORDPE和STUDYPE是以0x5DC开始的,那是因为我没有加上序号基数,中间那个蓝色箭头指向就是基数,函数真正的导出序号是要加上这个基数的,只不过我习惯这样达而已。另外开源前特意把里面变量的名字改成了一看就明白的意思,所以看起来有点奇怪。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值