PE重定位表深度解析:DLL基址偏移、手工解析与避坑指南
发布时间:2026/10/21 2:53:10 锦皓数字建站

前阵子调一个DLL加载失败的问题调试器一路追到了加载模块的内部代码最后发现根子出在重定位表上。想想挺感慨平时分析PE文件大家习惯先看导入表、导出表、节区头重定位表经常被一带而过可真出问题的时候它比任何一张表都折腾人。重定位表解决的是一个看起来很简单、细想又很绕的问题PE文件里写满了绝对地址但文件被加载到的内存基址不一定跟链接器当初设想的一样地址全错位了怎么办这篇文章不打算停留在有这么一个结构的层面而是把我自己从手工解析到工具辅助分析、再到实际踩坑的完整过程写下来。不管你是做逆向、写加载器、搞二进制分析还是刚入门PE格式想把这部分彻底弄明白照着文里的步骤都能自己复现一遍。1. 从搬家说起为什么每个PE文件里都要存一份重定位表1.1 链接器往代码里写死的绝对地址PE文件在被链接器生成的时候代码里的很多指令就已经把绝对地址写死了。举个例子一台32位程序里有一条mov eax, dword ptr [0x401234]这个0x401234不是随便来的它是链接器基于一个前提算出来的这个模块要被加载到0x400000这个基址而全局变量所在的位置距离基址0x1234于是就成了0x401234。同理call指令、跳转表、指针数组、异常处理表里到处都是这种基于预定基址算出来的绝对地址。这个预定基址在PE格式里叫ImageBase也叫首选基址。链接器编译时设定的EXE和DLL各有约定俗成的默认值MSVC链接器生成的32位EXE默认0x40000064位EXE默认0x14000000032位DLL默认0x1000000064位DLL默认0x180000000但这些都是可以改的/BASE链接选项就是干这个的。链接器在写这些绝对地址的时候是在一厢情愿地假设到时候这个文件就会加载到ImageBase这个位置。这个假设在大多数EXE身上是成立的但在DLL身上往往只是一个美好的愿望。1.2 加载器什么时候会拒绝原计划问题来了这个假设不是总能成立的。如果是EXE在不开启ASLR的老式加载场景里Windows加载器会尽量把进程地址空间留出一个和ImageBase相同的位置来所以EXE通常能如愿加载到首选基址。但DLL不行。DLL是要往别人的进程里塞的模块几个DLL可能都想用同一个基址先加载的那个占住了后来的就得换地方。再加上Windows Vista之后系统开始把ASLR地址空间布局随机化作为安全默认加载器在加载模块时会被迫或主动地给模块分配一个随机基址首选基址这种内定在ASLR面前就更靠不住了。ASLR的机制简单说就是每次加载同一个DLL基址都可能不一样。对于系统DLL、启用了/DYNAMICBASE的应用程序加载器会在一段可用的地址空间里随机挑一个高位地址作为模块基址而不是尊重ImageBase。基址一旦变了前面那句话就得重新考虑指令里写死的0x401234如果模块实际加载到0x5000000那么变量真实地址应该是0x501234但指令里还是0x401234一执行就访问了错误的内存轻则功能异常重则直接崩溃。1.3 修正的基本公式一切都在加一个delta加载器解决这个问题的方式简单粗暴但有讲究它把所有写死的绝对地址统一加上一个偏移量delta。delta 实际加载基址 - 首选基址例如DLL首选基址0x10000000实际被加载到0x20000000delta就是0x10000000。那么所有基于ImageBase算出来的绝对地址都加这个delta就对了新地址 指令中原有的绝对地址 delta这里有个很自然的联想如果是拿相对寻址写的代码比如x86下的E8/C3相对call、x64下常用的RIP相对寻址压根不存在这个问题因为地址是运行时算出来的。所以64位程序的指令很多都改用了RIP相对寻址重定位表里的条目往往比32位程序少一大截。这也是为什么同样功能的一个DLLx86版的重定位表可能几百个条目x64版可能只有几十个。但无论多少条加载器都必须知道一件事文件里哪些位置放着需要修正的绝对地址这些位置散落在代码节、数据节的各个角落不可能靠加载器自己去猜于是链接器在生成PE文件时就专门整理了一张清单把所有需要修正的位置统统写进去。这张清单就是重定位表。2. 逐字节拆解重定位表的数据布局2.1 块的骨架VirtualAddress与SizeOfBlock重定位表在PE的DataDirectory数组中排第6项索引为5。这个数组里的每一项都是8字节前4字节是表在虚拟地址空间中的RVA后4字节是这个表总共占多大。拿到这两个值就能在文件里定位到重定位数据。重定位表的主体由若干个块Block组成每个块对应内存中的一个4KB页面。块的结构体在Windows SDK里叫IMAGE_BASE_RELOCATION长这样typedef struct _IMAGE_BASE_RELOCATION { DWORD VirtualAddress; DWORD SizeOfBlock; WORD TypeOffset[1]; } IMAGE_BASE_RELOCATION;VirtualAddress本块所处理的页面的起始RVA注意一定是按页对齐的也就是0x1000的整数倍。SizeOfBlock本块的总大小包含上面两个DWORD以及后面跟着的所有TypeOffset条目。TypeOffset这就是一个接着一个的2字节条目真正描述哪里需要修正、用什么方式修正。块跟块之间是连续存放的前一个块结束的位置就是后一个块的开始位置。解析的时候只需要拿着块头里的SizeOfBlock往前跳就行。2.2 TypeOffset的位布局高4位是类型低12位是偏移每一个TypeOffset是一个16位的值它被拆成了两段15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 ------------------------------------- | 类型 | 页内偏移 | -------------------------------------高4位表示重定位类型低12位表示需要修正的位置相对于本块页面起始地址的偏移量。为什么是低12位因为一个页面是4KB2的12次方正好是4096页面内的所有字节偏移都能被12位覆盖。所以一个条目的完整RVA就按下面的方式算修正位置RVA 块的VirtualAddress (TypeOffset 0x0FFF)这个细节很容易踩坑我第一次自己解析的时候就直接把TypeOffset当成完整的RVA用了其实它只是页内偏移一定要把块的页基址加回来。2.3 常见重定位类型速查TypeOffset高4位的取值在winnt.h里有完整定义日常分析遇到最多的就下面几种取值宏名含义0IMAGE_REL_BASED_ABSOLUTE无效条目仅用于对齐填充不需要处理1IMAGE_REL_BASED_HIGH修正地址的高16位2IMAGE_REL_BASED_LOW修正地址的低16位3IMAGE_REL_BASED_HIGHLOW修正完整的32位地址32位程序最常见7IMAGE_REL_BASED_ARM_MOV32ARM平台的一条MOVW/MOVT对10IMAGE_REL_BASED_DIR64修正完整的64位地址64位程序最常见对x86和x64平台来说实战中见到的几乎就是两种类型3HIGHLOW和类型10DIR64。HIGHLOW表示这个位置上存的是一个32位绝对地址DIR64表示存的是一个64位绝对地址。修正的时候读出这个地址再加上delta写回去完事。剩下的一些类型HIGH、LOW、HIGHADJ等等主要出现在老式RISC平台或特殊场景里平时分析Windows PE基本碰不到。我初次手写解析器的时候只处理类型0、3、10已经能覆盖绝大多数PE文件。还有一点值得提每个块的最后几乎都会有一个TypeOffset为0x0000的条目。从位布局看它高4位是类型0ABSOLUTE低12位是偏移0既无类型又无偏移。这个条目纯粹是占位填充用的为了让块大小对齐、条目数量好算直接跳过它就行。如果你写解析器时没过滤类型0也不能算错只是会有很多无效条目被记录下来看结果时会觉得数据很脏。3. 手工解析实战完全不依赖第三方库的Python脚本这一节我不打算用pefile这类现成库因为用库只能让你会调用不能让你真懂。下面用一个纯Python脚本从字节流层面把一个PE文件的重定位表完整解析出来每一步我都拆开讲。3.1 第一步从DOS头一路找到重定位表目录PE文件开头是DOS头文件偏移0x3C处是一个4字节的e_lfanew字段它指向PE头的起始位置。PE头以PE\0\0四字节签名开头签名后面跟着20字节的FileHeader。其中我们要用到的字段有NumberOfSections文件偏移e_lfanew6节区数量SizeOfOptionalHeader文件偏移e_lfanew20可选头大小用来定位节区表OptionalHeader从PE头偏移24字节处开始。它的第一个字段是MagicPE32格式是0x10BPE3264位格式是0x20B。数据目录数组的起始位置32位在OptionalHeader偏移0x60处64位在0x70处。这是因为PE32把堆栈大小和堆大小从4字节扩展成了8字节整体往后挪了16字节。这里最容易搞混我翻过很多次文档才记住。重定位表目录是数据目录的第6项也就是索引5。每一项8字节前4字节是RVA后4字节是大小。import struct def get_reloc_directory(data): e_lfanew struct.unpack_from(I, data, 0x3C)[0] if data[e_lfanew:e_lfanew 4] ! bPE\x00\x00: raise ValueError(不是有效的PE文件) opt_offset e_lfanew 24 magic struct.unpack_from(H, data, opt_offset)[0] if magic 0x10B: dd_offset opt_offset 0x60 elif magic 0x20B: dd_offset opt_offset 0x70 else: raise ValueError(无法识别的OptionalHeader Magic: 0x%X % magic) rva, size struct.unpack_from(II, data, dd_offset 5 * 8) return rva, size这里有个小细节如果程序的/DYNAMICBASE没开或者编译时干脆禁用了重定位那么返回的rva和size都为0。解析函数拿到这两个0值要提前判断一下别拿着0继续找文件偏移。3.2 第二步把RVA换成文件偏移DataDirectory里给的是RVA是模块在虚拟内存中的相对地址。但我们要读的是磁盘文件同一份数据在文件里的位置和内存里的位置不是简单的直线对应关系需要先经过节区表转换。节区表在OptionalHeader紧后面每个节区描述占用40字节。我们关心的是VirtualAddressRVA基址、VirtualSize、PointerToRawData文件偏移、SizeOfRawData。换算规则很简单如果RVA落在某个节区的地址范围内那么文件偏移就是文件偏移 PointerToRawData (RVA - VirtualAddress)范围判断要用VirtualSize和SizeOfRawData里的较大值不然遇到节区末尾有填充数据、VirtualSize比SizeOfRawData小的情况会误判超出范围。def rva_to_offset(data, rva): e_lfanew struct.unpack_from(I, data, 0x3C)[0] num_sections struct.unpack_from(H, data, e_lfanew 6)[0] size_opt struct.unpack_from(H, data, e_lfanew 20)[0] sec_table e_lfanew 24 size_opt for i in range(num_sections): sec sec_table i * 40 vs struct.unpack_from(I, data, sec 8)[0] va struct.unpack_from(I, data, sec 12)[0] rs struct.unpack_from(I, data, sec 16)[0] ro struct.unpack_from(I, data, sec 20)[0] if va rva va max(vs, rs): return ro (rva - va) return None这一步看着简单实际上是整个手工解析链里最容易出错的环节。很多初学者喜欢直接用RVA去数文件字节数着数着就跟节区对齐规则打架了。记住PE里除了少数固定在头部区域的结构数据一律先过节区表别省这一步。3.3 第三步逐块读取重定位条目重定位RVA转成了文件偏移之后剩下的就是机械活了。解析逻辑是读8字节块头拿到page_rva和block_size。如果block_size为0说明表结束了直接break。条目数量 (block_size - 8) / 2。依次读2字节分解出类型和页内偏移。完整RVA page_rva 页内偏移。文件位置跳到下一个块pos block_size。def parse_relocations(data): reloc_rva, reloc_size get_reloc_directory(data) if reloc_rva 0 and reloc_size 0: return [] off rva_to_offset(data, reloc_rva) if off is None: return [] relocs [] pos off end off reloc_size while pos end: page_rva, block_size struct.unpack_from(II, data, pos) if block_size 0: break item_count (block_size - 8) // 2 for i in range(item_count): type_offset struct.unpack_from(H, data, pos 8 2 * i)[0] reloc_type (type_offset 12) 0x0F page_off type_offset 0x0FFF relocs.append((page_rva, page_off, reloc_type, page_rva page_off)) pos block_size return relocs我习惯把返回的元组记成(页面RVA, 页内偏移, 类型, 修正位置完整RVA)四元组这样后面做修正计算时不需要回头再算一遍。有一点值得注意重定位大小reloc_size很多时候会略大于最后一个块的实际内容。比如某些编译工具给重定位表末尾预留了几个字节的对齐填充或者DataDirectory里Size本身就包含了一些0的占位。我的解析循环用的是碰到block_size为0就停的策略比严格按end走更稳能避开尾部脏数据导致的越界。3.4 第四步模拟装载器修正过程并验证解析出条目之后最有成就感的一步就是亲手把重定位做一遍验证我们理解得对不对。首先要读出ImageBase。PE32的ImageBase在OptionalHeader偏移0x1C处是4字节PE32在0x18处是8字节。对DLL来说这个值就是首选基址。然后我们模拟系统加载器假设这个DLL被加载到了另一个基址然后对所有HIGHLOW和DIR64条目执行修正def get_image_base(data): e_lfanew struct.unpack_from(I, data, 0x3C)[0] opt_offset e_lfanew 24 magic struct.unpack_from(H, data, opt_offset)[0] if magic 0x10B: return struct.unpack_from(I, data, opt_offset 0x1C)[0] elif magic 0x20B: return struct.unpack_from(Q, data, opt_offset 0x18)[0] return 0 def simulate_reloc(data, relocs, actual_base): image_base get_image_base(data) delta actual_base - image_base print(ImageBase: 0x%X, 模拟加载基址: 0x%X, delta: 0x%X % (image_base, actual_base, delta)) fixed [] for page_rva, page_off, rtype, full_rva in relocs: if rtype 0: # ABS跳过 continue file_off rva_to_offset(data, full_rva) if file_off is None: continue if rtype 3: # HIGHLOW32位地址 old struct.unpack_from(I, data, file_off)[0] new (old delta) 0xFFFFFFFF fixed.append((full_rva, HIGHLOW, old, new)) elif rtype 10: # DIR6464位地址 old struct.unpack_from(Q, data, file_off)[0] new (old delta) 0xFFFFFFFFFFFFFFFF fixed.append((full_rva, DIR64, old, new)) return fixed验证的标准很简单修正后的新值减去delta应该等于原来的值反过来新值减去首选基址得到的相对偏移跟旧值减去首选基址得到的相对偏移必须完全一致。这也正好验证了重定位的本质——修改基址但保持偏移不变。比如一个32位DLL首选基址0x10000000模拟加载到0x20000000delta就是0x10000000。如果某条目修正前的值是0x10002000修正后必然是0x20002000。你把这个逻辑拿到调试器里看真实加载的DLL会发现完全吻合。4. 工具辅助分析从dumpbin到pefile我惯用的三种姿势手工解析能让你彻底明白原理但平时干活还是得靠工具提效。分享几个我实际常用的手段。4.1 dumpbin /relocations的输出长啥样MSVC工具链自带的dumpbin是最直接的系统级工具。打开开发者命令行执行dumpbin /relocations demo.dll输出是一块一块的以BASE RELOCATIONS #1开头下面每行先是页内偏移再是类型名。比如BASE RELOCATIONS #1 1000 RVA, 34 SizeOfBlock 14 HIGHLOW 22 HIGHLOW 40 HIGHLOW ... 0 ABS第一行告诉我们这是页基址0x1000的块块大小0x34字节。后面的每一行是条目(block_size-8)/2正好等于条目数量。最后那个0 ABS就是典型的填充项对应我们前面说的TypeOffset为0x0000的那一条。dumpbin的好处是零成本、官方出品、类型名直接给全称很适合验证自己写的解析器有没有读错。它的缺点是输出对自动化不友好一次只能看一个文件要批量提取还是得靠脚本。4.2 用pefile三行代码看全貌Python生态里最常用的PE解析库就是pefile。以前写分析脚本十次有八次会用到它。看重定位表就几行import pefile pe pefile.PE(demo.dll) for reloc in pe.DIRECTORY_ENTRY_BASERELOC: print(页面RVA 0x%X, 块大小 0x%X % (reloc.struct.VirtualAddress, reloc.struct.SizeOfBlock)) for entry in reloc.entries: print(类型 %d, 修正位置RVA 0x%X % (entry.type, entry.rva))注意这里entry.rva已经是完整的修正位置RVA内部帮你把页基址和页内偏移加好了不是TypeOffset里的原始低12位值写脚本的时候别再用错了。pefile最大的好处是能和其它分析无缝衔接。比如你要统计一个DLL里HIGHLOW和DIR64各有多少、修正位置集中在哪个节、某个特定RVA是否在重定位表里用pefile几分钟就能拼出结果。我写样本分析脚本时会把重定位信息作为模块特征的一部分输出和导入表、导出表、节区权限放在一起一眼就能看出一份PE的性格。4.3 图形化工具的直观对比图形化工具里我用得比较多的是CFF Explorer一类的PE查看器。在它的Relocations目录下可以看到每个块的页面基址、块大小展开后是每个条目的类型和偏移。鼠标点一下就能跳到对应虚拟地址这对初学者建立重定位条目到底指着哪的空间感很有帮助。不过图形工具也有局限样本一多、文件一大界面里几百上千个条目滚起来并不舒服。所以我的习惯是学习原理和验证想法时用图形工具实际批量分析时写Python脚本dumpbin则作为中间检查手段三种姿势按场景切换。5. 实战中容易踩的坑与几条经验5.1 块尾的ABS填充项前面反复提过每个块末尾都有一个类型为0、偏移为0的填充条目。如果你在统计这个重定位表到底修了几个地方时没过滤类型0结果会比真实值多出几个无意义的条目。我写过一次爬重定位表的自动化脚本最初输出条目数总是比dumpbin多排查了半天才发现是这个ABS填充项在作怪。过滤条件很简单rtype 0直接continue就可以了。另外block_size减8之后并不总能被2整除有些工具在块尾会额外补一个2字节的ABS条目让整体对齐到4字节这也是块尾ABS频繁出现的原因之一。5.2 重定位RVA超出文件范围怎么办解析过程中偶尔会遇到修正位置RVA转换不出文件偏移的情况。最常见的场景是重定位表指向了某个未被节区覆盖的区域或者指向了VirtualSize和RawSize处理不当的区域。这时候如果脚本直接返回None并跳过可能会漏掉真正的修正点但不跳过又会在越界读数据时崩溃。我的处理方式是遇到None时输出一条警告记录下这个RVA、对应的类型和所在的块然后继续往下走。至少不会让整个解析链中断。真正需要排查的时候把警告里的RVA丢到调试器里看看那块内存到底属于哪个节通常马上就能明白是文件被改过还是对齐边界的问题。5.3 没有重定位表的PE在加载时会怎样链接器提供了/FIXED选项可以生成不带重定位表的EXE很多驱动和BootLoader也习惯禁用重定位。但对普通DLL来说不带重定位表是很危险的。如果DLL声明了明确的基址需求又缺少重定位表而其他模块已经占用了这个基址Windows加载器会直接拒绝加载这个模块报错通常表现为应用程序无法正常启动一类的对话框调试器里则是映像格式无效、重定位被禁止之类的错误状态。有意思的是如果这个DLL恰好被加载到了首选基址比如系统给它单独留了位置那没有重定位表也能跑但ASLR开启之后这种恰好越来越难发生所以现代链接器为DLL生成重定位表几乎成了默认配置。这个现象在做恶意代码分析时也是个信号如果一个DLL的重定位表为空要么它真的不需要重定位要么它被刻意修改过准备配合其他手段固定加载基址。看到这种情况至少应该多留个心眼。5.4 加壳样本中重定位表为什么会消失前面讲的是干净PE文件的情况实际分析样本时还会碰到另一类问题重定位表被壳处理掉了。很多壳在加壳时会把原有的重定位表压缩、加密塞进壳自身的资源或数据区运行时解压后重建到内存里。结果就是磁盘上的PE文件DataDirectory里重定位目录的RVA和大小要么指向壳数据要么干脆为0。如果你用上面的脚本去解析一个加壳样本很可能得到没有重定位条目的结果——这不一定代表样本本身不需要重定位只是说原始的、静态可见的重定位表已经被搬走了。这也是为什么脱壳之后第一件事通常是检查导入表和重定位表是否恢复完整。一个脱壳失败的DLL即使代码逻辑看起来都对加载到非首选基址时照样会因为地址错乱而崩溃。理解了重定位表的原理你就知道脱壳时重建这一环真正在做什么以及为什么缺了它系统会不认账。说到底重定位表不是一个需要背诵的冷知识它是PE文件在被搬到别处人生中最重要的一场自我修正。把上面这些细节和脚本跑通一遍之后之后再遇到加载失败、DLL基址冲突、脱壳崩溃这类问题至少能知道往哪个方向查。我个人建议把这套手工解析脚本留在手边下次遇到可疑的PE文件先用它扫一眼重定位表往往比盲目看导入表更快暴露问题。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。