详解二进制PE文件 | 导入表技术

admin 2022年11月1日07:58:38评论62 views字数 8102阅读27分0秒阅读模式

概述

导入表,位于PE文件格式中的可选PE头中DataDirectory字段的第二项(索引值为1)

为了减少不同应用程序之间的重复性功能文件,windows将一部分功能函数抽出,最终形成对应的DLL文件,DLL文件提供对外的导出表,供外部应用程序调用。

本文主要谈论关于导入表加载流程相关问题。

//资源目录表,索引值为一的元素存储导入表结构地址
_IMAGE_DATA_DIRECTORY DataDirectory[1];

PE结构简述

具体PE文件的作用介绍在此处就不再介绍,PE基本结构图如下:

详解二进制PE文件 | 导入表技术

导入表

让我们将可选PE头结构展开观察

可选PE头中定义了大量关于PE结构的重要信息,包括等重要信息。虽然本文谈论的导出表结构不在其中定义,但是进入导出表的大门(地址)被记录在可选PE头的最后一个字段属性中 --

C语言中对可选PE头的定义结构体如下:

typedef struct _IMAGE_OPTIONAL_HEADER 
{

    WORD    Magic;                   // 32位PE文件是010Bh,64位PE文件是20B*
    BYTE    MajorLinkerVersion;      // 链接程序的主版本号
    BYTE    MinorLinkerVersion;      // 链接程序的次版本号
    DWORD   SizeOfCode;              // 所有含代码的节的总大小,按照FileAlignment对齐后的大小
    DWORD   SizeOfInitializedData;   // 所有含已初始化数据的节的总大小
    DWORD   SizeOfUninitializedData; // 所有含未初始化数据的节的大小
    DWORD   AddressOfEntryPoint;     // 程序执行入口OEP,加载到内存后的偏移值*
    DWORD   BaseOfCode;              // 代码的区块的起始RVA
    DWORD   BaseOfData;              // 数据的区块的起始RVA
    //
    // NT additional fields.    以下是属于NT结构增加的领域。
    //
    DWORD   ImageBase;               // 程序的首选装载地址,加载到内存的首地址(程序基址),但是随机基址可能不会使用此值*
    DWORD   SectionAlignment;        // 内存中的区块的对齐大小*
    DWORD   FileAlignment;           // 文件中的区块的对齐大小*
    WORD    MajorOperatingSystemVersion;  // 要求操作系统最低版本号的主版本号
    WORD    MinorOperatingSystemVersion;  // 要求操作系统最低版本号的副版本号
    WORD    MajorImageVersion;       // 可运行于操作系统的主版本号
    WORD    MinorImageVersion;       // 可运行于操作系统的次版本号
    WORD    MajorSubsystemVersion;   // 要求最低子系统版本的主版本号
    WORD    MinorSubsystemVersion;   // 要求最低子系统版本的次版本号
    DWORD   Win32VersionValue;       // 莫须有字段,不被病毒利用的话一般为0
    DWORD   SizeOfImage;             // 映像装入内存后的总尺寸(按照内存对齐后的大小)*
    DWORD   SizeOfHeaders;           // DOS头 + DOS Stub + PE头 + 可选PE头 + 区段头(最终需要用文件区块大小对齐)*
    DWORD   CheckSum;                // 映像的校检和
    WORD    Subsystem;               // 可执行文件期望的子系统
    WORD    DllCharacteristics;      // DllMain()函数何时被调用,默认为 0*
    DWORD   SizeOfStackReserve;      // 初始化时的栈大小
    DWORD   SizeOfStackCommit;       // 初始化时实际提交的栈大小
    DWORD   SizeOfHeapReserve;       // 初始化时保留的堆大小
    DWORD   SizeOfHeapCommit;        // 初始化时实际提交的堆大小
    DWORD   LoaderFlags;             // 与调试有关,默认为 0 
    DWORD   NumberOfRvaAndSizes;     // 下边数据目录的项数,这个字段自Windows NT 发布以来一直是16*
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];  //数据目录表(PE核心)* 
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

