Hoodlum1980 (fafa)'s Technological Blog

Languages mainly using and digging: C / CPP, ASM, C#, Python. Other languages:Java.

博客园 首页 新随笔 联系 订阅 管理

    在上一篇文章里,我使用一个 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,    00, 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, 000);
 
    
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, 0sizeof(null_iid));
    memset(
&null_thunk, 0sizeof(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。

posted on 2010-09-08 21:49  hoodlum1980  阅读(10546)  评论(1编辑  收藏  举报