十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于MFC的PE文件壳实现:加载流程与重定位机制解析

基于MFC的PE文件壳实现:加载流程与重定位机制解析 简介一份基于VS2015与MFC框架的Windows PE文件壳实现源码工程面向软件开发者与安全研究人员用于软件保护、反逆向及恶意软件防护机制研究。工程在Win10环境下编译核心功能包括向目标程序注入自定义代码、对代码段加密压缩、密码弹框并支持修复重定位、全面加密、花指令混淆、反调试及动态非对称加密可显著提升逆向分析门槛。压缩包共38个文件主要包含11个头文件、8个CPP源文件、Visual Studio工程文件sln/vcxproj/filters、DLL与LIB依赖库以及资源脚本、图标和辅助说明文档整体仅448KB结构清晰适合直接打开编译学习。目前已有184人学习下载对希望掌握PE加壳原理、熟悉加密与反调试技术的开发者具有不错的参考价值。 写一个PE文件壳很多人第一反应是“加壳”两个字马上联想到加密、反调试、商业壳那一套。但这个项目的定位完全不一样——它是用MFC框架实现的一个PE文件壳核心目标是搞清楚壳的加载流程、区块表处理、重定位修正、导入表遍历这些底层机制而不是做商业级保护。也就是说这不是一个“拿来用”的工具而是一个“拿来学”的样本。我在做这个项目的过程中深刻体会到一个道理PE壳看着复杂但把加载流程一步步拆开之后底层逻辑其实非常清晰。这篇文章就把我实现MFC版本PE壳的完整思路、核心细节、踩坑记录都整理出来希望对想研究PE结构、壳原理或者Windows加载机制的朋友有帮助。1. 整体设计与实现思路1.1 壳的本质是什么剥开所有神秘感壳的本质就是三个步骤压缩、附加、跳转。压缩指的是把原始PE文件的代码段和数据段进行压缩处理附加指的是把压缩后的数据和壳本身的代码拼接到一个新的PE文件中跳转指的是程序运行后壳代码先获得控制权完成解码还原再把控制权交还给原始入口点。这三个步骤听起来简单但每个步骤背后都牵扯到PE格式的完整理解。尤其是“跳转”这一步涉及重定位表、导入表、入口点修正等一系列问题。我在设计这个MFC版本壳时核心决策是把壳的加载器部分用纯C风格代码编写界面和辅助功能用MFC实现。这样既能保证加载器代码的独立性和可调试性又能利用MFC快速构建图形界面方便后续扩展加壳选项。1.2 为什么选择MFC而不直接用Win32或控制台程序很多做壳的人习惯直接用Win32汇编或者纯C写控制台程序命令行下输入参数就能工作。那为什么要绕一圈用MFC这要从实际使用场景说起。命令行工具虽然简洁但当你需要频繁测试不同加壳选项、查看PE文件结构信息、对比加壳前后差异时命令行交互效率很低。MFC版本可以把这些操作全部图形化左侧显示原始PE文件的结构信息右侧显示加壳后的结果中间通过按钮控制加壳选项。调试时还能把每个加载步骤的状态打印到界面上这对理解壳的加载流程帮助极大。另一个重要原因是MFC框架对文件操作封装得很成熟。CFile类可以方便地读写二进制文件CString处理路径和错误信息非常顺手CDialog管理配置项也不需要额外写解析代码。我实际使用中只需要关注壳的核心逻辑不用在UI和文件IO上浪费精力。1.3 壳的加载流程设计我的MFC壳采用了一个相对简单的设计处理流程分为两阶段加壳阶段打包时读取目标PE文件解析DOS头和NT头。遍历所有区块筛选出需要压缩的数据。使用压缩算法压缩区块数据并将压缩后的区块写入新文件。在文件尾部追加壳的加载器代码和解压信息结构体。修改新文件的入口点指向壳加载器的起始地址。运行阶段运行时系统加载壳文件并执行控制权转给壳加载器。加载器根据解压信息结构体申请内存并将压缩数据解压到对应位置。遍历重定位表修正基址变化导致的问题。修复导入表处理API地址绑定。跳转到原始入口点继续执行被加壳的程序。实际写代码时我发现自己搞错了不少细节比如压缩数据在文件中的对齐方式、解压后的内存权限设置等。后面在实操部分我会详细展开这些坑。2. 核心细节解析与实操要点2.1 PE解析阶段的几个关键数据结构做壳必须对PE结构了如指掌。我把涉及的核心结构列出来这些都是操作中绕不开的。第一个是IMAGE_DOS_HEADER关键在于e_lfanew字段它指向NT头的文件偏移。解析PE文件第一步就是要通过这个字段定位NT头。第二个是IMAGE_NT_HEADERS包含Signature、FileHeader和OptionalHeader三部分。OptionalHeader里的AddressOfEntryPoint入口点、ImageBase映像基址、DataDirectory数据目录都是壳必须处理的字段。第三个是IMAGE_SECTION_HEADER每个区块对应一个结构体记录区块名、虚拟大小、虚拟地址、原始数据大小、原始数据指针和区块特征。压缩区块时修改原始数据指针和大小即可但要保证新文件能被系统正确加载。解析时一个容易被忽略的点是有些文件有多个区块比如.text、.data、.rdata等不同区块的特征值不同。比如.text通常是代码段具有执行权限.data是读写数据段通常不可执行。压缩和解压时必须保留这些特征值否则解压出来的数据即使正确也可能因缺少执行权限而崩溃。2.2 入口点跳转的两种设计方式壳最核心的操作就是篡改入口点。实现方式有两种第一种是修改AddressOfEntryPoint让它直接指向壳加载器代码在内存中的地址。这种方式比较直接但要求加载器代码在加壳时已经被映射到新文件的合适位置。第二种是利用区块间隙或者文件对齐间隙放置跳转代码修改入口点上的一段指令让其跳到壳代码处。这种方式更隐蔽相对复杂需要额外处理指令长度对齐的问题。我采用的是第一种方式同时在末尾附加一个跳转指令跳回原始入口点。实际测试中这种方式简单可靠适合学习用途。跳转代码的编写有几个注意点必须考虑重定位问题。因为我们修改的是入口点RVA相对虚拟地址如果基址不是默认的ImageBase加载器需要能够修正。跳转指令推荐使用jmp短跳转或push-ret组合避免绝对地址带来的重定位麻烦。壳加载器运行时的栈空间和数据缓冲区分配在壳代码自己的数据段内不要依赖原程序的栈布局。2.3 重定位表处理的大学问重定位表是壳实现中我花时间最多的地方。简单说重定位表记录了程序中所有需要修正的绝对地址位置。当PE文件被加载到非首选基址时系统需要根据重定位表逐一修正这些地址。壳的加载流程中如果新文件保持了原始文件的ImageBase理论上重定位表可以不清空。但这样做有个隐患如果系统加载时基址被占用了怎么办稳妥的做法是在加壳时清空重定位表在运行时由加载器自行处理。这意味着加载器中要嵌入一个最小化的重定位处理逻辑。尤其要注意的是壳加载器自己所在的区块也可能有重定位需求比如代码中使用了全局变量或函数指针。这些数据如果被放在启动代码段内不修正就会导致崩溃。我的处理办法是把壳加载器的所有可变数据和代码设计为可重定位的运行时逐项修正。虽然牺牲了一点性能但换来了兼容性的提升。2.4 导入表修复的常见思路和坑导入表记录了PE文件依赖哪些外部DLL和函数。壳如果压缩了导入表所在区块运行时需要先重建导入表再让原程序正常调用API。正确处理流程是保存原始导入表信息包括DLL名称和API名称、序号等。壳启动时通过LoadLibrary和GetProcAddress手动加载依赖DLL并解析函数地址。将解析出的地址写回导入表对应的位置。这里最大的坑是不能仅仅解析导入函数就够了还要确保原程序后续能通过导入表正常调用。如果壳覆盖了原始导入表的数据必须在新导入表中保持结构完整。我遇到一个典型的崩溃场景某个程序用了延迟加载导入表Delay-Load Import这类表不在常规导入表范围内但同样调用DLL函数。如果壳只处理了主导入表忽略延迟加载表程序运行到特定功能时才崩溃。这就是需要完整遍历原始PE数据目录的原因。3. 实操过程与核心环节实现3.1 环境准备与MFC项目配置我的开发环境是Windows 7 Visual Studio 2013但这套代码迁移到VS2008或VS2015以上版本也能正常运行。关键是项目属性里要设置好“使用Unicode字符集”因为文件路径和错误信息都可能包含中文字符代码页不一致会导致调试困难。项目结构上我建议这样组织PEShell.h/cpp壳核心逻辑包括PE解析、压缩、解压。PELoader.asm壳加载器的汇编码实现运行时解压与控制权移交。PELoaderData.h定义解压信息结构体、重定位和导入表修复所需的数据结构。MainDialog.cppMFC主界面负责文件选择、选项配置、状态显示。这样划分的好处是核心逻辑和UI解耦后续切换到其他框架或命令行模式时只需要替换界面层。3.2 壳核心代码的实现要点加壳过程的核心代码框架如下BOOL CPEShell::Pack(LPCTSTR lpszSrcFilePath, LPCTSTR lpszDstFilePath) { CFile fileSrc, fileDst; if (!fileSrc.Open(lpszSrcFilePath, CFile::modeRead | CFile::shareDenyWrite)) return FALSE; if (!fileDst.Open(lpszDstFilePath, CFile::modeWrite | CFile::modeCreate)) return FALSE; // 1. 读取原始PE文件到内存 ULONGLONG ullFileSize fileSrc.GetLength(); if (ullFileSize 0 || ullFileSize MAX_FILE_SIZE) { AfxMessageBox(_T(文件大小异常)); return FALSE; } BYTE* pBuffer new BYTE[(size_t)ullFileSize]; memset(pBuffer, 0, (size_t)ullFileSize); fileSrc.Read(pBuffer, (UINT)ullFileSize); // 2. 解析PE头确认是有效的PE文件 if (pBuffer[0] ! M || pBuffer[1] ! Z) { AfxMessageBox(_T(不是有效的PE文件)); delete[] pBuffer; return FALSE; } PIMAGE_DOS_HEADER pDosHeader (PIMAGE_DOS_HEADER)pBuffer; PIMAGE_NT_HEADERS pNtHeaders (PIMAGE_NT_HEADERS)(pBuffer pDosHeader-e_lfanew); if (pNtHeaders-Signature ! IMAGE_NT_SIGNATURE) { AfxMessageBox(_T(PE签名无效)); delete[] pBuffer; return FALSE; } // 3. 按区块压缩记录压缩信息 // 4. 写入新头部 压缩数据 加载器代码 // 5. 修改新文件的入口点 // 6. 构建解压信息结构体并附加到文件尾 delete[] pBuffer; return TRUE; }实际编码中区块遍历和压缩信息记录的代码量最大。每个区块需要记录的信息包括虚拟地址、虚拟大小、压缩前大小、压缩后大小、特征值、压缩后数据在文件中的偏移。这些信息集中打包成一个结构体数组运行时加载器通过这个数组完成还原。3.3 运行时加载器的汇编与C混编实现壳加载器是整个项目中最考验基本功的部分。它既要用汇编保证控制权转换的精确定位又要用C实现复杂的重定位和导入表修复逻辑。我采用的是编译器混合模式核心流程用C编写通过纯汇编跳板接收控制权。加载器运行的第一个函数是壳入口伪代码如下void __declspec(naked) ShellEntry() { __asm { // 保存原始寄存器状态 pushad call ShellMain // 调用主处理函数 popad // 这里跳转回原始入口点 jmp OriginalEntryPoint } }其中OriginalEntryPoint在加壳时写入加载器的数据区。ShellMain函数负责获取当前基址。这个通过call $5然后pop eax之类的方式获取运行时实际装载地址。计算加载器数据区的绝对地址。解压原始区块数据到对应虚拟地址。处理重定位和导入表。返回原始的入口点地址。3.4 解压算法选择与实现壳的压缩算法我最初用的是zlib库成熟稳定但体积较大。后来换成了LZ77的自实现版本虽然压缩率略低但代码量只占zlib的十分之一不依赖外部库。而且加载器里也必须包含对应的解压代码自实现版本可控性高得多。实际使用中需要注意压缩和解压必须严格保持对称。我踩过一个坑压缩时的窗口大小和解压时不一致导致部分程序解压后偶尔崩溃排查了半天才发现是LZ77参数不一致导致的。所以如果你的项目也只是学习用途建议先用zlib或者lzo这类成熟库跑通整个流程再考虑替换成自实现的算法。先把壳的流程走通再优化压缩算法效率会高很多。3.5 MFC界面与日志输出设计界面部分我保留了一个多行编辑框作为日志输出窗口。壳在运行每个阶段时都会调用AppendLog函数在界面上实时刷新状态信息。这个设计在调试阶段帮了大忙。一开始我是把日志写到文件里但每次测试都要打开文件看进度太慢。后来直接重定向到MFC界面上每步操作的结果一眼就能看到。比如“正在解析PE头”、“正在压缩区块1.text...压缩前4096字节压缩后1536字节”、“正在写入加载器”、“加壳完成”这些信息都整齐地显示在界面上。4. 常见问题与排查技巧实录4.1 加壳后程序一运行就崩溃这个问题绝大多数情况下出在重定位表处理不完整。我遇到过一个程序加壳后启动就报“0xC0000005访问冲突”用调试器看崩溃位置在原始入口点附近的一条mov指令访问的地址明显是错误的。排查思路是这样的先用OD或x64dbg加载加壳后的文件记录崩溃时的模块基址然后对比该地址是否与原始RVA一致。如果不一致说明某个绝对地址没有被重定位修正。找到出错指令反汇编看它引用的是什么再回原始PE文件中搜索同一位置的引用方式就能定位到是哪个区块的重定位没做对。建议在加载器中加一个自检功能初始化完成后扫描加载器自身数据区检查是否有指向无效地址的指针。这个自检在调试模式下非常有用。4.2 导入表修复后程序运行到某个功能才报错这种情况往往是延迟加载表没处理。很多程序的DLL依赖并非全部写在主导入表里部分功能模块使用延迟加载即调用时才通过LoadLibrary加载DLL。壳如果在加壳时将这些数据破坏或覆盖运行时就会出问题。检测办法比较直接加壳后用dumpbin工具加/imports参数查看新文件的导入表对比原始文件的导入表。如果发现某些DLL或函数丢失说明壳在处理导入表时遗漏了延迟加载表数据目录项。建议在加壳时标记并保留延迟加载表数据运行时修复导入表后再通知原程序重新定位延迟加载结构。不过这个复杂度较高如果只是学习用途可以先约束只处理不含延迟加载表的简单程序。4.3 部分程序加壳后体积反而变大这个问题很常见尤其是针对那些本身已经优化过的安装包或压缩程序。壳的加载器代码和数据结构有固定体积开销如果原始程序可压缩率很低加壳后的总大小自然超过原始文件。解决办法是调整压缩阈值。我在加壳选项里加了一个“压缩率低于X%的区块不压缩”的选项默认阈值是10%。也就是说压缩后如果比压缩前只小了不到10%那就保留原始数据。这样可以有效避免加壳后体积膨胀的问题。另一种思路是压缩代码段和数据段时采用不同的压缩策略代码段压缩率通常比较高数据段如果本身是文本或资源数据压缩率也不错。如果遇到数据段压缩后反而更大可以把它排除在压缩范围外。4.4 MFC中CString与字节流混用导致的问题写MFC版本时还要注意一个细节CString默认是Unicode的而PE解析和压缩的缓冲区是BYTE*类型做文件IO时如果你直接用CString强转为const char*在Unicode环境下会得到错误数据。正确做法是使用CT2A或CW2A宏做字符串编码转换或者干脆在解析PE文件时完全不用CString统一用BYTE*指针操作。只在界面上展示信息时再把BYTE数组内容转成可读文本。4.5 常见问题速查表症状可能原因解决方法加壳后启动即崩溃0xC0000005重定位表处理不完整遍历所有区块的重定位项修正绝对地址运行到某功能时提示找不到DLL延迟加载导入表未处理保留延迟加载表数据运行时重建文件体积膨胀压缩无效数据段设置压缩率阈值低于阈值则不压缩界面中文乱码CString与ANSI字节流混用使用Unicode到ANSI的显式转换加壳后程序行为异常但无崩溃原始入口点被后续数据覆盖检查新文件入口点的代码是否完整保留5. 实操心得与扩展建议做完这个MFC版PE文件壳我最大的感触是PE壳并不是什么神秘领域的专属技能它就是PE加载机制的“逆用”。平时我们写程序时想方设法让PE格式能够被系统正确加载壳则是主动控制加载过程把控制权攥在自己手里。想通这一点后再去研究商业壳的设计思路就完全能看懂了。这个项目后续的扩展方向我觉得有两个比较有价值一是加入简单的反调试检测机制比如检测IsDebuggerPresent和调试端口虽然这是商业壳的常规操作但对理解系统调试机制有帮助二是在加载器中嵌入简单的Hash校验防止壳文件本身被篡改这也是壳中壳的基础。我个人更建议你先把这个基础版本调稳定把完整流程走通再逐步增加功能。别一开始就想着做多重壳、虚拟机保护那套那样问题会多到崩溃。一个能稳定运行、自己完全理解的简单壳胜过十个半懂不懂的复杂壳。最后再分享一个小技巧调试壳加载器时不要直接用调试器附加运行。正确做法是在壳加载器中嵌入若干int 3断点指令加壳时这些指令保留在特定位置运行时用调试器查看断点是否被触发、触发时的寄存器状态和数据区内容。这样能大幅提升调试效率。本文还有配套的精品资源点击获取
返回列表