PE 文件 的 重定位表

文章目录

第1课:计算机执行程序时,指令里为什么会有数字地址?

当你写下一行C代码,比如:

c 复制代码
int x = 10;
int y = x + 5;

编译器会把它变成CPU能懂的机器指令。CPU在执行时,需要知道"变量 x 存放在内存的哪个位置"。内存中的每个位置都有一个编号,就像门牌号一样,这个编号就是地址。

所以,编译出来的机器指令里,可能直接写着一个数字,比如:

复制代码
把地址编号为 0x00402000 那里的4字节数据,复制到某个寄存器

这个 0x00402000 就是硬编码的绝对地址 。问题来了:编译器怎么知道变量 x 最终会被放在内存的哪个编号上? 答案是:它其实不知道。它只能做一个假设。


第2课:什么是基址 (ImageBase)?什么是 RVA?

操作系统在加载程序时,会给程序分配一整块连续的内存空间。这块空间的起始地址,就叫做基址 (ImageBase)。编译器在生成代码时,会假定程序一定被加载到某个特定的基址上,然后在那个基础上给所有变量和函数分配地址。

PE文件(Windows下的可执行文件格式,如 .exe、.dll)的头部里,就记录着这个假设的基址值。

  • RVA (相对虚拟地址) :一个变量或函数距离基址的偏移量。比如基址假设为 0x00400000,如果变量被放在基址开始往后数 0x2000 个字节的位置,那它的 RVA 就是 0x2000
  • 编译器生成的绝对地址 = 假设的基址 + RVA。

用表格总结概念:

概念 通俗解释 举例
内存地址 内存中每个字节的门牌号,用一个数字表示 0x00402000
基址 (ImageBase) 程序被加载到内存时的起始地址(编译器假设的值) 0x00400000 (常见EXE基址)
RVA (相对虚拟地址) 某个东西相对于基址的偏移量,不是最终的真实地址 0x2000
绝对地址 基址 + RVA,编译进指令里的那个硬编码数字 0x00402000

第3课:为什么有时必须修改这些硬编码地址?

情况1:EXE文件

每个EXE都有自己独立的虚拟地址空间,没有其他程序跟它抢地方。所以操作系统大概率能把它加载到它假设的那个基址上(比如 0x00400000)。如果实际基址 = 假设基址,那指令里的硬编码地址全都是正确的,不需要改。所以很多EXE根本不需要重定位表

情况2:DLL文件

DLL不能自己运行,它必须被加载到其他进程(通常是EXE)的地址空间里。可是那个进程可能已经占用了 DLL 假设的基址(比如 0x10000000)。另外,Windows从Vista开始默认开启 ASLR(地址空间布局随机化),每次加载DLL时,系统会故意给它随机挑选一个基址。结果就是:DLL的实际基址几乎总是和假设基址不一样。

如果实际基址 ≠ 假设基址,指令里那些硬编码的绝对地址就全错位了。举个例子:

项目 假设值(编译时) 实际值(加载时)
基址 0x10000000 0x5A000000
某个函数的 RVA 0x00001000 0x00001000 (RVA是不变的)
该函数的绝对地址 0x10001000 应该是 0x5A001000
指令中硬编码的地址 0x10001000 0x10001000错误!

如果不修正,程序就会跳转到 0x10001000,那里可能根本没有代码,然后崩溃。


第4课:解决方案 ------ 重定位表的基本思想

我们需要一张清单,告诉操作系统的加载器:"在代码或数据区的哪些位置,存放了一个需要根据实际基址修正的绝对地址?"。

当加载器发现 实际基址 ≠ 假设基址 时,它会计算出一个差值:

Delta = 实际基址 - 假设基址

然后遍历清单上的每一个位置,把那里存放的数值加上 Delta,就变成了正确的地址。

这张清单就是重定位表 (Relocation Table)。它长在PE文件里,由链接器生成。


第5课:重定位表在PE文件中的位置

PE文件有一个"数据目录 (Data Directory)",里面登记着各种重要表格的位置和大小。重定位表是第6项(索引为5)。它的位置和大小由数据目录中的两个字段给出:

字段 含义
VirtualAddress 重定位表在内存中的RVA(距离文件映射基址的偏移)
Size 重定位表的总字节数

