逆向工程核心原理-13

逆向工程核心原理-第十三章-PE文件格式

本章主要介绍Windows系统下的PE文件格式, 内存映射, DLL等. 跟CSAPP的第七章有很多重叠, 但是CSAPP载体是Linux, 而本书是Windows

13.1 介绍

PE为Windows下的可执行文件格式, 由COFf发展而来.

名称由位数决定:

  • 32位: PE, 也叫 PE32
  • 64位: PE+, 也叫 PE32+

13.2 PE文件格式

PE文件分类:

  • 可执行文件: EXE SCR
  • 驱动程序: SYS VXD
  • 库: DLL OCX CPL DRV
  • 对象文件: OBJ

接下来使用了一个记事本程序来做实例, 使用的是Hex Editor工具

我们首先来看文件的头部分.

Untitled

13.2.1 基本结构

下图是普通PE文件的基本结构, 看到后面的知识点有点混的时候就来看这里

Untitled

我们现在进行的是分类:

  • PE头部分: DOS头 (DOS header) 到节区头 (Section header)
  • 后面的部分都称为PE体

然后是表示位置的不同方式:

  • 文件中使用偏移(offset)
  • 内存中使用VA(Virtual Address)

文件加载到内存中的时候, 情况会发生变化(CSAPP第九章, 比如常用的数据会留在内存, 不常用的留在磁盘)

PE头跟各节之间都有NULL填充, 这是为了提高处理文件的效率. 填充方式取决于”最小基本单元”, 其实位置必须是”最小基本单元”的整数倍

13.2.2 VA&RVA

RVA(Relative Virtual Address), 也就是相对虚拟地址, 指从某个基准位置(ImageBase)开始的相对地址.

1
2
3
满足下面的换算关系

RVA + ImageBase = VA

PE头内部信息大多以RVA形式存在, 因为重定位, 每个文件相对于基址的距离是不变的.

下面会按照自上而下的顺序介绍PE文件的组成

13.3 PE头

13.3.1 DOS头

DOS头的作用是支持对DOS的(向后)兼容.

整个结构体比较大, 但是要记的只有两个

1
2
3
4
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; //DOS签名:4D 5A 也就是"MZ"
WORD e_lfanew; //NT头的偏移
}

所有的PE文件开头都有DOS签名”MZ”.

Untitled

13.3.2 DOS存根

DOS存根在DOS头的下方, 是个可选项, 且大小不固定.

DOS存根由代码和数据混合而成, 当程序运行在DOS环境下会运行这部分代码, 而不是EP本身的代码, 而在Windows中则会运行EP本身的代码, 而忽略DOS存根.

Untitled

13.3.3 NT头

NT头: IMAGE_NT_HEADERS

结构体由三个成员组成:

  • 签名(Signature)结构体, 其值为”PE”
  • 文件头(File Header)结构体
  • 可选头(Optional Header)结构体

签名结构体

上面已经讲了, 其内容就是”PE”

Untitled

NT头: 文件头

文件头名: IMAGE_FILE_HEADER, 作用是: 描述各种执行信息

只用记四个重要成员:

  • Machine

值代表的是CPU的型号

  • NumberOfSections

PE文件节的数量

  • SizeOfOptionalHeader

表示可选头的长度

  • Characterisitics

标识文件的属性: 是否可运行, 是否为DLL文件, 以bit的形式

Untitled

NT头: 可选头

可选头名: IMAGE_OPTIONAL_HEADER32是PE头中最大的结构体, 名字叫可选, 但是非常重要.

下面是比较重要的成员:

  • Magic(魔数): 32位时值为10B; 64位时值为20B
  • AddressOfEntryPoint(入口点): 持有RVA(相对虚拟地址)的值, 相当重要
  • ImageBase: 基址, 非常重要
  • SectionAlignment, FileAlignment: FileAlignment指定磁盘文件最小单位, SectionAlignment指定内存文件最小单位, 二者不一定相同.
  • SizeOfImage(映射大小): 指定PE Image在虚拟内存中所占空间的大小, 一般文件大小跟加载内存大小不同.
  • SizeOfHeader(头大小): 指出整个PE头的大小, 该值必须时FileAlignment的整数倍
  • Subsystem: 用来区分驱动文件和普通可执行文件, 1为驱动, 2为窗口应用, 3为控制台应用.
  • NumberOfRvaAndSizes: 指定DataDirectory数组的个数(可选头的最后一个成员), 联系下一个一起看.
  • DataDirectory(数据表): 结构体数组有十六个(不一定)成员, 重要的有: 0号EXPORT(8个字节,四个字节指向导入表头元素,四个字节表示其大小), 1号IMPORT, 2号RESOURCE, 9号TLS, 后面会讲到.

