简介:一份基于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.cpp:MFC主界面,负责文件选择、选项配置、状态显示。
这样划分的好处是,核心逻辑和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断点指令,加壳时这些指令保留在特定位置,运行时用调试器查看断点是否被触发、触发时的寄存器状态和数据区内容。这样能大幅提升调试效率。
本文还有配套的精品资源,点击获取