1. 从一个真实需求说起为什么要手动给PE文件加节很多人第一次接触PE文件结构都是在做逆向分析或者安全研究的时候。你可能遇到过这样的场景手上有一个现成的可执行文件想往里面塞一段自己的代码但又不想破坏原有的功能逻辑。最直接的想法是直接改入口点跳过去但这样会带来一个问题——你新增的代码往哪里放如果直接覆盖原有代码段原程序就废了如果追加到文件末尾又需要让PE加载器知道这段数据的存在。这就是“新增节”这个操作要解决的核心问题。PE文件的结构本质上是一个精心编排的容器每个节区都有明确的起始位置、大小、内存对齐和权限属性。手动新增一个节就是在这个容器里合法地“加塞”一块新空间让操作系统加载时能正确识别并映射它。我最初学这个内容的时候看的是各种零散的笔记和论坛帖子信息碎片化严重很多关键细节没人讲清楚。比如为什么节区大小要对齐、文件对齐和内存对齐到底有什么区别、新增节之后为什么程序有时候能跑有时候直接崩。这些问题不搞明白照着步骤做也是知其然不知其所以然。这篇内容适合有一定PE基础、想深入理解文件结构并动手实操的读者。如果你连DOS头、NT头、节表这些基本概念都还没接触过建议先补一下基础。但如果你已经能看懂PE结构图只是没自己动手改过文件那这篇就是为你准备的。2. 动手之前必须搞清楚的PE节区底层逻辑2.1 文件对齐与内存对齐两个容易混淆的概念PE文件里有两个对齐值分别存在可选头的不同字段中。FileAlignment是文件中的对齐粒度SectionAlignment是加载到内存后的对齐粒度。这两个值通常不一样比如常见的文件对齐是0x200512字节内存对齐是0x10004096字节。为什么要有两个对齐因为磁盘存储和内存管理的需求不同。磁盘按扇区读写512字节是传统扇区大小按这个粒度对齐文件数据能提高IO效率。内存按页管理4096字节是常见页大小按这个粒度对齐能配合虚拟内存机制。这意味着什么呢一个节在文件中的大小和它在内存中的大小可能不同。比如你的节实际数据只有0x100字节但文件对齐是0x200那它在文件中占0x200字节加载到内存时内存对齐是0x1000那它占0x1000字节。节表里的SizeOfRawData记录的是文件中对齐后的大小VirtualSize记录的是实际数据大小而Misc.VirtualSize在内存中会被对齐到SectionAlignment。注意新增节的时候SizeOfRawData必须是FileAlignment的整数倍否则加载器可能拒绝加载或者行为异常。2.2 节表的结构与数量限制节表紧跟在可选头之后每个节表项40字节结构如下偏移大小字段说明0x008Name节名称最多8字节0x084VirtualSize内存中的实际大小0x0C4VirtualAddress内存中的起始RVA0x104SizeOfRawData文件中对齐后的大小0x144PointerToRawData文件中的起始偏移0x184PointerToRelocations重定位偏移通常为00x1C4PointerToLinenumbers行号偏移通常为00x202NumberOfRelocations重定位数量通常为00x222NumberOfLinenumbers行号数量通常为00x244Characteristics节属性标志节表数量存在NumberOfSections字段中位于文件头IMAGE_FILE_HEADER偏移0x02处占2字节。理论上最多可以有65535个节但实际上加载器对节的数量和总大小都有限制。Windows加载器通常要求节表之后紧接着就是节数据如果你新增节表项就需要在节表区域腾出空间。这里有个关键问题节表区域后面紧跟着第一个节的数据。如果你直接在节表末尾追加一个新节表项会覆盖掉第一个节的开头数据。所以常见做法是要么在文件末尾新增节表项并把节数据放在更后面要么把原有节数据整体后移。前者更简单也是我推荐新手先掌握的方式。2.3 新增节的核心思路拆解手动新增一个节本质上要做以下几件事在节表末尾追加一个40字节的新节表项更新NumberOfSections让它加1更新SizeOfImage让它包含新节的内存范围在文件末尾追加新节的实际数据并按FileAlignment对齐填写新节表项的各个字段确保VirtualAddress、PointerToRawData等值正确听起来不复杂但每一步都有坑。比如SizeOfImage的更新不是简单加一个值而是要按SectionAlignment对齐新节的VirtualAddress必须紧接在最后一个节的内存范围之后并且也要对齐。3. 手工操作全流程从复制文件到验证加载3.1 准备工作工具选择与文件备份我习惯用010 Editor配合PE模板来做这种操作因为它能直观显示每个字段的含义改起来不容易出错。当然你也可以用Python的pefile库写脚本自动化但第一次做建议手工来一遍把每个字节的含义搞清楚。首先复制一份目标文件命名为target_new.exe。永远不要在原文件上直接操作这是铁律。然后确认目标文件的架构——是32位还是64位这会影响可选头的大小和某些字段的偏移。用010 Editor打开后加载PE模板你能看到完整的结构树。提示选择目标文件时优先选一个简单的、没有签名的、没有重定位表的程序。带签名的文件修改后签名会失效带重定位表的文件新增节后可能需要修复重定位数据。3.2 定位节表末尾并追加新节表项在010 Editor的PE模板中找到最后一个节表项的位置。假设最后一个节表项从偏移0x1F8开始那它结束于0x1F8 0x28 0x220。这个位置就是新节表项的起始偏移。但这里有个问题0x220处可能已经是第一个节的数据了。你需要确认节表区域后面是否有足够的空闲空间。如果没有就需要把从0x220开始的所有数据整体后移40字节。这个操作在010 Editor里可以用“插入字节”功能完成。具体操作选中从0x220到文件末尾的所有数据整体后移40字节然后在0x220处填入新的节表项。新节表项的40字节先全部填0后面再逐个字段填写。3.3 填写新节表项的每个字段这是最核心的一步每个字段都有讲究Name8字节随便起个名字比如.newsec。注意不要和已有节重名虽然加载器不一定报错但调试时容易混淆。VirtualSize新节在内存中的实际大小。假设你要放的代码是0x150字节那这里填0x150。但要注意这个值不需要对齐加载器会自动按SectionAlignment对齐。VirtualAddress新节在内存中的起始RVA。计算方法是最后一个节的VirtualAddress 最后一个节的VirtualSize然后按SectionAlignment向上对齐。比如最后一个节VirtualAddress0x3000VirtualSize0x200SectionAlignment0x1000那新节的VirtualAddress align(0x30000x200, 0x1000) 0x4000。SizeOfRawData新节在文件中对齐后的大小。假设实际数据0x150字节FileAlignment0x200那SizeOfRawData align(0x150, 0x200) 0x200。PointerToRawData新节数据在文件中的起始偏移。这个值应该是文件末尾按FileAlignment对齐后的位置。比如原文件大小是0x800FileAlignment0x200那新数据从align(0x800, 0x200) 0x800开始。如果原文件大小是0x900那就从0xA00开始。Characteristics节属性。常见的可执行代码节用0x60000020CODE | EXECUTE | READ可读写数据节用0xC0000040INITIALIZED_DATA | READ | WRITE。如果你要放的是可执行代码记得加上EXECUTE标志否则加载后没有执行权限。3.4 更新文件头和可选头中的关键字段追加完节表项后需要回头修改几个全局字段NumberOfSections在文件头偏移0x02处原值加1。SizeOfImage在可选头中表示整个PE映像加载到内存后的大小。计算方法是新节的VirtualAddress align(新节VirtualSize, SectionAlignment)。比如新节VirtualAddress0x4000VirtualSize0x150SectionAlignment0x1000那SizeOfImage 0x4000 0x1000 0x5000。SizeOfCode可选如果新节是可执行代码可以把这个值加上新节的SizeOfRawData。不过这个字段加载器不太关心不改也不影响运行。CheckSum可选如果原文件有校验和修改后需要重新计算。但大多数普通可执行文件的校验和为0可以忽略。3.5 追加节数据并验证在文件末尾按PointerToRawData指定的偏移写入你的节数据。如果数据不足SizeOfRawData用0填充。然后在010 Editor里重新加载PE模板检查结构是否正常。验证步骤用PE查看工具确认节表数量正确新节属性正确用调试器加载看是否能正常启动如果新节是可执行代码在调试器里跳转到新节地址确认指令正确我实测下来只要VirtualAddress和PointerToRawData计算正确加载器基本都能正常识别。最容易出问题的地方是SizeOfImage没更新或者更新错了导致加载器认为映像大小不够直接报错。4. 那些让我折腾了大半天的坑4.1 节表空间不足导致的覆盖问题第一次做的时候我直接在节表末尾追加了40字节结果程序直接跑不起来。用调试器一看第一个节的数据开头被覆盖了。原因就是节表区域后面紧跟着节数据没有预留空隙。解决办法有两种一是把节数据整体后移二是把新节表项写到文件末尾然后修改节表指针。但后者需要修改可选头中的某些字段更复杂。我建议新手用第一种方法虽然要移动大量数据但逻辑简单不容易出错。具体操作时在010 Editor里选中从节表末尾到文件末尾的所有字节用“Insert Bytes”功能插入40个0字节。这样原来的数据就整体后移了节表区域多出40字节的空位。4.2 VirtualAddress计算错误导致的加载失败有一次我算VirtualAddress时忘了对齐直接把最后一个节的VirtualAddress加VirtualSize填进去了。结果加载器报错“不是有效的Win32应用程序”。后来才明白VirtualAddress必须是SectionAlignment的整数倍否则加载器无法正确映射内存页。正确的计算方式new_va align(last_section_va last_section_vsize, section_alignment)其中align函数是向上取整到指定对齐值。比如last_section_va0x3000vsize0x200alignment0x1000那new_va 0x4000。4.3 文件对齐不一致导致的节数据错位还有一次我填PointerToRawData时直接用了原文件大小没有按FileAlignment对齐。结果新节数据在文件中的位置和节表里记录的不一致加载器读到的数据是错的。正确做法new_raw align(original_file_size, file_alignment)如果原文件大小本身就是对齐的那直接用如果不是必须补齐到对齐边界。比如原文件0x8F0字节FileAlignment0x200那new_raw 0xA00。4.4 忘记更新SizeOfImage的连锁反应SizeOfImage这个字段很容易被忽略因为它不在节表里而在可选头中。但它的作用很关键——加载器用它来确定要为整个映像保留多少虚拟内存空间。如果这个值偏小新节可能无法被正确映射如果偏大浪费内存但一般不影响运行。我现在的习惯是改完节表后第一时间更新SizeOfImage然后再检查一遍所有相关字段。这个顺序能避免改完后面忘了前面。5. 用脚本自动化从手工到批量处理手工做一遍是为了理解原理但实际工作中不可能每次都手工改。用Python的pefile库可以把这个过程自动化。核心逻辑和手工操作一样只是用代码来定位偏移和写入数据。import pefile import struct def add_section(file_path, output_path, section_name, section_data, characteristics): pe pefile.PE(file_path) # 获取对齐值 file_align pe.OPTIONAL_HEADER.FileAlignment section_align pe.OPTIONAL_HEADER.SectionAlignment # 计算新节的VirtualAddress last_section pe.sections[-1] last_va_end last_section.VirtualAddress last_section.Misc_VirtualSize new_va ((last_va_end section_align - 1) // section_align) * section_align # 计算新节的PointerToRawData new_raw ((len(pe.__data__) file_align - 1) // file_align) * file_align # 计算SizeOfRawData data_len len(section_data) raw_size ((data_len file_align - 1) // file_align) * file_align # 构造新节表项 name_bytes section_name.encode(ascii)[:8].ljust(8, b\x00) new_section_header struct.pack(8sIIIIIIHHI, name_bytes, data_len, # VirtualSize new_va, # VirtualAddress raw_size, # SizeOfRawData new_raw, # PointerToRawData 0, 0, 0, 0, # 重定位和行号相关 characteristics ) # 读取原文件数据 with open(file_path, rb) as f: data bytearray(f.read()) # 在节表末尾插入新节表项 # 需要先找到节表结束的位置 # 这里简化处理实际需要根据PE结构精确定位 # ... # 更新NumberOfSections和SizeOfImage # ... # 写入新节数据 # ...实际写脚本时最麻烦的是在节表末尾插入数据。因为节表后面紧跟着节数据直接插入会覆盖。需要先把节数据整体后移再写入新节表项。或者换一种思路把新节表项写到文件末尾然后修改可选头中的节表指针。但后者需要更深入理解PE加载机制。我个人的做法是先用pefile解析出所有节的信息计算出新节表项应该插入的位置然后用字节操作把原数据分成三段——节表之前、节表区域、节数据区域重新拼接。这样逻辑清晰不容易出错。6. 新增节之后还能做什么学会新增节只是第一步。实际应用中你可能会往新节里放各种东西一段shellcode、一个DLL注入的加载器、或者只是用来存储配置数据。不同的用途对节属性的要求不同。如果放的是可执行代码Characteristics要包含IMAGE_SCN_CNT_CODE | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ对应值0x60000020。如果放的是可读写数据用IMAGE_SCN_CNT_INITIALIZED_DATA | IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE对应值0xC0000040。还有一个进阶操作把入口点改到新节。这需要修改可选头中的AddressOfEntryPoint字段让它指向新节的VirtualAddress。这样程序启动后直接执行你的代码你可以在里面做任何事最后再跳回原入口点。这个技术在安全研究中很常见但操作时要注意保存原入口点否则原程序逻辑就丢了。另外新增节之后如果还想继续加节流程是一样的只是每次都要重新计算VirtualAddress和PointerToRawData。建议写个函数封装起来传入节名、数据和属性自动完成所有计算和写入。我在实际使用中发现最容易出问题的环节永远是地址计算。每次改完文件一定要用PE查看工具确认所有字段的值是否符合预期然后再用调试器实际加载验证。光看静态结构没问题不代表加载器能正确识别动态验证才是最终标准。