Untitled

节区头

节区头名: IMAGE_SECTION_HEADER, 位与NT头下方, 每个节都有对应的节区头, 每个节区头都连在一起.

节区头记录一下几点: 节区起始位置, 节区大小, 访问权限( 是否可执行, 是否可读, 是否可写 )

以下为结构体较为重要的成员:

  • VirtualSize: 内存中节区所占大小
  • VirtualAddress: 内存中节区起始地址 (RVA)
  • SizeOfRawData: 磁盘文件中节区所占大小
  • PointerToRawData: 磁盘文件中节区其实位置
  • Charateristics: 节区属性 (bit OR)

VirtualAddress 和 PointerToRawData 由可选头中的SectionAlignment 和 FileAlignment 确定(这两个决定最小单位)

Untitled

13.4 RVA to RAW

下面是关于PE文件从 磁盘 到 内存 映射的内容.

这里跟csapp出入有些大, 所以换种记法,

  • 内存:RVA跟VirtualAddress是内存
  • 磁盘:RAW跟PointerToRawData是磁盘

这种映射叫: RVA to RAW

1
2
3
4
5
6
7
8
9
10
11
换算公式:
RAW - PointerToRawData = RVA - VirtualAddress

可推出:
Raw = RVA - VirtualAddress + PointerToRawData

RAW是内存中的地址(物理地址)
RVA是磁盘中的地址(虚拟地址)
virtualaddress是内存中节区基址(也是节区头的一个成员), 注意这个VA指的是节区头结构体的成员
而不是我们平常理解的VA(虚拟地址), 它也是用RVA来表示的.
pointertorawdata是磁盘中节区基址

13.5 IAT

IAT(Import Address Table, 导入地址表), IAT是一种表格, 用来记录程序正在使用哪些库中的哪些函数.

13.5.1 DLL

对DLL的特征:

  • 不用把库包含到程序中, 单独组成DLL文件, 需要时调用即可.
  • 内存映射技术加载后的DLL代码, 资源实现共享.
  • 更新库时只用替换相关DLL文件

加载DLL的方法:

  • 显式链接, 使用DLL时才加载, 使用完后释放内存
  • 隐式链接, 程序开始时一同加载DLL, 程序终止时释放内存

我们的重点IAT提供的机制是与隐式链接有关, 也就是程序一开始加载的DLL.

IAT实例

我们使用OD打开notepad.exe, 跟踪地址0x1002653(书上的图片)

Untitled

虽然一开始是使用的助记符, 但是可以看到实际上调用的是存放在0x1001104上的值来作为调用地址. 我们再跟进一下0x1001104:

Untitled

使用间接调用的而不是直接调用的原因是:

  • 重定位无法对实际地址编码(因为库的位置随时可变, 所以我们先预留一个地址, 在加载完库以后再把相应的函数地址放进这个预留的地址的内存中.)
  • 另一个原因是: 虚拟内存中表示地址使用的相对虚拟地址.

其中0x1001104就是我们的IAT

但是我们不仅需要知道什么是IAT, 还要知道它从哪里来的, 怎么找到它的, 它又有什么作用.

13.5.2 IMAGE_IMORT_DESCRIPTOR(导入描述符)

IMAGE_IMORT_DESCRIPTOR即导入描述符结构体描述了导入的库文件

