MFC/C++实现PE文件加壳工具:原理与实战解析
简介一份面向软件开发者与安全研究人员的MFC PE加壳工具源码工程。基于Windows 10与VS2015开发核心功能包括向目标PE添加自定义代码、代码段加密压缩后仍可正常运行并设有密码验证弹框还实现了重定位修复、花指令混淆、反调试及动态非对称加密适合用于软件保护或恶意软件防护对抗研究。压缩包共38个文件以h/cpp源码为主包含11个头文件与8个C源文件另有VS工程文件sln/vcxproj/filters、dll/lib运行依赖以及rc资源文件整体仅448KB结构清晰便于按模块阅读。目前已有184人学习浏览适合熟悉C并希望深入PE结构、壳技术与逆向对抗的读者。通过源码可直观理解壳加载流程与代码段压缩加密的落地方式配合txt说明能快速搭建编译环境并二次开发是学习手动加壳、反调试与花指令实战的参考项目。 做软件保护这块也快十年了说实话市面上主流的商业保护壳、开源壳我都拆过不少但要说真正把PE文件格式吃透还是得自己动手写一个壳。这个“MFC版本PE文件壳实现”的项目本质就是用MFC/C写一个完整的加壳工具把一个正常的EXE或DLL加密重组运行时可自行还原并跳转到原始入口点。它解决的问题很直接保护你的程序不被轻易静态分析、反汇编防止别人拿反编译工具直接扒逻辑。适合对PE结构有基础认识、想深入理解程序加载过程、或者需要给自家软件做轻量级保护的朋友参考。我先说结论这个项目做完你对PE文件的理解会上一个台阶很多以前看不懂的报错、运行机制会瞬间通透。整个壳的核心就三件事——给目标程序加一个节区、把原来的代码加密藏好、在运行时用一段自己写的代码解密还原并交还控制权。听起来简单实际操作里有大量细节坑下面我一步步拆开讲。1. 项目整体设计与核心思路拆解1.1 PE文件壳到底在解决什么问题先理清一个概念壳Packers / Protectors本质上是一段“引导程序”。加壳器把原始程序的代码和数据加密或压缩然后在PE文件里额外附加一段自己的代码。程序启动时系统按正常流程加载这个PE但入口点已经被修改成了壳代码。壳代码先运行把原始代码还原到内存中再把控制权交还给程序原本的入口点OEPOriginal Entry Point。这个机制能解决什么问题最常见的是防静态分析。没有壳的程序用IDA、x64dbg打开直接就能看到逻辑。加了壳以后静态文件里的代码是加密后的垃圾数据分析工具直接读出来毫无意义。另外壳还可以做反调试、反内存转储但这些属于进阶功能我这个项目重点实现的是加解密和跳转还原这条主线先把地基打好。有人会问现在市面上有现成的UPX、VMProtect、Themida为什么还要自己写这个问题我当初也想过。自己写壳最大的价值在于深度理解——你知道了IAT导入地址表、重定位表、节区对齐这些概念在实践中是怎么配合的以后不管是做逆向分析还是做漏洞研究基本功都会扎实很多。而且商业壳的特征码明显有些场景需要自定义壳来避开特征识别这时候自己写一个基础壳就很有必要。1.2 为什么用MFC/C这套技术栈标题里定了MFC这里说下我的选型和取舍。MFC在现在的开发圈子里确实不算新潮了但它做桌面工具确实方便。加壳器本身是个GUI程序涉及文件选择、进度显示、配置项管理MFC的CFileDialog、CString、CFile这些类能省大量时间。同时MFC基于Win32 API和内核打交道的能力一点没丢读写文件、映射内存、修改PE结构这些操作都可以直接用API完成。不过要分清一个边界MFC只是用来写“加壳器”这个宿主程序参与实际解密的“壳代码”也就是塞进目标程序的那段引导代码反而是用纯C和Win32 API写的甚至关键部分要手动用汇编控制寄存器。原因很简单壳代码运行在目标进程里如果你把一大坨MFC框架代码塞进去光初始化MFC运行时就得加载一堆依赖体积大、易错、兼容性差。实践中壳代码最理想的状态是“位置无关代码”PIC尽量只依赖kernel32.dll里少数几个API用GetProcAddress动态找到需要的函数入口再调用。这里有个取舍值得展开。开发时加壳器与壳代码虽然是两个不同程序但必须放在同一个解决方案里统一编译。编译加壳器时把壳代码编译成独立的二进制文件或者直接内嵌为字节数组加壳器再把这个二进制整体当作数据附加到目标PE的尾部节区里。这种“两段式”结构是这个项目的关键设计之一。我在做的时候把壳代码单独建了一个子项目配置成不依赖MFC的运行库生成的机器码全部位置无关这样拷贝到哪里都能运行。1.3 整体架构设计整个项目在架构上分成三个相对独立的模块加壳器MFC GUI程序加载目标EXE/DLL解析PE结构完成加密、添加节区、修改入口点、重建导入表等操作最后输出新的文件。壳代码Stub编译成独立的二进制运行时负责解密原始节数据、重建必要的PE数据结构最后跳转到OEP。公共工具库负责PE结构体定义、加解密算法、字节流处理等公共逻辑。这三个模块的依赖关系是单向的加壳器读取壳代码的二进制数据把它注入到目标文件里壳代码不依赖加壳器独立工作。这种分离让调试变得很方便——你可以先把壳代码编译好单独在一个测试EXE里验证解密逻辑是否正确确认无误后再集成到加壳器里。2. PE文件格式与壳的原理基础2.1 壳最关心的PE结构组成部分PE文件格式是Windows可执行文件的标准格式从偏移0开始的DOS头IMAGE_DOS_HEADER以“MZ”标志开场里面有个关键字段叫e_lfanew它指向真正的PE头IMAGE_NT_HEADERS)。这个PE头里包含了文件头IMAGE_FILE_HEADER和可选头IMAGE_OPTIONAL_HEADER可选头里存放着程序入口点RVA、镜像基址、节区对齐等信息。然后是节区表IMAGE_SECTION_HEADER数组。每个节区都有名字.text、.data、.rdata等、虚拟大小VirtualSize、虚拟地址VirtualAddress、原始数据大小SizeOfRawData、原始数据偏移PointerToRawData以及节区属性Characteristics比如可读、可写、可执行。这些参数是壳必须处理的——加壳时要把原始节区的数据加密加密前得先按这些字段定位到文件里的真实字节运行时壳代码要解密也得知道每个节区的虚拟范围。再往下是几个目录表DataDirectory位置在可选头的最后。对壳来说最重要的两个目录是导入表Import Table和重定位表Base Relocation Table。导入表记录了程序用了哪个DLL的哪些函数加载器靠这个建IAT重定位表则记录了文件里所有需要修正地址的位置当程序加载的基址和PE头里声明的ImageBase不一致时要逐项修正。这两个表在壳的实现里最容易踩坑后面我会专门讲。忠告我见过不少新手在写壳时跳过导入表和重定位表的处理只做加密和跳转结果程序一运行就崩。原因是壳代码本身用了导入表或者解密还原后原始IAT映射地址不对。PE结构是一个整体缺一环都不行。2.2 加壳与运行时还原的本质流程把这个流程画在脑子里比任何代码都重要加壳阶段静态在文件上操作解析目标PE读取所有节区数据。对每个节区的原始数据进行加密我用的是可变密钥的异或算法密钥和文件大小、入口点等参数混合生成。在文件末尾追加一个新节区比如叫.hes或.sec这个节区用来存放壳代码和加密后的密钥等数据。修改PE头的NumberOtSections节区数目字段更新SizeOfImage使其包含新节区。清空或保留原导入表如果壳代码自建IAT可以选择把原导入项转移进壳代码处理区。把AddressOfEntryPoint指向新节区里壳代码的偏移。运行阶段动态内存中操作系统正常加载PE映射节区到内存然后从入口点开始执行。控制权落在壳代码首条指令壳代码定位自己的位置通过call/pop技巧或当前位置计算。壳代码逐步完成解密密钥遍历原始节区把各节区数据按“存储位置 - RV地址”写回内存。重建IAT调用LoadLibrary/GetProcAddress把API地址写回导入表指向的地址区域。处理重定位表如果基址变了把每个需要重定位的字段加上基址差值。JMP到OEP原始程序开始正常执行。这个流程最反直觉的地方在于第3步加壳器加密的是“文件里的字节”而壳代码解密后要放回的是“内存映射后的位置”。文件偏移和虚拟地址是两个坐标系中间隔着节区偏移差。代码里必须时刻用RVA和文件偏移的换算公式文件偏移 RVA - 节区VirtualAddress 节区PointerToRawData。这个公式写错要么解密出来的数据是错的要么直接写入非法内存地址。3. MFC环境下PE壳的实操实现3.1 搭建加壳器框架与文件处理我用VS2013 MFC的对话框程序作为加壳器宿主界面很简单一个“选择文件”按钮、一个“开始加壳”按钮、一个进度条、一个编辑框显示日志。MFC的好处在这里体现得很快CFileDialog几行代码就能调起文件选择窗口CString处理路径字符串也很方便。这里会碰到一个MFC新手都绕不开的问题就是热词里那个“CString转char”。MFC在VS2013里默认使用Unicode字符集CString实际是CStringW但PE文件解析和文件读写API比如CreateFileA/CreateFileW在字符集上的要求很严格。我统一用CStringA与CString的转换逻辑CString strPath; CStringA strPathA(strPath); 然后把strPathA传递到ANSI版的API里。注意MFC项目属性里如果设置了“使用Unicode字符集”CString就是宽字符直接用CreateFile会匹配到CreateFileW没有什么问题但如果你的代码里混用了char*一定要转成CStringA再用否则中文路径会变成乱码。文件读取我用CFile类一次性读入整个文件到一个BYTE缓冲区里。对小文件几十MB以内这种做法简单直接不容易出错。读取后立刻校验DOS头MZ标志和PE签名不是合法PE就弹窗提示并终止。3.2 关键的PE解析与加密过程拿到了文件缓冲紧接着就是按结构体强转解析PE。解析的代码很枯燥核心就是获取几个关键指针PIMAGE_DOS_HEADER pDos (PIMAGE_DOS_HEADER)pFileData; PIMAGE_NT_HEADERS pNt (PIMAGE_NT_HEADERS)(pFileData pDos-e_lfanew); PIMAGE_FILE_HEADER pFile pNt-FileHeader; PIMAGE_OPTIONAL_HEADER pOpt pNt-OptionalHeader; PIMAGE_SECTION_HEADER pSec IMAGE_FIRST_SECTION(pNt);IMAGE_FIRST_SECTION是个宏它会根据可选头的大小精确计算出第一个节区表的位置强烈建议直接用这个宏而不是自己手动算偏移。接下来遍历所有节区分别处理名称、VirtualSize、VirtualAddress、PointerToRawData、SizeOfRawData这些字段。加密这个环节我选择了异或XOR算法原因很务实速度极快、实现简单、加解密是同一个操作。密钥是从目标文件本身派生的——取文件大小、入口点RVA、PE时间戳三者混合经过一个简单的线性变换生成若干字节的密钥流。这样每个文件加壳后的加密结果都不同避免一钥走天下的尴尬。代码大致是这样void EncryptSection(BYTE* pSectionData, DWORD dwSize, DWORD dwKey) { DWORD key dwKey; for (DWORD i 0; i dwSize; i) { pSectionData[i] ^ (BYTE)(key 0xFF); key (key * 1103515245 12345) 0xFFFFFFFF; // LCG伪随机序列 } }加密后要顺手把节区Characteristics设定成可信状态去掉写权限或执行权限的冲突组合避免自定义节区出现不必要的属性同时也能干扰一些粗浅的PE分析工具识别记忆中的文件布局。3.3 壳代码的编写与入口跳转壳代码是这个项目的灵魂。我把它设计成一段位置无关的汇编/C混合代码给它单独开一个编译单元。它的核心任务回顾一下解密原始节区、重建IAT、处理重定向、跳OEP。在MFC工程里混写壳代码有一点要注意不要把壳代码编译进加壳器的代码段因为加壳器运行时壳代码根本不会执行它只是作为数据被注入到目标文件里。我现在是这样做的用一个独立的C源文件写壳逻辑但用__declspec(allocate(.stub))把关键数组放到自定义节区再在加壳器里用链接器选项把这个数组导出成二进制字节流。或者更简单一点的方案直接用#pragma section和#pragma comment(linker, /SECTION:.stub,R)把壳代码放到命名的节区然后加壳器运行时通过GetModuleHandle GetProcAddress找到该节区地址把内存内容作为字节数组写入目标文件。不同编译器处理细节略有差异但思路一致。壳代码里获取API地址有套固定流程// 通过FS段寄存器获取PEB再拿ImageBase解析导入表找kernel32.dll的GetProcAddress __asm { mov eax, fs:[0x30] // PEB mov eax, [eax 0x0C] // LDR mov eax, [eax 0x14] // InMemoryOrderModuleList mov eax, [eax] // 第二个模块通常是ntdll // ... 遍历模块列表找kernel32 }这段汇编的逻辑是Windows所有进程的PEB进程环境块在FS段寄存器的0x30偏移处里面有一个加载模块链表的头指针遍历链表就能拿到kernel32.dll的基址。拿到模块基址后再解析它自己的导出表找GetProcAddress然后一切都好办了后续API都通过GetProcAddress动态取地址。这套东西第一次写会觉得很绕但我把它做成了一个固定函数之后复用很稳。当壳代码完成所有还原动作后最后一步是跳转OEP// 压入OEP地址然后ret出栈实现跳转 __asm { mov eax, dword ptr [oepAddress] jmp eax }别忘了跳转之前要把寄存器环境恢复成原始程序启动时的样子尤其是EAX等寄存器最好归零或恢复原始值。我在这里栽过跟头原始程序一启动就崩溃后来发现是壳代码运行后寄存器状态不对。标准的做法是在壳代码入口先备份上下文跳OEP前先恢复。但有一种更精细的修复思路让原程序入口从初始化的“壳代码入口处”执行OEP只需要对齐EIP不用刻意还原寄存器因为原始程序自己的启动代码本来就会初始化寄存器。不过为了稳妥我依然保留了pushad/popad的完整备份还原逻辑。3.4 完整联调从加壳到运行验证联调是暴露问题最多的环节。我的做法是这样先用一个用MFC写的“最简测试程序”一个对话框显示“OK”按钮作为加壳目标。这个测试程序很小、逻辑简单加壳后出问题容易定位。加壳前先记录它原始入口点加壳后用x64dbg打开加壳后的文件单步跟踪壳代码的每一步走到壳代码入口时看寄存器是否符合预期、解密循环结束看内存数据是否恢复成原始代码、IAT重建完检查API地址是否有效。整体加壳集成代码的主流程我用伪代码说明// 1. 读取目标PE到缓冲区 BOOL bSuccess ReadPeToBuffer(targetPath, pFileBuf, dwFileSize); // 2. 解析PE头获取各节区信息 PIMAGE_SECTION_HEADER pSections IMAGE_FIRST_SECTION(pNt); WORD wOldNumSections pNt-FileHeader.NumberOfSections; // 3. 对每个节区数据加密 for (WORD i 0; i wOldNumSections; i) { DWORD dwRawOffset pSections[i].PointerToRawData; DWORD dwRawSize pSections[i].SizeOfRawData; EncryptSection(pFileBuf dwRawOffset, dwRawSize, deriveKey(i)); } // 4. 在文件末尾追加壳代码二进制 AppendData(pFileBuf, dwFileSize, g_stubData, g_stubDataSize); // 5. 新增节区表项 PIMAGE_SECTION_HEADER pNewSection pSections[wOldNumSections]; ZeroMemory(pNewSection, sizeof(IMAGE_SECTION_HEADER)); strncpy_s((char*)pNewSection-Name, .hes, 5); pNewSection-VirtualAddress 上一个节区VirtualAddress ALIGN_UP(上一个节区VirtualSize, pOpt-SectionAlignment); pNewSection-SizeOfRawData ALIGN_UP(g_stubDataSize, pOpt-FileAlignment); pNewSection-PointerToRawData ALIGN_UP(dwFileSize, pOpt-FileAlignment); pNewSection-Characteristics IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_CNT_CODE; // 6. 更新PE头与入口点 pNt-FileHeader.NumberOfSections; pOpt-SizeOfImage ALIGN_UP(...); pOpt-AddressOfEntryPoint 新节区RVA 壳代码入口偏移; // 7. 写回文件 WriteFileFromBuffer(outputPath, pFileBuf, dwFileSize);一系列调试完成后我的加壳器实际跑通了一整套加壳流程目标测试程序从双击运行到弹出OK框整个过程不超过一秒。壳代码在OD/x64dbg里单步走完全程顺利跳回OEP测试程序后续的逻辑和UI都保持正常。那一刻真的比较有成就感这篇文章开头我说它带来的提升也正是在这里体会到的。4. 常见问题与排查技巧实录4.1 加壳后程序无法启动症状通常是双击没反应任务管理器里闪一下进程就消失。第一步先别急用x64dbg加载加壳后的文件看入口点是否落在了新节区。如果停在了0xCC填充区或空指令区说明入口点算错了。最常见的错误是新节区RVA计算时没有按节对齐值向上对齐。SectionAlignment在多数PE里是0x1000如果你的新节区VirtualAddress没有对齐到0x1000的整数倍加载器就会报“无效的PE文件”。另一个高频原因写完新节区表项后忘了更新SizeOfImage。SizeOfImage是PE加载器决定映射多大内存的总量如果新节区扩展了却没有更新它系统按旧大小映射内存壳代码一跑就访问越界直接访问违例。记住SizeOfImage要把新节区的大小按SectionAlignment向上取整加到旧SizeOfImage上。4.2 壳代码运行到一半崩溃这种问题要分情况看。如果崩溃在解密循环里大概率是文件偏移算错了读到了错误位置的数据。检查你的节区遍历逻辑指针遍历节区时不能简单用ImageFirstSection i就默认连续排列了每个节区都有各自的PointerToRawData要逐个拿到偏移再去文件缓冲区定位。如果崩溃在IAT重建环节常见原因是对原始程序的导入表理解有偏差。MFC程序依赖大量DLL和API壳代码如果直接清空原始IAT再重新构建很容易漏掉某些按需加载的模块。我的经验是初次实现壳时不要动原始导入表让加载器正常处理旧IAT壳只负责解密代码和跳转等到原理彻底吃透了再考虑自己接管IAT做进一步保护。先能跑起来再追求极致这种推进节奏能省下大量调试时间。另外如果你在壳代码里用了局部变量记住壳代码运行时的栈是正常的系统在入口前已经把栈设置好但别依赖C编译器生成栈帧的那些函数序言。壳代码最稳的写法是全部用全局/静态数组和寄存器传参或者在进入核心逻辑前手动设定ESP指向一个壳内预留的缓冲区。4.3 加壳后被杀毒软件误报这是所有壳作者都会遇到的问题。新写的壳没有任何合法签名行为上又是解密代码和动态获取API被杀毒软件怀疑成恶意行为实属正常。我的处理办法是一是给加壳器加数字签名提高可信度二是壳代码里的字符串常量全部手工加密避免出现明显的危险字符串特征比如敏感API名、加密循环特征三是谨慎处理API获取方式尽量用一步一步解析导出表的方式而不是直接调用有名API这样能降低很多误报率。要注意国产杀软的云查杀引擎尤其敏感同样的壳可能在测试机上一路通过换台装了360或火绒的机器就直接查杀。我一般会用多引擎在线扫描服务比如VirusTotal做个交叉验证确认没有明显的行为特征后再发给别人测试。这个环节不是壳本身的bug但不处理会严重影响用户的使用体验。4.4 常见问题速查表我把自己实际踩过坑的问题整理成一张表方便你对照排查症状可能原因排查方向加壳后EXE无任何反应入口点地址错误/OEP算错用调试器看入口点是否在新节区代码内运行即报0xC0000005解密完没有恢复节区访问权限或写入了未映射内存检查VirtualProtect是否正确设置PAGE_EXECUTE_READWRITE程序能启动但UI异常IAT重建遗漏部分API或重定位表未处理对比原始IAT与重建IAT的差异偶尔能跑偶尔崩溃有ASLR地址随机化的重定位没处理检查重定位表遍历每个重定位项都要加基址差值静态编译的MFC程序加壳后变大很多MFC程序自身节区多加壳后加密数据占用新节区这是正常现象可考虑加压缩算法减小体积64位程序加壳失败壳代码未适配x64本项目默认处理x86x64需要重写壳代码和寄存器逻辑4.5 调试壳代码的几个独家技巧壳代码调试本质上就是“在没有源码的环境里看汇编”。我分享几个我在实际调试中摸索出的技巧首先在壳代码里故意加一个“软件断点陷阱”帮助定位。在关键步骤比如解密集满入口、IAT重建完成、准备跳OEP前插入一条0xCC指令。加壳后的程序一运行调试器会停在第一处0xCC处你就能在这逐步观察每一步的状态。不过要记得发布版本里这些断点必须全部去掉否则目标程序跑起来会持续触发断点。我的做法是把这些断点放在一个临时配置里发布时用宏切换关闭。其次用“对比原始文件调试”的方式找逻辑差异。我写了一个辅助脚本能把原始程序和加壳程序在同一个调试器里同时打开。壳跳OEP之前把加壳程序的寄存器、栈、关键内存地址和原始程序刚启动时的状态做diff差异一目了然。这个方法尤其适合定位“壳运行完破坏了原始上下文”的疑难杂症。最后建议把加密算法和壳代码做成可以在命令行独立运行的小工具。这样你可以在纯控制台环境里先验证加解密逻辑写点单元测试确认无误后再集成到MFC界面里。命令行工具调试效率远高于GUI加壳器这个经验用在工作上也是一样的道理。5. 项目收获与后续扩展方向写完这个MFC版本的PE文件壳我对Windows程序加载机制的理解比看十本书都扎实。以前看PE结构觉得就是背表格现在再看这些字段脑子里会自动浮现加载器运行时的处理逻辑系统怎么从MZ找到PE头怎么按节区映射内存怎么把导入表变成IAT重定位表又在哪里修正。这种“知其所以然”的感觉是做纯应用层开发很难获得的。我更想说的是壳和脱壳其实是同一个硬币的两面。写完壳你自然能看懂脱壳的思路。比如调试器里常见的“内存断点法”“运行脱壳法”本质都是在壳代码解密完成后、跳OEP前那一瞬间把内存dump出来。理解了壳的原理你会发现所谓脱壳就是在找那个“解密完毕、控制权还没交还”的窗口。这个认知让我在分析恶意样本时也获得了很大帮助。这个项目后续可以扩展的方向不少。想在保护强度上继续做可以加模拟执行或虚拟机保护VM思路把关键代码翻译成自定义字节码让静态分析工具彻底看不懂想在体积优化上做可以引入ZLIB/LZMA压缩算法让壳不仅有加密功能还有压缩能力类似UPX的模式想提升兼容性那就必须适配x64——64位程序没有FS:[0x30]的PEB访问方式改用GS段寄存器数量也更多处理逻辑要整体调整。最后再分享一个实际使用中的小心得加壳后的程序建议在干净的系统环境虚拟机里测一遍再在装了各种杀软的真实环境里测一遍两边的行为都要正常才能交付。壳这个东西本质是给软件穿上“铠甲”但铠甲做太重会影响行动做太薄又起不到防护作用怎么在体积、兼容性和防护强度之间取得平衡是接下来慢慢要根据实际需求优化的事。本文还有配套的精品资源点击获取