加载器通过这个目录项找到重定位表,然后开始解析。


第6课:重定位表的整体结构 ------ 一个个"块"

重定位表不是一个平铺的地址清单,而是分块 组织的。每个块负责描述一个 4KB 大小 的内存页(一页正好是 4096 = 0x1000 字节)里需要修正的地址。为什么要按页分块?我们最后讲空间优化时会明白。

整个表由许多个重定位块 (Relocation Block) 首尾相连组成,最后以一个全零的块作为结束标志。

每个块的组成可以这样理解:

部分 大小 内容
块头部 8 字节 描述这个块对应哪个页,以及这个块总共有多大
重定位项数组 可变长度 一堆 2 字节的小条目,每个条目指出页内的具体偏移和类型
可能的填充 0~2 字节 为了让整个块的大小是4的倍数,尾部可能补充几个0

最终整个表的结构顺序:

块1 (8字节头部 + 若干条目 + 可能填充)

→ 块2 (8字节头部 + 若干条目 + 可能填充)

→ ...

→ 结束块 (8字节全0)


第7课:块头部详细拆解 ------ IMAGE_BASE_RELOCATION

每个块的开头8个字节是固定的结构,在C语言里长这样:

c 复制代码
struct IMAGE_BASE_RELOCATION {
    DWORD VirtualAddress;  // 4字节,本块对应的内存页起始RVA
    DWORD SizeOfBlock;     // 4字节,本块的总字节数(从头到尾,含填充)
};

我们用表格解释这两个字段:

字段 字节数 说明 必须满足的条件
VirtualAddress 4 该块所对应内存页的起始RVA。注意,是RVA,不是绝对地址。 必须是 0x1000 (4096) 的整数倍。也就是说,低12位必须全为0。
SizeOfBlock 4 整个块的大小,包括这个8字节头部、所有重定位项、以及尾部填充。 通常能被4整除,因为最终要对齐。

举个块头部的例子(十六进制):

复制代码
00 20 00 00  10 00 00 00

小端序解码后:

字节序列 对应的十六进制值 含义
00 20 00 00 0x00002000 VirtualAddress = 第2个4KB页的起始RVA
10 00 00 00 0x00000010 SizeOfBlock = 16 字节

第8课:从块头部计算出重定位项的个数

块头部后面紧跟着的就是重定位项。每个项固定 2 字节,所以我们可以算出有几个项:

项的数量 = (SizeOfBlock - 8) / 2

但要注意:SizeOfBlock 可能为了4字节对齐而包含了填充,所以这个除法计算出来的项数包含了那些可能仅仅是填充的"伪项"。我们解析的时候,需要根据每个项的类型来判断它是否有效(类型为0的项就是没用的,见下文)。


第9课:重定位项 ------ 2字节里的玄机

每个重定位项是一个 16 位的数字(WORD),它的位布局如下:

位范围 名称 长度 作用
11 ~ 0 (低12位) Offset 12位 表示在本页内的偏移量(0 ~ 4095)
15 ~ 12 (高4位) Type 4位 表示修正的方式(怎么修补那个地址)

也就是说,我们把一个2字节的数值 item 拆开:

  • Type = item >> 12 (右移12位,取高4位)
  • Offset = item & 0x0FFF (与0xFFF做按位与,取低12位)

重定位项要修正的那个数据的 RVA 是这样算出来的:

目标 RVA = 本块的 VirtualAddress + Offset

举个例子:

如果 VirtualAddress = 0x2000,某个条目的 Offset = 0x030,那么需要修正的数据位于 RVA = 0x2030 处。


第10课:Type 的取值和对应的修正操作

Type 字段告诉加载器,在目标位置存放的究竟是一个32位地址、64位地址、还是别的什么。下面是最常见的几种类型:

Type 值 宏名称 含义 加载器实际执行的操作
0 IMAGE_REL_BASED_ABSOLUTE 无操作,纯粹用于对齐填充 啥也不做,跳过
3 IMAGE_REL_BASED_HIGHLOW 修正一个完整的32位绝对地址(x86程序) 从目标RVA处读取4字节,加上Delta,再写回原处
10 (0xA) IMAGE_REL_BASED_DIR64 修正一个完整的64位绝对地址(x64程序) 从目标RVA处读取8字节,加上Delta,再写回原处
5 IMAGE_REL_BASED_HIGHADJ 修正一个被拆成高低两部分的高16位地址(非常罕见) 需要结合下一个重定位项一起处理,见下文

