资讯动态

PE文件节合并实战:从结构解析到RVA修正与重定位处理

发布时间:2026/10/22 6:14:38 来源:尧图企业网站定制
1. 为什么需要合并节从PE文件结构说起PE文件Portable Executable是Windows平台下可执行文件、动态链接库、驱动文件的标准格式。只要你用过Windows就绕不开它。但很多人对PE的理解停留在“双击能跑就行”的层面一旦遇到需要修改、精简、加固或者做静态分析的场景就不知道从哪下手了。合并节Section Merging就是PE文件改造中一个非常实用、但资料零散的技术点。先说清楚合并节到底解决什么问题。PE文件在磁盘上按“节”来组织代码和数据常见的节有.text代码、.rdata只读数据、.data可读写数据、.rsrc资源等。每个节在文件里占一段空间加载到内存时又按内存对齐粒度映射到虚拟地址空间。节越多文件头里的节表Section Table就越大加载器需要处理的映射项也越多。合并节的核心目的就是把多个功能相近或权限相同的节合并成一个节从而减少节的数量缩小节表体积有时还能让文件在磁盘上更紧凑。那为什么有人要干这件事我总结下来主要有三类需求。第一类是文件体积优化尤其是做壳、做保护、做嵌入式分发的时候每减少一个节节表就少40字节同时可能减少因节对齐产生的空洞。第二类是静态分析对抗把代码和数据混在一起让逆向分析的人不容易一眼看出哪个节是干什么的。第三类是格式规范化有些老旧工具或特定加载场景对节的数量和排列有要求合并后能通过校验。但合并节不是随便把两段数据拼在一起就完事。PE文件有严格的对齐规则、权限标记和重定位要求。合并错了轻则文件跑不起来重则系统直接报“不是有效的Win32应用程序”。所以下面我会从整体设计思路开始一步步拆到实操细节把踩过的坑都摆出来。2. 合并节的整体设计与核心思路拆解2.1 节合并的三种常见策略在实际操作中合并节并不是只有一种做法。根据目标不同我通常把它分成三种策略同权限合并、全量合并和选择性合并。同权限合并是最安全的做法。比如.text和.rdata都是只读可执行或只读不可执行合并后权限标记取交集或更严格的一方加载器不会报权限冲突。全量合并则是把所有节都塞进一个节里文件头最小但权限管理最麻烦因为代码段需要可执行数据段需要可写合并后要么全部可写可执行安全性差要么需要运行时动态改权限复杂度高。选择性合并介于两者之间只合并那些访问模式一致、生命周期重叠的节。我个人的经验是除非你有明确的体积压缩指标否则优先选同权限合并。因为Windows加载器对节权限的检查虽然不算严格但杀毒软件和EDR产品会对“可写且可执行”的节格外敏感全量合并很容易触发告警。2.2 为什么不能简单拼接对齐与RVA的约束很多人第一次尝试合并节时会想当然地把两个节的数据直接首尾相接然后改一下节表里的VirtualSize和SizeOfRawData。这么做大概率会失败原因在于PE文件有两套对齐体系文件对齐FileAlignment和节对齐SectionAlignment。文件对齐通常是0x200512字节节对齐通常是0x10004096字节。每个节在磁盘上的起始偏移必须是FileAlignment的整数倍在内存中的起始RVA必须是SectionAlignment的整数倍。如果你把两个节拼在一起新节的磁盘偏移可能不满足对齐要求加载器读取时就会出错。更关键的是RVA相对虚拟地址。PE文件里大量的数据结构——导入表、导出表、重定位表、资源目录——都存储的是RVA。合并节后如果节的起始RVA变了这些RVA全部需要修正。修正RVA不是改一个数字那么简单你需要遍历所有引用这些地址的地方包括代码里的绝对地址、数据目录里的指针、重定位块里的偏移。这也是为什么合并节通常要和重定位表一起处理。2.3 合并后的权限标记怎么定节的权限由节表里的Characteristics字段控制常见标志有标志含义典型节IMAGE_SCN_MEM_EXECUTE可执行.textIMAGE_SCN_MEM_READ可读几乎所有节IMAGE_SCN_MEM_WRITE可写.dataIMAGE_SCN_CNT_CODE包含代码.textIMAGE_SCN_CNT_INITIALIZED_DATA包含已初始化数据.data合并两个节时Characteristics要取并集还是交集答案是取并集但要有取舍。比如.text可执行可读和.rdata可读合并结果应该是可执行可读这样代码还能跑只读数据也不会被写坏。但如果.text和.data合并结果就是可执行可读可写这就危险了。我的做法是合并后如果必须包含可写数据就把可执行标志去掉然后在代码里通过VirtualProtect动态给需要执行的区域加权限。虽然麻烦一点但能避开大部分安全软件的启发式检测。3. 核心细节解析与实操要点3.1 节表结构的逐字段解读要合并节必须先吃透节表结构。每个节表项占40字节定义如下以C语言结构体表示typedef struct _IMAGE_SECTION_HEADER { BYTE Name[8]; // 节名如.text union { DWORD PhysicalAddress; DWORD VirtualSize; // 内存中的实际大小 } Misc; DWORD VirtualAddress; // 内存中的RVA DWORD SizeOfRawData; // 磁盘上的大小按FileAlignment对齐 DWORD PointerToRawData; // 磁盘上的偏移 DWORD PointerToRelocations; // 重定位偏移通常为0 DWORD PointerToLinenumbers; // 行号偏移通常为0 WORD NumberOfRelocations; // 重定位数量 WORD NumberOfLinenumbers; // 行号数量 DWORD Characteristics; // 权限标志 } IMAGE_SECTION_HEADER;合并时你需要新建一个节表项把两个旧节的数据按对齐规则拼好然后更新以下字段VirtualSize两个节VirtualSize之和再按SectionAlignment向上取整。VirtualAddress取第一个节的VirtualAddress通常保留靠前的那个。SizeOfRawData两个节SizeOfRawData之和再按FileAlignment向上取整。PointerToRawData第一个节的PointerToRawData。Characteristics两个节标志的并集但要去掉冲突位。注意VirtualSize和SizeOfRawData不一定相等。VirtualSize是内存实际使用量SizeOfRawData是磁盘占用。合并时两个都要算不能只改一个。3.2 数据目录的RVA修正清单合并节后以下数据目录项里的RVA可能需要修正导出表Export Table如果导出表在合并的节里其RVA要改。导入表Import Table导入表通常放在.rdata或独立节合并后RVA变化。资源表Resource Table资源目录树里全是RVA合并.rsrc时必改。异常表Exception Tablex64程序特有存放栈展开信息。基址重定位表Base Relocation Table如果合并后基址变了重定位块里的偏移要重算。调试目录Debug Directory通常指向文件偏移而非RVA但也要检查。TLS表Thread Local StorageTLS回调地址是VA合并后要修正。我一般会写一个脚本遍历所有数据目录把落在被合并节范围内的RVA统一加上一个偏移量。偏移量等于新节的起始RVA减去旧节的起始RVA。如果旧节在新节前面偏移量为正如果旧节被移到了后面偏移量为负。3.3 重定位表的处理技巧重定位表是合并节时最容易翻车的地方。PE文件里的重定位表由多个重定位块组成每个块对应一个4KB页面块内记录该页面内所有需要修正的地址偏移。合并节后如果节的起始RVA变了重定位块里的偏移也要跟着变。处理方法是先解析所有重定位块把每个重定位项的RVA算出来块起始RVA 项内偏移然后根据新旧节的映射关系把RVA转换成新的值再重新分块。重新分块时要注意每个块仍然只能覆盖一个4KB页面不能跨页。实操心得如果合并后的节起始RVA和原来某个节的起始RVA相同那么该节的重定位块可以原样保留只需要处理另一个节的重定位项。所以合并时尽量让新节的起始RVA等于第一个节的起始RVA这样能省一半工作量。4. 实操过程与核心环节实现4.1 准备工作解析原始PE文件我习惯用Python的pefile库来做前期分析因为它能快速把节表、数据目录、重定位表都解析出来。先装好环境pip install pefile然后写一段脚本打印出所有节的信息import pefile pe pefile.PE(target.exe) for section in pe.sections: print(fName: {section.Name.decode().rstrip(chr(0))}) print(f VirtualAddress: {hex(section.VirtualAddress)}) print(f VirtualSize: {hex(section.Misc_VirtualSize)}) print(f PointerToRawData: {hex(section.PointerToRawData)}) print(f SizeOfRawData: {hex(section.SizeOfRawData)}) print(f Characteristics: {hex(section.Characteristics)}) print()跑一遍你就能看到哪些节在磁盘上相邻、哪些节权限相同、哪些节之间有空洞。这些信息决定了你能不能安全合并。4.2 选择合并目标与计算新布局假设我们决定合并.text和.rdata。先记录它们的参数字段.text.rdataVirtualAddress0x10000x5000VirtualSize0x3A000x1200PointerToRawData0x4000x3E00SizeOfRawData0x3A000x1200Characteristics0x600000200x40000040新节的起始RVA取.text的0x1000。新节的VirtualSize 0x3A00 0x1200 0x4C00按0x1000对齐后是0x5000。新节的PointerToRawData取0x400。新节的SizeOfRawData 0x3A00 0x1200 0x4C00按0x200对齐后是0x4C00。新节的Characteristics 0x60000020 | 0x40000040 0x60000040可执行可读包含代码和已初始化数据。计算完成后把.rdata的数据从磁盘偏移0x3E00处复制到新节内偏移0x3A00处。注意这里0x3A00是.text的SizeOfRawData不是VirtualSize。因为磁盘上数据是紧凑排列的按SizeOfRawData拼接即可。4.3 修正数据目录与重定位数据目录的修正可以用pefile辅助完成。先找到所有指向.rdata范围内的RVA把它们统一减去0x4000因为.rdata原RVA是0x5000新位置是0x1000 0x3A00 0x4A00差值0x5000 - 0x4A00 0x600但实际偏移要看对齐后的结果。更稳妥的做法是建立旧RVA到新RVA的映射函数然后遍历所有数据目录项逐个转换。重定位表的修正稍微复杂。先用pefile解析出所有重定位项if hasattr(pe, DIRECTORY_ENTRY_BASERELOC): for reloc in pe.DIRECTORY_ENTRY_BASERELOC: for entry in reloc.entries: print(fRVA: {hex(entry.rva)}, Type: {entry.type})对于每个RVA判断它落在哪个旧节里然后根据映射关系算出新RVA。如果新RVA和旧RVA在同一个4KB页面内块结构可以不变如果跨页了就要重新分块。重新分块时把新RVA按0x1000分组每组生成一个新的重定位块。4.4 写回文件并验证所有修正完成后用pefile的write()方法写回新文件pe.write(merged.exe)但pefile的写回有时会保留一些旧节表项需要手动清理。更可靠的做法是直接操作二进制先构造新的节表然后按新布局重写整个文件。我一般会写一个自定义的PE重建函数按以下顺序输出DOS头 PE头 节表节数量减一新合并节的数据其他未合并节的数据重定位表、调试信息等附加数据验证阶段先用dumpbin /headers merged.exe检查节表是否正确再用dumpbin /relocations看重定位表有没有异常。最后双击运行或者用调试器加载确认程序能正常启动。注意如果原文件有数字签名合并节后签名必然失效。这是预期行为因为签名覆盖了整个文件内容。如果需要保留签名只能在合并后重新签名但重新签名需要有效的代码签名证书。5. 常见问题与排查技巧实录5.1 合并后程序无法启动的排查路径程序跑不起来是最常见的问题。我一般按以下顺序排查检查节表对齐用dumpbin /headers看新节的VirtualAddress和PointerToRawData是否满足对齐要求。VirtualAddress必须是SectionAlignment的倍数PointerToRawData必须是FileAlignment的倍数。检查入口点入口点RVA是否还在有效范围内如果入口点落在被合并的节里它的RVA有没有被正确修正检查数据目录导入表、导出表、资源表的RVA是否指向了正确的位置可以用CFF Explorer打开文件逐个查看数据目录。检查重定位如果程序开启了ASLR重定位表必须正确。可以用dumpbin /relocations看有没有解析错误。检查节权限新节的Characteristics是否包含了必要的执行和读取权限如果代码段被合并后失去了可执行标志程序会直接崩溃。5.2 常见问题速查表问题现象可能原因解决方法提示“不是有效的Win32应用程序”节表对齐错误或节数量字段未更新检查FileAlignment和SectionAlignment更新NumberOfSections程序启动后立即崩溃入口点RVA错误或代码段权限不足修正入口点RVA确保Characteristics包含MEM_EXECUTE导入函数找不到导入表RVA未修正重新计算导入表RVA更新数据目录资源加载失败资源目录RVA未修正遍历资源目录树修正所有RVA杀毒软件报毒合并后出现可写可执行节拆分权限避免同时设置MEM_WRITE和MEM_EXECUTE文件体积反而变大对齐空洞增加调整合并顺序让新节起始RVA和磁盘偏移尽量紧凑5.3 独家避坑技巧技巧一先备份再动手。合并节是不可逆操作一旦写回文件原始节表就没了。我习惯在操作前把原文件复制三份分别放在不同目录避免手误覆盖。技巧二用十六进制编辑器做最后校验。脚本跑完后用HxD或010 Editor打开新文件手动检查节表区域的每个字段。脚本可能有bug但十六进制不会骗人。技巧三合并顺序影响工作量。如果两个节在磁盘上本来就相邻合并后不需要移动数据只需要改节表。如果中间隔着其他节就要把后面的节整体前移工作量大很多。所以优先合并相邻节。技巧四不要合并.reloc节。重定位表通常单独放在.reloc节它的位置和大小由数据目录直接指向。合并.reloc会导致重定位表解析混乱除非你完全重写重定位逻辑否则得不偿失。技巧五测试环境要干净。合并后的文件可能在某些Windows版本上能跑在另一些版本上崩溃。我一般会在Windows 10和Windows 11的虚拟机里各测一遍有条件的话再测一下Windows Server。不同版本的加载器对节表的校验严格程度不一样。6. 合并节的进阶应用与扩展思路6.1 结合节加密做轻量级保护合并节之后新节的数据是连续存放的。如果你把代码节和数据节合并然后对整个新节做异或加密运行时再解密就能实现一个最简单的保护壳。这种壳的强度不高但胜在实现简单适合做内部工具的防误改。具体做法是在合并后的节里留一小段解密代码入口点指向这段代码。解密代码先把新节按字节异或一个密钥然后跳转到原始入口点。密钥可以硬编码也可以从文件头某个字段派生。注意解密代码本身不能加密否则就死循环了。6.2 合并节与文件体积优化的量化分析合并节到底能省多少空间我拿一个实际例子算过。一个典型的控制台程序有5个节.text、.rdata、.data、.rsrc、.reloc。每个节表项40字节5个节就是200字节。合并.text和.rdata后节表变成4个省了40字节。同时如果两个节之间有对齐空洞合并后空洞消失还能再省几百字节。但省的空间有限通常不超过1KB。所以如果你的目标是大幅压缩体积合并节只是辅助手段主要还得靠压缩壳或资源重打包。合并节真正的价值在于简化文件结构让后续的自动化处理更容易。6.3 自动化合并脚本的设计要点如果你经常需要合并节建议写一个自动化脚本。脚本的核心模块包括PE解析模块读取节表、数据目录、重定位表。布局计算模块根据合并策略计算新节的RVA、偏移、大小。数据搬移模块按对齐规则复制节数据。RVA修正模块遍历所有引用更新RVA。写回模块重建文件并输出。脚本的难点在于RVA修正的完整性。我的经验是先把所有需要修正的RVA收集到一个列表里去重后统一处理。不要边遍历边修改否则容易漏掉或重复修改。提示写脚本时建议加一个“干跑”模式只打印将要修改的字段不实际写文件。确认无误后再执行写回。这个习惯帮我避免了好几次不可逆的错误。7. 我个人在实际操作中的体会合并节这件事说起来原理不复杂但真正动手时细节非常多。我最早尝试合并.text和.rdata改完节表后程序直接崩了排查了一下午才发现是重定位表没修正。后来我养成了一个习惯每次合并前先用脚本把所有数据目录和重定位项的RVA打印出来合并后再打印一遍对比差异。差异列表就是我的修正清单一个都不能漏。另一个体会是不要追求一次合并所有节。我见过有人想把5个节合并成1个结果权限冲突、重定位混乱、资源加载失败最后文件彻底报废。稳妥的做法是每次只合并两个同权限的节验证通过后再考虑下一步。合并节不是目的让文件按预期工作才是。最后分享一个小技巧如果你只是想让文件看起来节少一点但又不想承担合并的风险可以先把节名改成一样的。加载器不检查节名唯一性两个都叫.text也能跑。这招在应付某些只看节数量的检查时很好用但别拿去做正式发布。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