首页 / 资讯中心 / 文章详情

PE文件扩大节原理与实战:手工操作与Python脚本自动化指南

PE文件扩大节原理与实战:手工操作与Python脚本自动化指南 ★ FEATURED ARTICLE
1. 从“节”说起为什么PE文件里会有“节”这个概念如果你接触过Windows平台的可执行文件格式PEPortable Executable这个词一定不陌生。exe、dll、sys这些文件本质上都是PE格式的变体。而“节”Section是PE文件里最核心的组织单元之一它承载了代码、数据、资源、重定位信息等各类内容。你可以把PE文件想象成一栋大楼节就是大楼里的不同楼层——代码节是办公区数据节是仓库资源节是展示厅每个楼层各司其职。那为什么会有“扩大节”这个需求这得从PE文件的结构说起。PE文件在磁盘上的布局和加载到内存后的布局是两套映射关系。文件头之后紧跟着节表Section Table节表里记录了每个节的名称、虚拟地址、虚拟大小、文件偏移、文件对齐后的大小、内存对齐后的大小、节区属性等关键字段。操作系统加载器在映射文件时会按照节表中记录的虚拟地址和大小把各个节搬到内存里。问题就出在这里每个节在文件中的原始数据大小是固定的节与节之间通常还有对齐填充。当你想要往某个已有的节里塞入更多数据时如果新数据超出了该节当前的文件大小或虚拟大小就会覆盖到后面节的内容或者导致加载器映射失败。这时候就需要“扩大节”——把目标节的虚拟大小和文件大小都撑大同时把后面的节整体往后挪给新数据腾出空间。这个操作在逆向工程、软件保护、壳的开发、病毒分析、补丁制作等领域非常常见。比如你想给某个程序注入一段自定义代码最直接的做法就是找一个代码节把它扩大然后把你的shellcode追加进去再修改入口点跳转过去。又比如你在做软件汉化需要往资源节里塞入更多字符串资源节不够大就得扩容。但扩大节不是简单地改几个数字就完事。它涉及到文件对齐、内存对齐、节表空间、重定位、导入表引用、异常处理表等一系列连锁反应。改错一个字段程序直接崩溃或者加载失败。我见过太多新手在这一步翻车——要么忘了更新SizeOfImage要么把节表撑爆了导致节表覆盖了第一个节的数据要么扩大后没修正后续节的文件偏移导致整个文件错位。这篇文章我会从底层原理讲起把扩大节的完整流程拆开揉碎包括手工操作和脚本自动化两种方式并分享我在实际项目中踩过的坑和总结出的经验。无论你是刚接触PE格式的新手还是已经能看懂节表但没动手改过的进阶者都能从里面找到可以直接复现的步骤和避坑指南。2. 动手之前必须搞清楚的PE节表底层逻辑2.1 节表在文件里的精确位置和结构PE文件的开头是DOS头IMAGE_DOS_HEADER紧接着是DOS stub然后才是真正的PE头。PE头由三部分组成签名Signature4字节的“PE\0\0”、文件头IMAGE_FILE_HEADER20字节、可选头IMAGE_OPTIONAL_HEADER32位下通常是224字节。可选头之后就是节表每个节表项占40字节IMAGE_SECTION_HEADER节的数量由文件头里的NumberOfSections字段决定。节表项的结构如下偏移大小字段名含义0x008字节Name节名称如“.text”“.data”0x084字节VirtualSize节在内存中的实际大小0x0C4字节VirtualAddress节在内存中的起始RVA0x104字节SizeOfRawData节在文件中对齐后的大小0x144字节PointerToRawData节在文件中的起始偏移0x184字节PointerToRelocations重定位表偏移通常为00x1C4字节PointerToLinenumbers行号表偏移通常为00x202字节NumberOfRelocations重定位项数量0x222字节NumberOfLinenumbers行号数量0x244字节Characteristics节属性标志这里有几个关键点需要特别注意。VirtualSize是节在内存中的实际使用大小而SizeOfRawData是节在文件中对齐后的大小。两者不一定相等——VirtualSize可能小于SizeOfRawData因为文件对齐通常比内存对齐更宽松也可能大于SizeOfRawData某些加壳程序会这样处理运行时再解压。VirtualAddress是节在内存中的RVA它必须是SectionAlignment的整数倍。PointerToRawData是节在文件中的偏移必须是FileAlignment的整数倍。这两个对齐值定义在可选头里典型值分别是0x1000和0x200。2.2 文件对齐与内存对齐扩大节时最容易搞混的两个参数FileAlignment和SectionAlignment是扩大节操作中绕不开的两个参数。FileAlignment决定节在磁盘上的起始偏移必须是多少的倍数SectionAlignment决定节在内存中的起始RVA必须是多少的倍数。举个例子假设FileAlignment0x200SectionAlignment0x1000。某个节的VirtualSize0x1A00SizeOfRawData0x1A00PointerToRawData0x400VirtualAddress0x1000。现在你要往这个节里追加0x800字节的数据那么新的VirtualSize至少是0x2200新的SizeOfRawData至少是0x2200。但0x2200不是0x200的整数倍所以SizeOfRawData要向上对齐到0x22000x2200/0x20017正好整除。如果追加后变成0x2300那就要对齐到0x2400。内存这边VirtualSize变成0x2200后下一个节的VirtualAddress原本是0x3000假设现在这个节的内存范围是0x1000到0x3200已经超过了0x3000所以下一个节的VirtualAddress必须往后挪到0x40000x3200向上对齐到0x1000的倍数。这就是连锁反应的起点。注意很多教程只告诉你改VirtualSize和SizeOfRawData却不提后续节的VirtualAddress和PointerToRawData也要同步调整。这是导致程序加载失败的头号原因。2.3 节表空间一个容易被忽略的硬约束节表本身也占用文件空间。节表紧跟在可选头之后第一个节的数据通常从FileAlignment的整数倍位置开始。节表和第一个节数据之间的空隙就是节表可以扩展的空间。假设可选头结束于文件偏移0x1F0FileAlignment0x200那么第一个节的数据从0x200开始。节表从0x1F8开始可选头结束位置到0x200结束只有8字节可用。每个节表项40字节这意味着你连一个新节表项都加不进去。如果你想通过“新增一个节”的方式来扩大容量就必须先确认节表后面有没有足够的空隙。如果没有就得把第一个节的数据整体往后挪腾出空间给新的节表项。这个操作比单纯扩大已有节更复杂因为要修改所有后续节的PointerToRawData。在实际操作中我通常优先选择“扩大已有节”而不是“新增节”因为前者不需要动节表项数量只需要修改现有节表项的字段和后续节的位置。只有当已有节无法满足需求比如所有节都紧挨着没有扩展余地时才考虑新增节。3. 扩大一个节的完整手工操作流程3.1 准备工作选对工具和备份原文件手工扩大节需要一款能直接编辑二进制文件的工具。我常用的是010 Editor配合PE模板或者CFF Explorer。010 Editor的PE模板能自动解析所有字段修改后还能校验结构完整性。CFF Explorer更轻量适合快速查看和修改。不管用什么工具第一步永远是备份原文件。扩大节的操作是不可逆的一旦改错原文件可能无法恢复。我习惯把原文件复制一份命名为xxx_backup.exe然后在副本上操作。选好目标节也很关键。通常选择最后一个节来扩大因为这样不需要调整后续节的位置后面没有节了。如果必须扩大中间的节就要做好批量调整后续所有节的准备。另外目标节最好有可写属性Characteristics包含IMAGE_SCN_MEM_WRITE否则你追加的数据在内存中可能不可写导致运行时异常。3.2 计算扩大后的各项参数假设我们要扩大最后一个节原参数如下FileAlignment 0x200SectionAlignment 0x1000目标节VirtualSize 0x1A00目标节VirtualAddress 0x5000目标节SizeOfRawData 0x1A00目标节PointerToRawData 0x1A00文件总大小 0x1A00 0x1A00 0x3400现在要追加0x1000字节的新数据。计算过程新VirtualSize 0x1A00 0x1000 0x2A00新SizeOfRawData 0x2A00向上对齐到0x200的倍数 0x2A000x2A00/0x20021整除新PointerToRawData不变还是0x1A00新文件总大小 0x1A00 0x2A00 0x4400检查SizeOfImage原SizeOfImage 0x5000 0x1A00对齐到0x1000 0x7000。新SizeOfImage 0x5000 0x2A00对齐到0x1000 0x8000。需要更新可选头里的SizeOfImage字段。如果目标节不是最后一个节还要计算后续节的偏移变化。假设后面还有一个节原VirtualAddress0x7000PointerToRawData0x3400。扩大后前一个节的VirtualAddressVirtualSize0x50000x2A000x7A00向上对齐到0x1000的倍数是0x8000所以后续节的VirtualAddress要改成0x8000。PointerToRawData要改成0x1A000x2A000x4400向上对齐到0x200的倍数是0x4400。3.3 逐字段修改与数据追加用010 Editor打开文件定位到节表。找到目标节的IMAGE_SECTION_HEADER结构依次修改VirtualSize和SizeOfRawData。然后跳到可选头修改SizeOfImage。如果后续节需要移动还要修改它们的VirtualAddress和PointerToRawData。修改完字段后把文件指针移到原文件末尾0x3400追加0x1000字节的新数据。新数据可以是你的shellcode、补丁代码、额外资源等。追加完成后保存文件。这里有个细节追加的数据长度必须是FileAlignment的整数倍吗不是必须的但SizeOfRawData必须是对齐后的值。实际追加的字节数可以小于SizeOfRawData剩余部分用0填充即可。加载器只关心SizeOfRawData不关心你实际写了多少字节。3.4 验证扩大结果加载测试与结构校验修改完成后先用PE查看工具确认所有字段是否正确。重点检查VirtualSize是否等于或大于实际数据大小、SizeOfRawData是否是FileAlignment的整数倍、VirtualAddress是否是SectionAlignment的整数倍、SizeOfImage是否覆盖了所有节的内存范围。然后尝试运行程序。如果程序能正常启动且功能不受影响说明扩大操作基本成功。如果程序崩溃或报错用调试器加载查看是否在加载阶段就失败通常是SizeOfImage或节表字段错误还是运行到某处才崩溃可能是数据覆盖或重定位问题。我习惯用PE-bear或CFF Explorer的“重建PE”功能做一次结构校验它会自动检测节表重叠、对齐错误等问题。如果校验通过基本可以放心。4. 脚本化扩大节用Python批量处理4.1 为什么手工改容易出错脚本更可靠手工改字段看起来简单但实际操作中很容易漏改某个字段。比如你改了VirtualSize和SizeOfRawData却忘了更新SizeOfImage或者你移动了后续节的位置却忘了同步修改它们的PointerToRawData。这些错误在简单程序上可能不明显但在复杂程序上会导致加载失败或运行时崩溃。用脚本处理的好处是所有计算逻辑写死在代码里每次执行都一致可以批量处理多个文件修改前可以自动备份修改后可以自动校验。我现在的做法是只要涉及超过两个字段的修改一律用脚本。4.2 用pefile库读取和修改节表Python的pefile库是处理PE文件的利器。它可以解析PE结构也能修改字段并保存。下面是一个扩大最后一个节的示例脚本import pefile import os import shutil def expand_last_section(file_path, extra_size, output_pathNone): # 备份原文件 if output_path is None: output_path file_path.replace(.exe, _expanded.exe) shutil.copy(file_path, output_path) pe pefile.PE(output_path) # 获取最后一个节 last_section pe.sections[-1] file_alignment pe.OPTIONAL_HEADER.FileAlignment section_alignment pe.OPTIONAL_HEADER.SectionAlignment # 计算新大小 old_virtual_size last_section.Misc_VirtualSize old_raw_size last_section.SizeOfRawData new_virtual_size old_virtual_size extra_size new_raw_size ((new_virtual_size file_alignment - 1) // file_alignment) * file_alignment # 修改节表字段 last_section.Misc_VirtualSize new_virtual_size last_section.SizeOfRawData new_raw_size # 更新SizeOfImage new_image_size last_section.VirtualAddress new_virtual_size new_image_size ((new_image_size section_alignment - 1) // section_alignment) * section_alignment pe.OPTIONAL_HEADER.SizeOfImage new_image_size # 保存修改 pe.write(output_path) # 追加数据 with open(output_path, ab) as f: f.write(b\x00 * extra_size) print(f扩大完成{output_path}) print(f新VirtualSize: 0x{new_virtual_size:X}) print(f新SizeOfRawData: 0x{new_raw_size:X}) print(f新SizeOfImage: 0x{new_image_size:X}) # 使用示例 expand_last_section(target.exe, 0x1000)这个脚本只处理最后一个节的情况。如果要扩大中间的节还需要遍历后续所有节修改它们的VirtualAddress和PointerToRawData。逻辑是从目标节的下一个节开始每个节的VirtualAddress增加(new_virtual_size - old_virtual_size)并对齐PointerToRawData增加(new_raw_size - old_raw_size)并对齐。4.3 处理中间节扩大的连锁偏移扩大中间节时后续节的偏移调整是必须的。下面是一个处理中间节的函数片段def expand_middle_section(pe, section_index, extra_size): file_alignment pe.OPTIONAL_HEADER.FileAlignment section_alignment pe.OPTIONAL_HEADER.SectionAlignment target pe.sections[section_index] old_virtual_size target.Misc_VirtualSize old_raw_size target.SizeOfRawData new_virtual_size old_virtual_size extra_size new_raw_size ((new_virtual_size file_alignment - 1) // file_alignment) * file_alignment virtual_delta new_virtual_size - old_virtual_size raw_delta new_raw_size - old_raw_size target.Misc_VirtualSize new_virtual_size target.SizeOfRawData new_raw_size # 调整后续节 for i in range(section_index 1, len(pe.sections)): sec pe.sections[i] sec.VirtualAddress virtual_delta sec.VirtualAddress ((sec.VirtualAddress section_alignment - 1) // section_alignment) * section_alignment sec.PointerToRawData raw_delta sec.PointerToRawData ((sec.PointerToRawData file_alignment - 1) // file_alignment) * file_alignment # 更新SizeOfImage last_sec pe.sections[-1] new_image_size last_sec.VirtualAddress last_sec.Misc_VirtualSize new_image_size ((new_image_size section_alignment - 1) // section_alignment) * section_alignment pe.OPTIONAL_HEADER.SizeOfImage new_image_size这个逻辑看起来简单但实际执行时要注意VirtualAddress和PointerToRawData的对齐是分别进行的不能混用。而且调整顺序必须从后往前还是从前往后从前往后即可因为每个节的调整只依赖于前一个节的最终大小不依赖于后续节。4.4 脚本执行后的自动化校验脚本改完后我会用pefile重新加载文件检查以下项所有节的VirtualAddress是否按升序排列且不重叠所有节的PointerToRawData是否按升序排列且不重叠SizeOfImage是否大于等于最后一个节的VirtualAddressVirtualSize文件实际大小是否大于等于最后一个节的PointerToRawDataSizeOfRawData如果任何一项不满足脚本会报错并保留备份文件。这套校验逻辑帮我省去了大量手工检查的时间。5. 扩大节之后必须同步处理的几个隐藏字段5.1 重定位表地址变了重定位项也要跟着变如果你的目标程序启用了ASLR地址空间布局随机化或者你修改的节包含需要重定位的代码/数据那么扩大节后重定位表可能失效。重定位表记录了哪些位置在加载时需要修正地址。当你移动了节的位置这些位置的RVA变了重定位表里的记录也要相应更新。具体来说重定位表由多个块组成每个块对应一个4KB页面。块头记录了页面的RVA后面跟着若干重定位项每项2字节高4位是类型低12位是页内偏移。如果你把某个节从RVA 0x5000移到了0x6000那么所有指向0x5000页面的重定位块都要把页RVA改成0x6000。手工改重定位表非常繁琐通常的做法是如果程序没有重定位表很多exe默认没有或者你扩大的节不涉及重定位就可以忽略。如果涉及建议用工具重新生成重定位表或者直接关闭ASLR修改可选头的DllCharacteristics字段去掉IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE标志。5.2 导入表与导出表RVA引用需要重新计算导入表Import Table和导出表Export Table里存储的都是RVA。如果你移动了包含这些表的节或者移动了被导入函数所在的节这些RVA就会指向错误的位置。导入表的结构是IMAGE_IMPORT_DESCRIPTOR数组每个描述符包含OriginalFirstThunk、Name、FirstThunk等字段这些都是RVA。如果导入表本身被移动了可选头里的DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]要更新。如果导入表引用的DLL名称字符串或函数名称字符串被移动了描述符里的Name和OriginalFirstThunk也要更新。导出表类似IMAGE_EXPORT_DIRECTORY里的AddressOfFunctions、AddressOfNames、AddressOfNameOrdinals都是RVA移动后都要修正。实际操作中我通常避免移动包含导入导出表的节。如果必须移动会用脚本遍历所有RVA引用并批量修正。这个工作量大且容易出错所以能避则避。5.3 异常处理表与调试信息x64程序通常包含异常处理表.pdata节里面记录了每个函数的异常处理信息包括起始RVA、结束RVA、展开信息RVA等。如果你移动了代码节这些RVA全部失效程序在抛出异常时可能崩溃。调试信息.debug节里也包含大量RVA引用不过调试信息通常不影响程序运行可以暂时忽略。对于异常处理表我的建议是如果你只是扩大节并追加数据没有移动原有代码的位置那么异常处理表不受影响。如果你移动了代码节要么同步修正.pdata里的RVA要么直接删除.pdata节程序会失去异常处理能力但通常能运行。6. 实战中踩过的坑与排查思路6.1 节表撑爆导致第一个节数据被覆盖有一次我给一个节表很满的程序新增节没注意节表后面的空隙只有0x10字节结果新的节表项直接覆盖了第一个节的前0x28字节数据。程序加载时直接报“不是有效的Win32应用程序”。排查过程用PE工具查看节表发现第一个节的PointerToRawData指向的数据开头是乱码而正常应该是代码。对比备份文件确认是节表越界写入。修复方案把第一个节的数据整体往后挪0x200字节一个FileAlignment腾出空间给新节表项同时更新第一个节的PointerToRawData和所有后续节的偏移。这个操作相当于在节表和第一个节之间插入了一个对齐填充区。经验总结新增节之前一定要计算节表末尾到第一个节数据起始位置的空隙大小。空隙 第一个节PointerToRawData - (节表起始偏移 节数量 * 40)。如果空隙小于40字节就必须先挪动第一个节的数据。6.2 忘记更新SizeOfImage导致加载器拒绝映射SizeOfImage是可选头里的一个重要字段表示整个PE文件加载到内存后占用的总大小。加载器在映射文件时会检查这个值是否足够大。如果你扩大了节但没更新SizeOfImage加载器可能只映射原大小的内存导致新追加的数据没有被映射访问时触发访问违例。我遇到过一次扩大节后程序能启动但一执行到新追加的代码就崩溃。用调试器查看发现新代码所在的内存页是未映射的。检查SizeOfImage发现还是旧值。修复方案SizeOfImage 最后一个节的VirtualAddress 最后一个节的VirtualSize然后向上对齐到SectionAlignment的倍数。这个计算必须在所有节的位置确定之后进行。6.3 文件对齐计算错误导致节数据错位FileAlignment通常是0x200但有些程序会改成0x1000甚至更大。如果你按0x200计算实际程序用的是0x1000那么你设置的PointerToRawData可能不是0x1000的倍数加载器会认为文件格式非法。我有一次处理一个FileAlignment0x1000的程序习惯性地按0x200对齐结果保存后程序无法加载。用PE工具查看发现第二个节的PointerToRawData0x600不是0x1000的倍数。修复方案始终从可选头里读取FileAlignment和SectionAlignment的实际值不要硬编码。计算对齐时用公式aligned (value alignment - 1) ~(alignment - 1)。这个公式对所有2的幂次对齐值都适用。6.4 扩大后程序运行崩溃但加载正常的排查链路如果程序能加载但运行崩溃排查思路如下确认崩溃地址是否在新追加的数据范围内。如果是检查追加的数据是否正确比如shellcode是否有重定位问题。确认崩溃地址是否在原有代码范围内。如果是可能是扩大操作覆盖了原有数据或者移动节时破坏了原有代码。用调试器查看崩溃时的寄存器和栈判断是代码逻辑问题还是内存访问问题。对比备份文件确认所有被修改的字段是否与预期一致。如果涉及重定位或导入表检查这些表的RVA是否已同步更新。我通常会用WinDbg加载程序设置断点在入口点然后单步执行观察是否在某个位置跳转到了错误地址。这个方法能快速定位是加载问题还是逻辑问题。7. 一些提高成功率的实操建议扩大节这个操作说难不难说简单也不简单。核心就那几个字段但牵一发而动全身。我做了这么多次总结下来几条最实用的建议。第一永远从最后一个节开始尝试扩大。最后一个节后面没有其他节不需要调整后续偏移风险最低。只有当最后一个节无法满足需求时才考虑中间的节。第二修改前用脚本自动计算所有受影响字段的新值并打印出来人工核对一遍。我见过太多人直接改文件改完才发现某个字段算错了但已经覆盖了原文件。第三扩大后的第一次测试先用一个简单的测试程序比如自己写的一个Hello World验证流程确认脚本和操作步骤没问题再应用到目标程序上。这样即使出错也不会造成太大损失。第四如果目标程序有数字签名或校验和扩大节后这些都会失效。数字签名需要重新签名通常做不到校验和可以用工具重新计算可选头的CheckSum字段。如果程序依赖签名验证扩大节后可能无法运行。第五对于加壳的程序扩大节之前最好先脱壳。壳程序通常会对节表做校验你改了节表字段壳在运行时检测到不一致就会拒绝执行或直接崩溃。脱壳后的程序结构更清晰扩大节的成功率也更高。最后保持耐心。扩大节不是一次就能成功的操作尤其是面对复杂程序时可能需要反复调整和测试。每次失败都是一次学习搞清楚为什么失败比盲目重试更有价值。
阅读完成 · 觉得有帮助?
咨询建站