将其绘成内存空间占比图,可以看出可选PE头中一半以上空间都被数据目录表所占:

详解二进制PE文件 | 导入表技术


内存空间占比图

研究资源目录表结构

资源目录表记录了各项资源的入口点,该字段也是整个PE研究的

数组编号 英文描述 中文描述
00 Export table address and size 导出表地址和大小
01 Import table address and size
02 Resource table address and size 资源表地址和大小
03 Exception table address and size 异常表地址和大小

Certificate table address and size 属性证书数据地址和大小
05 Base relocation table address and size 基地址重定位表地址和大小

Debugging information starting address and size 调试信息地址和大小

Architecture-specific data 预留为 0

Global pointer register relative virtual address 指向全局指针寄存器的值
09 Thread local storage (TLS) table address and size 线程局部存储地址和大小
10 Load configuration table address and size 加载配置表地址和大小
11 Bound import table address and size
12 Import address table address and size
13 Delay import descriptor address and size

CLR Runtime Header address and size CLR运行时头部数据地址和大小

Reserved 系统保留

C语言对IMAGE_DATA_DIRECTORY定义如下:

//数据目录表详细构成
typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;  //虚拟地址,相对内存的偏移
    DWORD   Size;    //表大小,该值可以被修改,但是该值不影响表大小
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

详解二进制PE文件 | 导入表技术

image-20221028113021295

内存空间占比图

开始上正题 ----- 导入表

我们自己编写的应用程序时,程序会调用系统开放的接口函数(API)。因此,在使用这些功能函数时,就需要将对应的系统DLL,并获取DLL中所调用函数对应地址。

C语言对导入表的定义如下

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;            // 0 for terminating null import descriptor
        DWORD   OriginalFirstThunk;         // 包含指向IMAGE_THUNK_DATA(输入名称表)结构体的数组
    } DUMMYUNIONNAME;
    //此字段为0,FirstThunk也指向输入名称表,与OriginalFirstThunk作用相同
    //如果该字段为-1,表示FirstThunk存储了真实的地址值
    DWORD   TimeDateStamp;                  // 当可执行文件不与输入的dll绑定时,此字段为0
    DWORD   ForwarderChain;                 // 第一个被转向的API的索引
    DWORD   Name;         // 指向被输入的DLL的acii字符串的RVA
    DWORD   FirstThunk;                     // 指向输入地址表(IAT)的RVA,IAT是一个IMAGE_THUNK_DATA结构的数组
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

根据次结构可以看出,结构中存储了外部DLL的名称,但是没有我们想要的对应函数地址。但是OriginalFirstThunk字段指向了一个新的结构体IMAGE_THUNK_DATA。

详解二进制PE文件 | 导入表技术

image-20221028151317948

内存空间占比图

IMAGE_THUNK_DATA结构定义

typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;  //指向一个转向者字符串的RVA;
        DWORD Function;             // 被输入的函数的内存地址
        DWORD Ordinal;    //被输入的API的序数值
        DWORD AddressOfData;        // 指向IMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

C语言对_IMAGE_THUNK_DATA32的定义,看着一大串内容,实际上仅仅占用了四个字节,主要使用AddressOfData字段含义。在此处也没有发现函数名称和函数地址。但是AddressOfData又指向了另一个结构体 ----- IMAGE_IMPORT_BY_NAME。

IMAGE_IMPORT_BY_NAME结构体定义

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint; //函数编号
    CHAR    Name[1];//表示函数名的字符串偏移
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

直到遇见IMAGE_IMPORT_BY_NAME,我们才勉强又看见好像是函数名称的字节命名(Name),但是细看会发现,该字节定义仅仅有大小。

然我们整理下这些结构体的寻址过程:

详解二进制PE文件 | 导入表技术

image-20221028153846915

