1. 从“永久”二字说起:PE注入到底在解决什么问题
很多人第一次看到“PE永久注入”这个词,脑子里冒出来的第一个念头是“怎么让我的程序一直生效”。这个直觉其实抓到了核心,但“永久”这两个字在PE文件的世界里,含义比想象中要微妙得多。
PE,也就是Portable Executable,是Windows平台上可执行文件、动态链接库、驱动文件等遵循的一套二进制格式规范。它的结构像一栋精装修的房子:有楼层索引(节表)、有承重墙(代码段)、有水电管线(导入表、导出表、重定位表),还有物业登记处(PE头)。所谓“注入”,本质上是往这栋已经建好的房子里,塞进一间原本不存在的房间,并且让房子的日常运转逻辑(程序执行流程)在某个时刻自动走进这间房间。
那“永久”是什么意思?它不是说这个注入操作会永远留在系统里无法清除,而是指注入的内容被写进了PE文件本身的磁盘结构中,而不是只停留在内存里。内存注入重启就没了,而PE永久注入是改文件,文件在,注入就在。这是两者最根本的分界线。
我见过不少刚接触这块的朋友,把内存补丁和文件补丁混为一谈,结果调试半天发现重启后一切归零。所以第一件要搞清楚的事就是:你改的是磁盘上的字节,还是运行时的内存页。PE永久注入属于前者,它动的是文件结构,需要理解PE格式的节表、节区数据、入口点这些底层概念。
这个方向适合什么人?如果你在做软件功能扩展、逆向分析学习、或者需要给某个已有模块增加一段不依赖外部加载器的常驻逻辑,PE注入是绕不开的基本功。但它也有明确的边界:它不负责绕过任何安全机制,也不涉及网络层面的任何操作,纯粹是二进制文件结构层面的改造。理解这一点,后面的所有操作才有意义。
2. 动手之前必须吃透的PE文件骨架
2.1 DOS头、NT头与节表:注入的“地基”在哪里
PE文件的开头是一个DOS头,最关键的字段是e_lfanew,它指向真正的NT头。NT头里包含文件头和可选头,可选头里有几个字段对注入至关重要:AddressOfEntryPoint(入口点RVA)、ImageBase(建议加载基址)、SectionAlignment(节对齐)、FileAlignment(文件对齐)。
节表紧跟在可选头后面,每个节表项40字节,描述了每个节的名称、虚拟大小、虚拟地址、原始数据大小、原始数据偏移、节属性等。注入操作通常有两种落点:一是新增一个节,把注入代码放在新节里;二是扩展已有节,把代码塞进节末尾的空隙或扩展后的空间里。
新增节的好处是逻辑清晰、不破坏原有节的数据布局,缺点是会改变节的数量,某些校验机制可能会注意到。扩展已有节的好处是节表不变,但需要处理节属性是否可执行、是否有足够的空间、以及对齐问题。两种方式各有适用场景,后面会详细对比。
2.2 入口点与执行流劫持:怎么让代码“自动跑起来”
光把代码写进文件还不够,你得让程序执行到它。最直接的方式是修改AddressOfEntryPoint,让它指向你注入的代码。但这里有个陷阱:如果你直接改入口点,原来的入口代码就不会被执行了,程序可能直接崩溃。
正确的做法是在注入代码的开头保存寄存器环境,执行完自己的逻辑后,恢复环境并跳回原入口点。这就像在高速公路主路上设了一个服务区,车辆必须进服务区、办完事、再回到主路继续行驶,而不是把主路改道到服务区就结束了。
还有一种方式是通过修改导入表,把某个会被调用的API替换成自己的函数,等自己的逻辑跑完再调用原API。这种方式更隐蔽,但实现复杂度更高,需要理解导入表的结构和绑定过程。
2.3 重定位与地址无关代码:为什么你的代码换个环境就崩
PE文件里的地址有两种:相对虚拟地址(RVA)和虚拟地址(VA)。VA = ImageBase + RVA。如果程序被加载到非默认基址,所有硬编码的VA都需要修正,这就是重定位表的作用。
注入的代码如果引用了自身内部的地址,必须使用RVA或者基于当前指令位置的相对寻址,否则一旦基址变化就会指向错误的内存。写注入代码时,我习惯用位置无关的写法,所有跳转和数据引用都通过相对偏移计算,这样无论模块被加载到哪里都能正常工作。
如果你注入的代码需要调用系统API,还需要处理导入表。简单的方式是动态获取API地址,比如通过GetProcAddress,但这需要先拿到LoadLibrary或GetModuleHandle的地址。更直接的方式是在注入代码里自己解析PE导出表,找到kernel32.dll的基址,再找GetProcAddress,再找其他需要的函数。这个过程听起来绕,但写一次就能复用。
3. 新增节注入的完整实操链路
3.1 判断是否有空间新增节表项
节表项紧跟在可选头之后,节表后面通常还有一段空间,大小取决于可选头里的SizeOfHeaders和文件对齐。如果节表后面到第一个节数据之间有足够空间(至少40字节),就可以直接追加一个节表项。如果没有,就需要把后续数据整体后移,或者把节表搬到文件末尾的空隙里。
我一般先用工具查看SizeOfHeaders和第一个节的PointerToRawData,计算两者之间的差值。如果差值大于当前节表占用加上40字节,就说明有空间。这个判断步骤很多人跳过,结果写文件时覆盖了其他数据,程序直接打不开。
3.2 构造新节的数据与属性
新节的名称可以随便取,但建议用合法字符,比如.inj或.new。虚拟大小要按SectionAlignment对齐,原始数据大小要按FileAlignment对齐。节属性至少需要包含IMAGE_SCN_CNT_CODE、IMAGE_SCN_MEM_EXECUTE、IMAGE_SCN_MEM_READ,如果代码里有可写数据,还要加上IMAGE_SCN_MEM_WRITE。
注入代码本身需要先编译成位置无关的二进制片段。我通常写一小段汇编,用汇编器生成原始机器码,然后手动提取字节。代码结构一般是:保存标志寄存器和通用寄存器、执行自定义逻辑、恢复寄存器、跳回原入口点。跳回原入口点的地址需要计算:原入口点RVA减去新节RVA,再减去当前指令到跳转指令的偏移,得到一个相对跳转。
3.3 修改入口点并验证执行流
把AddressOfEntryPoint改成新节的RVA,这是最激动人心也最容易翻车的一步。改完之后,程序启动时会先执行注入代码,再跳回原入口。验证的方法是先用调试器加载,在注入代码开头下断点,看是否命中,然后单步跟踪到跳回原入口,确认原程序逻辑正常执行。
这里有个细节:如果原程序有TLS回调,TLS回调的执行时机在入口点之前。如果你注入的代码依赖某些初始化完成后的状态,可能需要考虑TLS回调的影响。大多数情况下,入口点注入已经够用,但遇到带TLS的程序要多留个心眼。
4. 扩展节注入:不改变节表数量的另一种思路
4.1 寻找节内空隙与扩展空间
不是所有程序都方便新增节。有些程序对节数量有校验,或者节表后面没有足够空间。这时候可以考虑扩展最后一个节。具体做法是:把最后一个节的VirtualSize和SizeOfRawData都增加注入代码的长度,按对齐规则向上取整,然后把注入代码追加到该节原始数据的末尾。
这种方式的优点是节表完全不变,文件头区域不动,兼容性更好。缺点是如果最后一个节是只读的,需要修改节属性加上可执行标志;如果最后一个节后面紧跟着其他数据(比如覆盖数据、调试信息),扩展会覆盖它们,需要先确认文件末尾没有重要内容。
4.2 处理节属性与内存保护
扩展节之后,节属性必须包含可执行标志。如果原节属性没有IMAGE_SCN_MEM_EXECUTE,需要手动加上。这一步在文件层面改的是节表项的第36-40字节(Characteristics字段)。改完之后,加载器会把该节映射为可执行内存,代码才能跑。
但要注意,有些程序在运行时会对自己的代码段做完整性校验,或者用VirtualProtect修改内存保护属性。如果你的注入代码所在的内存页被改成只读,执行时就会触发访问异常。遇到这种情况,可以在注入代码开头先调用VirtualProtect把自己的内存页改成可执行可读可写,执行完再改回去。这个操作需要动态获取VirtualProtect地址,增加了代码复杂度,但能解决大部分保护冲突。
4.3 跳转指令的偏移计算与字节修补
无论新增节还是扩展节,最后都要从注入代码跳回原入口点。这个跳转通常用相对跳转指令E9,后面跟4字节的偏移。偏移的计算公式是:目标地址 - (当前指令地址 + 5)。当前指令地址就是跳转指令所在的VA,目标地址是原入口点的VA。
如果注入代码和原入口点不在同一个节,甚至不在同一个内存页,这个偏移可能超出32位有符号数的范围。虽然PE文件通常不会这么大,但理论上存在这种可能。保险的做法是用FF 25绝对跳转,后面跟目标地址的绝对VA。但绝对VA需要重定位,如果程序被加载到非默认基址,这个VA就错了。所以更稳妥的方式还是相对跳转,并在注入代码里处理重定位。
5. 注入代码的编写与提取:从汇编到字节流
5.1 用汇编器生成位置无关代码
我习惯用NASM或MASM写注入代码,编译时指定生成纯二进制格式,不链接任何库。代码里所有外部函数调用都通过动态解析获取地址,不依赖导入表。这样生成的字节流可以直接嵌入到任何PE文件里。
一个典型的注入代码框架是这样的:开头用pushad保存所有通用寄存器,用pushfd保存标志寄存器;然后调用自定义逻辑;最后用popfd和popad恢复环境;计算跳回原入口点的相对偏移;用jmp跳过去。如果自定义逻辑里需要调用API,就在保存环境之后、执行逻辑之前,先解析出API地址存到寄存器或栈上。
5.2 动态解析API地址的通用方法
解析API地址的起点是找到kernel32.dll的基址。在32位Windows上,fs:[0x30]指向PEB,PEB+0x0C是Ldr,Ldr+0x14是模块链表头,遍历链表可以找到kernel32.dll的基址。拿到基址后,解析它的导出表,找到GetProcAddress的地址,然后用GetProcAddress去获取其他需要的函数。
这个过程听起来繁琐,但代码量不大,大概几十行汇编。写一次之后可以做成模板,以后每次注入只需要改自定义逻辑部分。我建议把这部分代码单独保存成一个.asm文件,用的时候直接包含进来,避免重复劳动。
5.3 提取机器码并检查空字节
汇编器生成的二进制文件里可能包含空字节(0x00),这在字符串处理或某些注入场景下会有问题。如果注入代码是通过某种字符串方式传递的,空字节会截断数据。这时候需要用编码器对代码进行编码,运行时再解码。但如果是直接写文件,空字节没有影响,不需要额外处理。
提取机器码之后,我通常会反汇编一遍,确认指令没有错位,跳转偏移计算正确。这一步用调试器加载到目标进程里单步执行一遍是最稳妥的,能看到每条指令的实际效果。
6. 实测中容易翻车的几个关键点
6.1 文件对齐与节对齐的坑
PE文件有两个对齐值:文件对齐(FileAlignment)通常是0x200,节对齐(SectionAlignment)通常是0x1000。文件里的偏移按文件对齐,内存里的地址按节对齐。写文件时如果搞混了这两个对齐,加载器映射出来的内存布局就会错位,程序直接崩溃。
我踩过一次坑:扩展节的时候只按文件对齐增加了原始数据大小,忘了同步调整虚拟大小,结果内存里节的实际大小不够,注入代码后半部分被截断。后来养成了习惯,每次改节大小都同时检查VirtualSize和SizeOfRawData,确保两者都按各自的对齐规则向上取整。
6.2 校验和与数字签名的处理
PE可选头里有一个CheckSum字段,很多程序不校验它,但某些系统组件或驱动会校验。如果你修改了文件但没更新校验和,加载时可能被拒绝。计算校验和需要用特定的算法,Windows提供了CheckSumMappedFileAPI,但需要自己调用。简单的方式是用工具重新计算,或者直接把校验和字段清零,有些加载器对零校验和是放行的。
数字签名是另一个问题。修改文件内容后,原有的数字签名会失效。如果程序依赖签名验证,注入后可能无法运行。这种情况下,要么去掉签名验证逻辑,要么在测试环境中关闭签名强制。这部分涉及安全机制,实际使用时需要根据具体场景判断。
6.3 多线程与注入时机的冲突
如果注入代码在入口点执行,此时程序还没有创建其他线程,环境相对干净。但如果注入代码执行时间过长,或者依赖某些尚未初始化的全局变量,可能会出问题。我遇到过注入代码里调用了某个API,而该API依赖的DLL还没有被加载,导致调用失败。
解决办法是在注入代码里先确保依赖的DLL已加载,或者把注入逻辑放到更晚的时机,比如通过修改导入表让注入代码在某个特定API被调用时执行。但导入表注入的复杂度更高,需要权衡。
7. 验证注入是否“永久”生效的完整流程
7.1 静态验证:文件结构完整性检查
注入完成后,第一步是用PE查看工具检查文件结构。确认节表数量、节属性、入口点地址、文件大小等字段是否符合预期。特别要检查新增或扩展的节是否在文件中有对应的原始数据,原始数据大小是否足够容纳注入代码。
我一般会对比注入前后的节表,逐字段确认变化。如果发现某个字段被意外修改,比如SizeOfImage没有更新,加载器可能无法正确映射内存。SizeOfImage应该等于最后一个节的虚拟地址加上虚拟大小,按节对齐向上取整。
7.2 动态验证:调试器跟踪执行路径
静态检查通过后,用调试器加载注入后的文件。在注入代码开头下断点,看是否命中。命中后单步执行,观察寄存器变化、内存读写、API调用是否正常。最后跟踪到跳回原入口点,确认原程序逻辑继续执行。
如果程序在注入代码执行过程中崩溃,重点检查:栈是否平衡、寄存器是否恢复、API地址是否解析正确、跳转偏移是否计算准确。我习惯在注入代码的每个关键节点插入int3断点,逐步缩小问题范围。
7.3 重启验证:确认磁盘修改的真实性
“永久”的核心验证是重启。关闭程序,重新打开,看注入逻辑是否依然生效。如果重启后失效,说明注入只改了内存没改文件,或者文件修改被某种机制还原了。这一步能排除掉所有内存层面的假象,确认磁盘上的字节确实被修改了。
重启验证通过后,还可以把文件复制到另一台机器上运行,确认注入不依赖特定环境。如果换机器后失效,可能是注入代码里硬编码了某个地址或依赖了特定系统版本,需要改成更通用的写法。
8. 关于PE注入的一些个人经验与边界认知
PE永久注入这个方向,技术本身是中性的,它就是一种二进制文件改造手段。我在学习过程中最大的体会是:理解PE格式比记住操作步骤重要得多。格式吃透了,遇到任何变体都能自己推导出方案;只记步骤的话,换个程序就不知道从哪下手了。
另一个体会是关于“最小改动原则”。每次注入尽量只改必须改的字段,不要顺手改其他东西。我见过有人注入时把整个可选头重写了一遍,结果把Subsystem字段改错了,程序从窗口程序变成了控制台程序,虽然能跑但行为完全不对。改动越少,引入意外问题的概率越低。
还有一点是关于测试环境。PE注入的测试一定要在可控的、不影响生产的环境里做。修改系统文件或常用软件的文件,可能导致系统不稳定或软件无法启动。我通常会在虚拟机里做实验,确认稳定后再考虑是否应用到实际场景。
最后说一个实际中经常被忽略的点:注入代码的清理。如果你注入的逻辑需要在使用后恢复原状,比如临时修改某个函数行为,那注入代码里必须包含恢复逻辑。否则程序运行一段时间后,行为可能越来越偏离预期。永久注入不等于永久生效,它只是说修改被写进了文件,至于这个修改是否应该一直存在,取决于你的设计意图。
这个领域往下挖还有很多细节,比如64位PE的注入、驱动文件的注入、带壳程序的注入,每一个方向都有各自的坑和技巧。但万变不离其宗,PE格式的基本结构是共通的,把32位无壳程序的注入吃透,其他变体只是在这个基础上增加处理逻辑而已。