我们来看看IMAGE_IMORT_DESCRIPTOR导入描述符结构体的组成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这个结构体是导入描述符
typedef struct _IMAGE_IMORT_DESCRIPTOR{
union{
DWORD Characteristics;
DWORD OriginalFirstThunk;//这个是INT(Import Name Table导入名称表)的地址
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name; //指向库名称的指针
DWORD FirstThunk; //这个是IAT(Import Address Table导入地址表)的地址
}IMAGE_IMORT_DESCRIPTOR;

这个结构体是INT中的元素
typedef struct IMAGE_IMORT_BY_NAME{
WORD Hint;
BYTE Name[1];
}IMAGE_IMORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

其中比较重要的成员:

  • OriginalFirstThunk : INT导入名称表的地址(记简写的同时也要记住中文含义, 才方便理解)
  • FirstThunk: IAT导入地址表的地址
  • Name: 库名称字符串的地址

下面是各个定义之间的关系图

Untitled

虽然图中INT跟IAT指向的是相同的地址, 但是在很多情况下它们是不一致的.

接下来是了解IAT从哪里来, 是PE装载器把导入函数输入至IAT, 接下来是详细过程:

  • 读取IID(导入描述符, 也就是一个库), 获取库名称
  • 装载相应的库
  • 读取IID的OriginalFirstThunk(INT的地址), 获取INT地址
  • 注意读取INT数组的值, 获取相应的IMAGE_IMORT_BY_NAME ( 也就是说INT数组是一个结构体指针数组, 每个元素都指向一个IMAGE_IMORT_BY_NAME结构体 ) 的Hint成员或Name成员. 获取相应函数的起始地址
  • 读取IID的FirstThunk(IAT的地址), 获取IAT地址
  • 讲上面获取的函数地址输入相应的IAT数组值(也就是说IAT是一个指针数组, 每个元素都指向一个函数的开头地址)
  • 重复步骤 4 - 7 , 知道INT结束

我在这里再用自己的语言描述一下过程: PE装载器是主体, 通过库的导入描述符找到了一个函数的INT, 再通过INT所指向的结构体(INT是一个地址数组, 每个地址指向一个IMAGE_IMORT_BY_NAME结构体)的成员找到相应的IAT元素, 并将函数地址给到对应的IAT元素.

IMAGE_IMPORT_DESCRIPTOR(导入描述符)数组(注意是数组, 因为要引入多个库, 这个数组就叫做导入表)位于PE文件的PE体中, 但是它的地址信息存放在PE头 → NT头 → 可选头结构体 → DataDirectory[1]结构体 → VirtualAddress中.

这里再讲一下DataDirectory结构体的组成:

  • RVA of xxxxxxx: 存有xxxxxxx的相对虚拟地址
  • size of xxxxxxx: 存有xxxxxxx的内存字节大小

Untitled

我们来看看notepad的DataDirectory数组的前三个元素

:

Untitled

前三个元素分别用来描述: 导入表的位置及其大小, 导出表的位置及其大小, 资源的位置及其大小

我们先关注重点导入表, 其RVA = 0x7604, 我们使用上面的RVA → RAW的公式

1
2
3
RVA - VirtualAddress = RAW - PointToRawData
0x7604 - 0x1000 = RAW - 0x400
得到了RAW = 0x6A04, 也就是在磁盘中的文件偏移, 也就是我们在hexeditor中的地址

Untitled

这里红框及其以下全部都是IMAGE_IMPORT_DESCRIPTOR导入描述符结构体, 红框内是第一个IMAGE_IMPORT_DESCRIPTOR导入描述符结构体, 对应第一个导入库.

我们来列出notepad的重要成员:

Untitled

接下来逐个跟进以下

1. 库名称(Name)

如上图, 其成员的值为: 0x7AAC ( 注意这是RAV, 因为程序内引用使用的是RVA )

1
2
3
4
5
使用上面的公式,  0x7AAC位于.text节

RVA - VirtualAddress = RAW - PointToRawData

0x7AAC - 0x1000 = RAW - 0x400

得到了名称字符串的磁盘文件偏移为0x6EAC, 使用HexEditor查看该地址

Untitled

得到了库名为: comdlg32.dll

2. OriginalFirstThunk - INT(导入名称表的地址)

该成员的值为: 0x7990, ( RAV )

1
2
3
4
5
使用公式转换,  0x7990位于.text节

RVA - VirtualAddress = RAW - PointToRawData

0x7990 - 0x1000 = RAW - 0x400

得到了INT的磁盘文件偏移为0x6D90, 使用HexEditor查看该地址

Untitled

我们再一次回顾上面的介绍: INT是一个地址数组(是一个地址数组, 每一个地址元素都指向一个IMAGE_IMORT_BY_NAME结构体), 所以通过这个地址我们可以找到相应的IMAGE_IMORT_BY_NAME结构体.

接下来我们再一次跟进这个地址, 来到相应的IMAGE_IMORT_BY_NAME结构体中.

3. IMAGE_IMORT_BY_NAME

我们跟进的是INT[0] = 0x7a7a

1
2
3
4
5
使用公式转换,  0x7a7a位于.text节

RVA - VirtualAddress = RAW - PointToRawData

0x7a7a - 0x1000 = RAW - 0x400

得到该结构体的磁盘文件偏移为: 0x6E7A, 使用HexEditor查看该地址

Untitled

得到了IMAGE_IMORT_BY_NAME结构体:

  • 前面的两个字节(小端排序): 得到了Ordinal(序数), 即0x000F
  • 后面的为函数名称字符串”PageSetupDlgW”, 字符串末尾以’\0’结尾

后面会有对Ordinal序数的用法解释, 我们先继续了解IID导入描述符.

4. FirstThunk - (IAT 导入地址表)

成员FirstThunk的值为: 0x12C4, 仍然使用RAV → RAW的公式, 得到RAW: 0x6C4

我们使用HexEditor查看

Untitled

IAT也是一个结构体指针数组, 每个元素指向一个结构体.

IAT的第一个元素值(四个字节)被硬编码为0x76324906, 该值无实际意义, 加载的时候回有准确的地址代替该值.

使用OD查看IAT

Untitled

其中IAT的地址为0x10012C4, 其值为0x30 6b 0b 77(跟上面的不一样), 是API准确的起始地址.

我们跟进0x306b0b77

跟进不了………

先继续吧.

13.6 EAT

EAT为一种核心机制, 它使不同的应用程序可以调用库文件中提供的函数. 也就是说, 只有通过EAT才能准确求得从相应库中导出函数的起始地址. 和IAT一样, PE文件内的特定结构体(IMAGE_EXPORT_DIRECTORY)保存导出信息, 且只有一个用来用来说明EAT的结构体.(因为PE可以引入多个库, 但是只能导出一个)

PE文件的PE头 → NT头 → 可选头结构体 → DataDirectory[0]结构体 → VirtualAddress中. 保存了IMAGE_EXPORT_DIRECTORY结构体数组的起始地址. 跟IAT相似.

下面是IMAGE_EXPORT_DIRECTORY的重要成员:

  • NumberOfFunctions 实际Export函数的个数
  • NumberOfNames Export函数中具有名称的函数个数
  • AddressOfFunctions Export函数地址数组
  • AddressOfNames 函数名称地址数组
  • AddressOfNameOrdinals Ordinal地址数组

下面是导出地址表的关系图

Untitled

从库中获取函数地址的API为GetProcAddress()函数, 下面展示它如何获取函数地址

以下是GetProcAddress()操作原理及其步骤:

  • 利用NumberOfNames成员得到函数名数组
  • 在函数名数组中存储字符串地址, 通过strcmp比较字符串, 查找指定的函数名称. 此时数组的索引称为name_index
  • 利用AddressOfNameOrdinals找到orinal数组.
  • 在orinal中通过name_index找到相应的orinal值
  • 通过AddressOfFunctions找到函数地址数组(EAT)
  • 用orinal值作为索引, 获得指定函数的起始地址

函数名 → name_index → orinal → 在EAT中用orinal找到准确的函数地址

使用kernel32.dll练习刚刚的EAT

通过书中提供的地址找到了IMAGE_EXPORT_DIRECTORY结构体RAW为1

Untitled

这个就是IMAGE_EXPORT_DIRECTORY结构体的内存

按照上面的过程走一遍

1. 查看NumberOfNames

首先是NumberOfNames成员RVA = 0x353C, 可以以算出RAW = 0x293C

使用HexEditor查看

Untitled

得到的是字符串指针数组, 我们查看的是2号元素地址: 0x4BBC(这里跟原书上有差异, 原书为4BBD)

得到了RVA = 0x4BBC, 计算得出RAW = 0x3FBC

2. 查看指定名称字符串

得到了RVA = 0x4BBC, 计算得出RAW = 0x3FBC, 通过HexEditor查看, 内容也跟原书不一样, 是另一个函数, 不过也好, 有差异会更有意思一点.

Untitled

3. Oridinal数组

下面查找AddConsoleAliasA函数的Ordinal值

其中AddressOfNameOrdinals成员的值为RVA = 0x441c, 转换成RAW = 0x381c

使用HexEditor查看

Untitled

4. ordinal

将第二步中求得的index值(书中是2, 这里是4)应用到第三步中的ordinal组成的书数组中

5. 函数地址数组 - EAT

最后查找AddAtomW的实际函数地址.AddressOfFunctions的值为RVA = 0x2654(跟书中一样), 所以可计算得到RAW = 0x1A54, 使用HexEditor查看

Untitled

6. AddConsoleAliasA函数地址

使用前面的Ordinal作为索引, 得到了RVA = 0x071CA1, 因为ImageBase的值为0x7C7D0000, 所以该函数的虚拟地址为: 0x7C7D0000 + 0x71CA1 = 0x7C841CA1

高级PE

13.7.2 Patched PE(打补丁的PE)

PE规范只是一个书面的标准, 意思是: 如果按照这个规范一丝不苟的完成, 那么一定是对的; 如果没有按照这个规范, 也不一定是错的, 我们平时的程序中就没有用到一些结构体成员, 也能够正常运行.

文章作者: LamのCrow
文章链接: http://example.com/2022/03/23/第十三章-PE文件格 c8367/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 LamのCrow