现代Windows程序最常见的就是 Type = 3 (32位) 和 Type = 10 (64位)。


第11课:特殊类型 Type = 5 的详细处理过程

在极其古老的某些CPU架构上(比如MIPS、Alpha),一条指令装不下完整的32位地址,只能拆成"高16位"和"低16位"两条指令。Type=5 就是用来修正这种高16位部分的。

处理一个 Type=5 的条目时,加载器会同时消耗掉当前条目和紧随其后的下一个条目(不管下一个条目的Type是多少)。步骤如下:

  1. 记当前条目为 Item1,它的 Offset 为 off1
  2. 读取 Item2(紧跟着的下一个2字节),它的 Offset 为 off2,Type 忽略。
  3. off1 指向的位置读取一个16位值,作为原来错误的"高16位"。
  4. off2 指向的位置读取一个16位值,作为"低16位"。
  5. 拼成完整32位值:完整值 = (高16位 << 16) + 低16位
  6. 给完整值加上 Delta:修正后完整值 = 完整值 + Delta
  7. 将修正后完整值的高16位写回 off1 指向的位置。
  8. 跳过 Item2,不让它再被单独处理。

除非你在逆向很老的Windows NT for MIPS 程序,否则几乎不会见到这种类型。


第12课:对齐和填充的具体表现

编译器为了加载效率,会让每个重定位块的总大小(SizeOfBlock)保持为 4的倍数。由于头部是8字节(已是4的倍数),但每个重定位项是2字节。如果项的个数是奇数,那么8 + N*2 会是一个除4余2的数。此时必须补充2个字节的填充(通常为0)。

示例计算:

条件 计算
假设某块有 3 个有效项 8 + 3×2 = 14 字节
14 不是4的倍数,需填充 加2字节 → 16字节
所以 SizeOfBlock = 16
你解析时会读到 4 个"项" ( (16-8)/2 = 4 ) 其中前3个是有效项,第4个是全零的填充项

那个全零的填充项,Type=0、Offset=0,加载器看到 Type=0 就知道应该忽略它。


第13课:结束标记

整个重定位表结束的标志是一个 全零的块头部。即:

字段
VirtualAddress 0x00000000
SizeOfBlock 0x00000000

注意:是头部8个字节全为0,而不是仅仅某个字段为0。当加载器读到一个块的头部发现这两个值都是0时,就知道重定位表到头了,停止处理。


第14课:完整走一遍手工解析示例

假设我们在一个x86 DLL的重定位表数据中找到这样一段16进制序列:

复制代码
00 20 00 00 10 00 00 00
30 30 18 30 00 00 00 00

按小端序逐字节分析:

第一步:解析块头部 (前8字节)

字节偏移 原始字节 组合后值(小端) 含义
0-3 00 20 00 00 0x00002000 VirtualAddress = 第0x2000页
4-7 10 00 00 00 0x00000010 (16) SizeOfBlock = 16字节

第二步:计算项数

项数 = (16 - 8) / 2 = 4 个项(但里面可能有填充)

第三步:读取4个2字节项 (偏移8~15)

字节偏移 原始字节 组合后值(小端) Type (高4位) Offset (低12位) 是否有效
8-9 30 30 0x3030 3 0x030 是 (Type=3)
10-11 18 30 0x3018 3 0x018 是 (Type=3)
12-13 00 00 0x0000 0 0x000 否 (填充)
14-15 00 00 0x0000 0 0x000 否 (填充)

可以看到,真正有效的重定位项只有前两个,后两个全零的条目是用于对齐填充,加载器会忽略它们。

第四步:计算需要修正的实际RVA

条目 VirtualAddress + Offset = 目标RVA 修正方式
第1项 0x2000 + 0x030 0x2030 读取该处的4字节值,加上 Delta
第2项 0x2000 + 0x018 0x2018 读取该处的4字节值,加上 Delta

假设实际基址是 0x5A000000,预设基址是 0x10000000,那么 Delta = 0x4A000000。加载器会去内存中RVA为 0x20300x2018 的地方,把那里存放的32位整数加上 0x4A000000


