逆向工程核心原理-第十三章-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工具
我们首先来看文件的头部分.
13.2.1 基本结构
下图是普通PE文件的基本结构, 看到后面的知识点有点混的时候就来看这里
我们现在进行的是分类:
- 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 | 满足下面的换算关系 |
PE头内部信息大多以RVA形式存在, 因为重定位, 每个文件相对于基址的距离是不变的.
下面会按照自上而下的顺序介绍PE文件的组成
13.3 PE头
13.3.1 DOS头
DOS头的作用是支持对DOS的(向后)兼容.
整个结构体比较大, 但是要记的只有两个
1 | typedef struct _IMAGE_DOS_HEADER { |
所有的PE文件开头都有DOS签名”MZ”.
13.3.2 DOS存根
DOS存根在DOS头的下方, 是个可选项, 且大小不固定.
DOS存根由代码和数据混合而成, 当程序运行在DOS环境下会运行这部分代码, 而不是EP本身的代码, 而在Windows中则会运行EP本身的代码, 而忽略DOS存根.
13.3.3 NT头
NT头: IMAGE_NT_HEADERS
结构体由三个成员组成:
- 签名(Signature)结构体, 其值为”PE”
- 文件头(File Header)结构体
- 可选头(Optional Header)结构体
签名结构体
上面已经讲了, 其内容就是”PE”
NT头: 文件头
文件头名: IMAGE_FILE_HEADER, 作用是: 描述各种执行信息
只用记四个重要成员:
- Machine
值代表的是CPU的型号
- NumberOfSections
PE文件节的数量
- SizeOfOptionalHeader
表示可选头的长度
- Characterisitics
标识文件的属性: 是否可运行, 是否为DLL文件, 以bit的形式
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, 后面会讲到.
节区头
节区头名: IMAGE_SECTION_HEADER, 位与NT头下方, 每个节都有对应的节区头, 每个节区头都连在一起.
节区头记录一下几点: 节区起始位置, 节区大小, 访问权限( 是否可执行, 是否可读, 是否可写 )
以下为结构体较为重要的成员:
- VirtualSize: 内存中节区所占大小
- VirtualAddress: 内存中节区起始地址 (RVA)
- SizeOfRawData: 磁盘文件中节区所占大小
- PointerToRawData: 磁盘文件中节区其实位置
- Charateristics: 节区属性 (bit OR)
VirtualAddress 和 PointerToRawData 由可选头中的SectionAlignment 和 FileAlignment 确定(这两个决定最小单位)
13.4 RVA to RAW
下面是关于PE文件从 磁盘 到 内存 映射的内容.
这里跟csapp出入有些大, 所以换种记法,
- 内存:RVA跟VirtualAddress是内存
- 磁盘:RAW跟PointerToRawData是磁盘
这种映射叫: RVA to RAW
1 | 换算公式: |
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(书上的图片)
虽然一开始是使用的助记符, 但是可以看到实际上调用的是存放在0x1001104上的值来作为调用地址. 我们再跟进一下0x1001104:
使用间接调用的而不是直接调用的原因是:
- 重定位无法对实际地址编码(因为库的位置随时可变, 所以我们先预留一个地址, 在加载完库以后再把相应的函数地址放进这个预留的地址的内存中.)
- 另一个原因是: 虚拟内存中表示地址使用的相对虚拟地址.
其中0x1001104就是我们的IAT
但是我们不仅需要知道什么是IAT, 还要知道它从哪里来的, 怎么找到它的, 它又有什么作用.
13.5.2 IMAGE_IMORT_DESCRIPTOR(导入描述符)
IMAGE_IMORT_DESCRIPTOR即导入描述符结构体描述了导入的库文件
我们来看看IMAGE_IMORT_DESCRIPTOR导入描述符结构体的组成
1 | 这个结构体是导入描述符 |
其中比较重要的成员:
- OriginalFirstThunk : INT导入名称表的地址(记简写的同时也要记住中文含义, 才方便理解)
- FirstThunk: IAT导入地址表的地址
- Name: 库名称字符串的地址
下面是各个定义之间的关系图
虽然图中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的内存字节大小
我们来看看notepad的DataDirectory数组的前三个元素
:
前三个元素分别用来描述: 导入表的位置及其大小, 导出表的位置及其大小, 资源的位置及其大小
我们先关注重点导入表, 其RVA = 0x7604, 我们使用上面的RVA → RAW的公式
1 | RVA - VirtualAddress = RAW - PointToRawData |
这里红框及其以下全部都是IMAGE_IMPORT_DESCRIPTOR导入描述符结构体, 红框内是第一个IMAGE_IMPORT_DESCRIPTOR导入描述符结构体, 对应第一个导入库.
我们来列出notepad的重要成员:
接下来逐个跟进以下
1. 库名称(Name)
如上图, 其成员的值为: 0x7AAC ( 注意这是RAV, 因为程序内引用使用的是RVA )
1 | 使用上面的公式, 0x7AAC位于.text节 |
得到了名称字符串的磁盘文件偏移为0x6EAC, 使用HexEditor查看该地址
得到了库名为: comdlg32.dll
2. OriginalFirstThunk - INT(导入名称表的地址)
该成员的值为: 0x7990, ( RAV )
1 | 使用公式转换, 0x7990位于.text节 |
得到了INT的磁盘文件偏移为0x6D90, 使用HexEditor查看该地址
我们再一次回顾上面的介绍: INT是一个地址数组(是一个地址数组, 每一个地址元素都指向一个IMAGE_IMORT_BY_NAME结构体), 所以通过这个地址我们可以找到相应的IMAGE_IMORT_BY_NAME结构体.
接下来我们再一次跟进这个地址, 来到相应的IMAGE_IMORT_BY_NAME结构体中.
3. IMAGE_IMORT_BY_NAME
我们跟进的是INT[0] = 0x7a7a
1 | 使用公式转换, 0x7a7a位于.text节 |
得到该结构体的磁盘文件偏移为: 0x6E7A, 使用HexEditor查看该地址
得到了IMAGE_IMORT_BY_NAME结构体:
- 前面的两个字节(小端排序): 得到了Ordinal(序数), 即0x000F
- 后面的为函数名称字符串”PageSetupDlgW”, 字符串末尾以’\0’结尾
后面会有对Ordinal序数的用法解释, 我们先继续了解IID导入描述符.
4. FirstThunk - (IAT 导入地址表)
成员FirstThunk的值为: 0x12C4, 仍然使用RAV → RAW的公式, 得到RAW: 0x6C4
我们使用HexEditor查看
IAT也是一个结构体指针数组, 每个元素指向一个结构体.
IAT的第一个元素值(四个字节)被硬编码为0x76324906, 该值无实际意义, 加载的时候回有准确的地址代替该值.
使用OD查看IAT
其中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地址数组
下面是导出地址表的关系图
从库中获取函数地址的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
这个就是IMAGE_EXPORT_DIRECTORY结构体的内存
按照上面的过程走一遍
1. 查看NumberOfNames
首先是NumberOfNames成员RVA = 0x353C, 可以以算出RAW = 0x293C
使用HexEditor查看
得到的是字符串指针数组, 我们查看的是2号元素地址: 0x4BBC(这里跟原书上有差异, 原书为4BBD)
得到了RVA = 0x4BBC, 计算得出RAW = 0x3FBC
2. 查看指定名称字符串
得到了RVA = 0x4BBC, 计算得出RAW = 0x3FBC, 通过HexEditor查看, 内容也跟原书不一样, 是另一个函数, 不过也好, 有差异会更有意思一点.
3. Oridinal数组
下面查找AddConsoleAliasA函数的Ordinal值
其中AddressOfNameOrdinals成员的值为RVA = 0x441c, 转换成RAW = 0x381c
使用HexEditor查看
4. ordinal
将第二步中求得的index值(书中是2, 这里是4)应用到第三步中的ordinal组成的书数组中
5. 函数地址数组 - EAT
最后查找AddAtomW的实际函数地址.AddressOfFunctions的值为RVA = 0x2654(跟书中一样), 所以可计算得到RAW = 0x1A54, 使用HexEditor查看
6. AddConsoleAliasA函数地址
使用前面的Ordinal作为索引, 得到了RVA = 0x071CA1, 因为ImageBase的值为0x7C7D0000, 所以该函数的虚拟地址为: 0x7C7D0000 + 0x71CA1 = 0x7C841CA1
高级PE
13.7.2 Patched PE(打补丁的PE)
PE规范只是一个书面的标准, 意思是: 如果按照这个规范一丝不苟的完成, 那么一定是对的; 如果没有按照这个规范, 也不一定是错的, 我们平时的程序中就没有用到一些结构体成员, 也能够正常运行.