暂定的寻址模型-1

根据具体文件进行分析(简单PE文件)

详解二进制PE文件 | 导入表技术

image-20221028161847478

文件对齐:0x200

导入表RVA:0x19188

导入节RVA:0x19000

导入节大小:0x8A3

导入节在文件中偏移:0x6400

计算导入表在文件中的具体位置

0x19188 - 0x19000 + 0x6400 = 0x6588

根据刚刚讨论,现在在文件中0x6588位置上的应该是结构体_IMAGE_IMPORT_DESCRIPTOR,其第一个字段指向了_IMAGE_THUNK_DATA32结构体,而该结构体又指向了_IMAGE_IMPORT_BY_NAME结构体

详解二进制PE文件 | 导入表技术

image-20221028164218711

但是新的问题就出来了,明明_IMAGE_IMPORT_BY_NAME结构体的第二个NAME字段只有一个字节,为什么能够指向这么多字符的字符串。

  • 在编译、链接生成PE文件时,NAME一个字节只需要记录字符串的长度,然后利用动态内存分配,获取指定字符串长度的空间大小。
  • 在静态解析PE时,没有用到NAME一个字节中存储的值,因为成型的PE文件NAME字段存储的是某一个函数名的首字母,但当前空间的地址是可以利用的,只需要按照输出当前地址,就能输出整个函数名称(因为它是以0结尾的字符串)。

经过一次分析可以看出导出表结构和开始分析的一样,原因是只使用了一个外部函数,此文件源码如下:

#include <stdio.h>
#include <windows.h>

int main(){
 MessageBox(NULL, TEXT("Hello"), TEXT("World"), MB_OK);
 return 0;
}

如果我们使用了多个外部函数,并且存在多个函数在不同的DLL文件中的情况。那么PE文件的导入表如何构成

根据具体文件进行分析(复杂PE文件)

接下来我们分析下Notepad这个经典的exe文件

详解二进制PE文件 | 导入表技术

image-20221028170136669
#寻找_IMAGE_OPTIONAL_HEADER结构
0x204BC - 0x20000 + 0x1CC00 = 0x1D0BC

详解二进制PE文件 | 导入表技术

image-20221028170653307
#寻找_IMAGE_THUNK_DATA32结构
0x206EC - 0x20000 + 0x1CC00 = 0x1D2EC

详解二进制PE文件 | 导入表技术

image-20221028170725437
#寻找_IMAGE_IMPORT_BY_NAME结构
0x20BA4 - 0x20000 + 0x1CC00 = 1D7A4

详解二进制PE文件 | 导入表技术

image-20221028171845300

我们同样可以根据规律找到Notepad中的导入函数名称。但是问题是,自己的导入表中仅有一个函数,但是在OpenProcessToken函数后边还有其他的导入函数,那么后边的导入函数是谁去索引。

_IMAGE_THUNK_DATA32结构特点:四字节,存储了函数名称地址(_IMAGE_IMPORT_BY_NAME结构地址),我们返回去重新计算_IMAGE_THUNK_DATA32结构的下一组值

详解二进制PE文件 | 导入表技术

image-20221028172156605
0x20BB8 - 0x20000 +0x1CC00 = 0x1D7B8

详解二进制PE文件 | 导入表技术

image-20221028172341330

可以发现每一个函数名称地址(_IMAGE_IMPORT_BY_NAME)都被_IMAGE_THUNK_DATA32结构(四字节)进行存储。在本文最上边的暂定寻址模型需要进行更新。

详解二进制PE文件 | 导入表技术

image-20221028172751682

暂定的寻址模型-2

暂定的寻址模型-2仅仅解决了同一DLL中的函数名称记录,如果存在不同DLL呢,此时就需要利用更上一层的结构体_IMAGE_OPTIONAL_HEADER来保存。让我们看一下最终的导入表寻址模型。

详解二进制PE文件 | 导入表技术

image-20221028173334609

导入表最终寻址模型