第15课:为什么这种设计能节省空间?

回顾一下,如果直接存放每个需要修正的完整RVA,每个地址就要用4字节。重定位表用"页基址+页内12位偏移"的方法,把每个位置的信息压缩到了2字节。虽然每个块多付出了8字节的头部开销,但这8字节被该页内的所有项分摊了。

方案 存储一个需要修正的地址 额外开销 对于一页内有100个地址的情况,总字节
直接存储完整RVA 4 字节/个 100 × 4 = 400 字节
重定位表分块 2 字节/个 每块8字节头部 8 + 100×2 = 208 字节
节省 约 48%

页内需要修正的地址越多,节省越显著。而实际上,一个DLL的代码和数据往往很密集地包含许多需要重定位的地址,所以这种压缩设计非常有价值。


第16课:加载器在内存中的完整处理流程

用文字描述加载器拿到重定位表后的每一个动作:

  1. 从PE数据目录第6项获取重定位表的RVA和大小。
  2. 将整个重定位表从文件映射到内存(属性通常为只读,并且在修正完成后可以释放)。
  3. 计算 Delta = 实际加载基址 - PE头中记录的预设基址
  4. 如果 Delta == 0,说明恰好加载到了理想位置,直接跳过重定位,什么也不用改。
  5. 如果 Delta != 0,则开始循环:
    • 把当前指针当作一个块头部,读取 VirtualAddressSizeOfBlock
    • 如果 VirtualAddress == 0SizeOfBlock == 0结束处理
    • 计算项数 count = (SizeOfBlock - 8) / 2
    • 将指针后移8字节,得到重定位项数组的起始位置。
    • 遍历 i 从 0 到 count-1
      • 读取 item = 数组[i] (2字节,小端序)。
      • 计算 type = item >> 12, offset = item & 0xFFF
      • 如果 type == 0,继续下一个 i。
      • 算出要修补的内存位置:pPatch = 实际基址 + VirtualAddress + offset
      • 如果 type == 3:读取 pPatch 处的32位值,加上 Delta,写回。
      • 如果 type == 10:读取 pPatch 处的64位值,加上 Delta,写回。
      • 如果 type == 5:执行前面详述的高16位调整,并额外增加 i(跳过下一个条目)。
    • 遍历完所有项后,将当前块指针向后移动 SizeOfBlock 字节,指向下一个块头部,继续循环。
  6. 全部修正完毕。此时程序里所有硬编码的绝对地址都已经变成基于真实基址的正确地址了。

总结表格:你需要记住的核心

问题 答案
为什么需要重定位表? 因为程序(尤其DLL)实际加载的基址可能与编译器假设的基址不同,硬编码的地址会出错,必须修正。
重定位表是什么? 一张清单,列出所有需要修正的地址在哪些位置,以及如何修正。
表由什么组成? 多个重定位块 + 一个全零结束块。每个块对应一个4KB内存页。
块头部包含什么? 页起始RVA (VirtualAddress) 和本块总大小 (SizeOfBlock)。
一个重定位项多大? 2 字节。高4位是类型 (Type),低12位是页内偏移 (Offset)。
最常见的类型? Type=3 (修正32位地址), Type=10 (修正64位地址)。Type=0 为填充忽略。
修正公式 新值 = 旧值 + (实际基址 - 预设基址)
如何判断表结束? 遇到一个块的 VirtualAddress 和 SizeOfBlock 都是 0。
为什么要这样设计? 节省空间。用2字节代替4字节存储地址偏移,通过分页头部分摊开销。
相关推荐
阿昭L3 个月前
PE文件之导入表(一):导入函数调用机制、导入表基本结构
逆向工程·pe文件
0xCC说逆向1 年前
Windows逆向工程提升之IMAGE_OPTIONAL_HEADER
汇编·windows·安全·架构·逆向·pe结构·pe文件
0xCC说逆向1 年前
Windows逆向工程提升之二进制分析工具:HEX查看与对比技术
汇编·windows·单片机·嵌入式硬件·安全·pe结构·pe文件
qwertyuiop_i1 年前
pe文件二进制解析(用c/c++解析一个二进制pe文件)
c语言·c++·pe文件
ぃ扶摇ぅ2 年前
PE文件(四)FileBuffer-ImageBuffer
pe文件