详解二进制PE文件 | 导入表技术

image-20221028191033239

在文件中的构成排列

导入表的双桥结构

从开始到现在,聊了这么多,也才仅仅找到了对应的函数名称位置,在真正获取函数地址时,还有一小段录需要走。那就是需要了解下双桥结构。

详解二进制PE文件 | 导入表技术

image-20221028192529456

双桥结构图

前述文章只讲述了OriginalFirstThunk字段的指向过程,但是FirstThunk字段与之相同。在PE文件未加载时,两字段都指向相同的位置,但是如果将PE文件装载到内存中,FirstThunk所指向的_IMAGE_THUNK_DATA32结构体数组,其中存储的就不是函数名称地址,而是对应可执行函数的首地址。

详解二进制PE文件 | 导入表技术

image-20221028192542955

因此,OriginalFirstThunk和FirstThunk也存有一个别名:

  • OriginalFirstThunk:INT(导入名称表)
  • FirstThunk:IAT(导入地址表)

最终,我们可以在中,获取想要执行的函数在内存中的地址。

导入地址表从函数名变为内存地址过程

根据当前系统的DLL查找顺序,寻找相同名称的DLL,然后再DLL中获取查找对应的地址

  • 先从已经加载到内存的dll中找同名的dll,找到了就加载。
  • 从系统已知dll列表查找。这个列表记录在注册表里面。
  • 应用程序所在目录。
  • 当前工作目录。
  • 系统目录(%System%)
  • Windows目录(%Windir%)
  • 环境变量PATH中指定的目录(%PATH%)

DLL加载后,会在TEB、PEB环境中遍历到同名DLL,在对应DLL的导出表中,比对函数名称,比对成功后找到对应函数地址,修改到自己的导入表中。追中完成导入表的装载。

注意:不同版本操作系统,DLL加载顺序可能不同,甚至系统对DLL加载顺序会引起DLL劫持问题。具体细节参考百度。

额外的延伸问题

如果,导入表内容庞大,那么每次加载可执行PE文件时,修正导入地址表的就需要考虑。既然已经了解了导入地址表的修正过程,那么我们是否可以考虑将导入地址表进行固化。程序每次执行直接进行指定地址的调用,跳过

能够考虑IAT表绑定固化的前提:

  • 操作系统是在应用程序之前进行加载执行,因此系统DLL加载时间,一般也在应用之前
  • 每次加载顺序相同,DLL中函数地址更变的概率也不大
  • 但是不同版本操作系统加载同名DLL,其中函数地址不尽相同。

如果真的进行手工替换导入地址表,造成新的两个问题:

  • DLL没有在指定内存中,IAT中的地址就是错误的
  • DLL不存在时,IAT中的地址是失效的

绑定导入表失效怎么修复

  • 前述文章说过,PE的导入表是双桥结构,其中IAT表被绑定为固定的地址,但是INT表并没有断链
  • 如果IAT表中地址错误,那么根据之前聊过的规律,利用INT表中的DLL名称与函数名称,也是能找到对应的函数地址的

如何判断PE是否使用了绑定导入表

  • 导入表中存有一个存储时间戳的字段(TimeDateStamp)来判断是否使用绑定导入表
    • TimeDateStamp = 0x0 :未使用
    • TimeDateStamp = 0xFFFFFFFF:使用导入表

小总结

  • 在画图的过程中,可以发现一个有趣的问题,微软设计的结构体都是四字节对齐状态
  • 及时存有不对齐空隙,也会被所谓的“保留字段”所填充
==================
参考学习资料:
01、滴水逆向的部分视频内容
02、琢石成器:Windows下32位汇编语言程序设计(第3版)

原文始发于微信公众号(0x00实验室):详解二进制PE文件 | 导入表技术

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年11月1日07:58:38
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   详解二进制PE文件 | 导入表技术https://cn-sec.com/archives/1383499.html

发表评论

匿名网友 填写信息