news 2026/10/14 5:37:02

从零手搓MFC版PE文件壳:PE结构解析与入口点重定向实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手搓MFC版PE文件壳:PE结构解析与入口点重定向实战

简介:这是一份基于MFC框架的Windows PE文件壳实现源码,面向软件开发者与安全研究人员,用于保护软件产品、防止代码被轻易反编译破解,也可用于研究恶意软件防护机制与测试安全检测技术。资源包共38个文件,以h头文件、cpp源文件为核心,辅以dll、lib、vcxproj工程文件、sln解决方案及rc资源脚本等,压缩包约448KB,工程结构完整,可直接在VS2015环境下编译调试。核心功能包括向目标程序添加代码、对代码段加密压缩并保证程序正常运行、密码弹框,附加功能涵盖修复重定位、全面加密压缩、花指令混淆、反调试与动态非对称加密,能有效增强程序安全性与隐蔽性。目前已有192人学习下载,适合具备一定C++与逆向基础的开发者参考,可借此理解PE结构、壳的加载流程与代码加密思路,并在此基础上扩展自定义保护策略。

1. 从零手搓一个 MFC 版 PE 文件壳:为什么还有人愿意啃这块硬骨头

第一次接触“MFC版本PE文件壳实现”这个方向,很多人会以为它只是加壳工具的换皮版本,实际动手才发现,它把 Windows 可执行文件结构、MFC 界面框架、内存映射与节区操作揉在了一个工程里。所谓 PE 文件壳,本质是在原始可执行文件外面包一层自己的加载逻辑,让系统先跑壳代码,再由壳把原始映像还原到内存并跳转执行。MFC 在这里不是装饰,它承担的是壳配置界面、参数持久化、日志输出和调试辅助。适合谁?适合已经能看懂 PE 头、写过简单 Win32 窗口程序,又想找一个完整闭环项目把“文件解析—节区追加—重定位—跳转”串起来的开发者。它不解决业务问题,但能让你对 Windows 加载器多一层肌肉记忆。

2. PE 结构复习与 MFC 工程骨架:先把地基打对

2.1 壳到底改动了 PE 的哪些字段

一个最小可用的 PE 壳,通常做四件事:读取原始文件、在末尾追加一个新节区、把入口点指向壳代码、把原始入口点保存到壳数据里。这里最容易翻车的是节区对齐。文件对齐(FileAlignment)和节对齐(SectionAlignment)不是一回事,前者通常 0x200,后者通常 0x1000。追加节区时,文件里的原始数据要按 FileAlignment 补齐,内存中的虚拟大小要按 SectionAlignment 对齐,否则加载器会直接报“不是有效的 Win32 应用程序”。

另一个关键字段是 SizeOfImage。它必须覆盖新增节区后的虚拟地址范围,否则壳代码所在页面不会被映射。很多人只改了 SizeOfCode 或 NumberOfSections,忘了 SizeOfImage,结果壳代码根本没进内存。常见做法是:新节区 VirtualAddress 取原 SizeOfImage 按 SectionAlignment 向上对齐后的值,然后 SizeOfImage 更新为 VirtualAddress + VirtualSize 再按 SectionAlignment 对齐。

MFC 工程骨架方面,建议用基于对话框的 MFC 应用,不要用单文档。对话框工程启动路径短,消息循环简单,方便把壳配置参数(比如是否压缩、是否弹窗、日志级别)做成控件。工程字符集统一用 Unicode,但 PE 解析部分建议用std::vector<BYTE>和裸指针操作,避免 CString 在二进制数据上产生歧义。

2.2 用 MFC 对话框工程搭出壳配置界面

新建 MFC 对话框工程后,先删掉默认的“确定/取消”按钮,换成三个核心控件:一个编辑框用于显示目标文件路径,一个按钮触发文件选择,一个复选框控制是否输出日志。文件选择用CFileDialog,过滤条件写_T("可执行文件 (*.exe)|*.exe|所有文件 (*.*)|*.*||")。路径拿到后不要直接开始改 PE,先做一次只读校验:用CreateFile以GENERIC_READ打开,读前 0x400 字节,检查IMAGE_DOS_HEADER.e_magic == IMAGE_DOS_SIGNATURE和IMAGE_NT_HEADERS.Signature == IMAGE_NT_SIGNATURE。

下面这段代码是校验入口,放在对话框类的成员函数里:

// 校验目标文件是否为合法 PE32/PE32+,并返回 NT 头偏移 bool ValidatePeFile(const CString& strPath, DWORD& dwNtOffset, bool& bIs64) { HANDLE hFile = CreateFile(strPath, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile == INVALID_HANDLE_VALUE) return false; BYTE buffer[0x400] = { 0 }; DWORD dwRead = 0; ReadFile(hFile, buffer, sizeof(buffer), &dwRead, nullptr); CloseHandle(hFile); if (dwRead < sizeof(IMAGE_DOS_HEADER)) return false; auto pDos = reinterpret_cast<IMAGE_DOS_HEADER*>(buffer); if (pDos->e_magic != IMAGE_DOS_SIGNATURE) return false; if (pDos->e_lfanew <= 0 || pDos->e_lfanew + sizeof(IMAGE_NT_HEADERS) > dwRead) return false; auto pNt = reinterpret_cast<IMAGE_NT_HEADERS*>(buffer + pDos->e_lfanew); if (pNt->Signature != IMAGE_NT_SIGNATURE) return false; dwNtOffset = pDos->e_lfanew; bIs64 = (pNt->FileHeader.Machine == IMAGE_FILE_MACHINE_AMD64); return true; }

逻辑说明:先以只读方式打开文件,读前 0x400 字节足够覆盖 DOS 头和 NT 头。e_lfanew是 NT 头在文件中的偏移,必须落在已读范围内。bIs64用于后续区分 PE32 和 PE32+,因为可选头结构不同。参数dwNtOffset是输出参数,调用方拿到后可以继续用文件映射或二次读取解析节表。注意ReadFile的dwRead可能小于请求值,所以所有偏移判断都要用dwRead做上界。

2.3 节表解析与新增节区的内存布局

解析节表时,节数量在FileHeader.NumberOfSections,节表紧跟在可选头之后。可选头大小不固定,PE32 是sizeof(IMAGE_OPTIONAL_HEADER32),PE32+ 是sizeof(IMAGE_OPTIONAL_HEADER64)。常见错误是直接用sizeof(IMAGE_NT_HEADERS)去算节表偏移,在 64 位下会偏。正确做法是:pNt->FileHeader.SizeOfOptionalHeader才是可选头实际大小,节表偏移 =dwNtOffset + 4 + sizeof(IMAGE_FILE_HEADER) + SizeOfOptionalHeader。

新增节区时,我一般把壳代码编译成一个独立的二进制块,作为资源嵌入 MFC 工程,运行时从资源里读出来。节区名可以叫.mfcsh,Characteristics 设为IMAGE_SCN_CNT_CODE | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE。VirtualSize 是壳代码实际大小,SizeOfRawData 是按 FileAlignment 对齐后的大小。PointerToRawData 取原文件末尾按 FileAlignment 对齐后的偏移。

注意:如果原文件末尾没有按 FileAlignment 对齐,直接追加会导致节区数据错位。务必先补齐到对齐边界再写入。

3. 壳代码注入与入口点重定向:让原始程序先跑你的代码

3.1 壳代码的最小汇编框架与数据区约定

壳代码本身可以用 C 写,编译时关闭运行时库、关闭栈检查、指定入口函数,然后用dumpbin或自定义脚本提取.text段。壳代码要做的事:保存寄存器、获取自身基址、定位原始入口点、还原原始节区(如果做了压缩或加密)、跳转回原始入口点。最小框架如下:

// 壳代码入口,编译为独立二进制后嵌入资源 // 注意:不要使用任何 CRT 函数,所有操作直接走内存 extern "C" __declspec(naked) void ShellEntry() { __asm { pushad pushfd // 获取当前指令地址,作为壳代码基址 call GetBase GetBase: pop ebx sub ebx, offset GetBase - offset ShellEntry // 从壳数据区读取原始入口点 RVA mov eax, [ebx + OriginalEntryRva] // 计算原始入口点虚拟地址:映像基址 + RVA mov ecx, [ebx + ImageBase] add eax, ecx // 还原原始入口点前几个字节(如果被覆盖) // ... popfd popad jmp eax } }

逻辑说明:pushad/pushfd保存现场,call GetBase利用 call 压栈特性拿到当前 EIP,再减去偏移得到壳代码基址。OriginalEntryRva和ImageBase是壳数据区里的字段,由注入器在写文件时填好。最后jmp eax跳回原始入口点。参数方面,OriginalEntryRva是原始入口点的 RVA,ImageBase是 PE 可选头里的映像基址。注意 64 位下这段汇编要重写,寄存器名和调用约定都不同,建议先做 32 位版本跑通再扩展。

3.2 修改 AddressOfEntryPoint 与保存原始值

在注入器里,拿到 NT 头后,OptionalHeader.AddressOfEntryPoint就是原始入口点 RVA。把它保存到壳数据区的OriginalEntryRva字段,然后把AddressOfEntryPoint改成新节区的 VirtualAddress 加上壳代码入口在节区内的偏移。这里有个细节:壳代码入口不一定是节区开头,如果节区里还有数据区,入口偏移要算准。

// 保存原始入口点并重定向到壳代码 auto pNt32 = reinterpret_cast<IMAGE_NT_HEADERS32*>(pFileBase + dwNtOffset); DWORD dwOldEntry = pNt32->OptionalHeader.AddressOfEntryPoint; DWORD dwNewEntry = pNewSection->VirtualAddress + dwShellEntryOffset; // 把 dwOldEntry 写入壳数据区,位置由壳代码约定 WriteShellData(pShellData, offsetof(ShellData, OriginalEntryRva), dwOldEntry); WriteShellData(pShellData, offsetof(ShellData, ImageBase), pNt32->OptionalHeader.ImageBase); pNt32->OptionalHeader.AddressOfEntryPoint = dwNewEntry;

逻辑说明:dwOldEntry是原始入口点 RVA,写入壳数据区后,壳代码运行时能读到。dwNewEntry是新入口点,指向壳代码。ImageBase也写入壳数据区,因为壳代码里不能直接假设基址。参数dwShellEntryOffset是壳代码入口相对于新节区起始的偏移,通常在提取壳二进制时确定。注意修改完所有字段后,要重新计算校验和(如果原文件有),否则某些系统会拒绝加载。

3.3 写回文件时的对齐与校验和更新

写回文件时,顺序很重要:先写原始文件内容,再写补齐字节,再写新节区数据,最后更新节表。节表里新节区的PointerToRawData要指向文件末尾对齐后的位置,SizeOfRawData是壳代码按 FileAlignment 对齐后的大小。VirtualAddress是原 SizeOfImage 按 SectionAlignment 对齐后的值。SizeOfImage更新为VirtualAddress + VirtualSize再按 SectionAlignment 对齐。

校验和更新可以用CheckSumMappedFileAPI,也可以自己按 PE 校验和算法算。如果不想算,把OptionalHeader.CheckSum置 0 通常也能加载,但某些驱动或保护机制会检查。建议还是算一下,代码不长。

// 更新 SizeOfImage 和节表后,重新计算校验和 pNt32->OptionalHeader.SizeOfImage = AlignUp( pNewSection->VirtualAddress + pNewSection->Misc.VirtualSize, pNt32->OptionalHeader.SectionAlignment); // 写回文件后,用 MapFileAndCheckSum 或 CheckSumMappedFile 更新 DWORD dwHeaderSum = 0, dwFileSum = 0; CheckSumMappedFile(pFileBase, dwFileSize, &dwHeaderSum, &dwFileSum); pNt32->OptionalHeader.CheckSum = dwFileSum;

逻辑说明:AlignUp是自定义对齐函数,把值向上对齐到指定边界。CheckSumMappedFile需要文件已映射到内存,返回的dwFileSum就是新校验和。参数SectionAlignment来自可选头,通常 0x1000。注意校验和计算要在所有字段修改完成后进行,否则算出来是旧值的校验和。

4. 避坑与排查:那些让壳加载失败的小细节

4.1 加载器报“不是有效的 Win32 应用程序”

现象:双击加壳后的 exe,系统直接弹框说不是有效程序。原因:最常见的是SizeOfImage没更新,或者新节区的VirtualAddress与原有节区重叠。解决:用 PE 查看工具确认新节区的 VirtualAddress 是否大于原 SizeOfImage 对齐后的值,并检查 SizeOfImage 是否覆盖了新节区末尾。另一个原因是文件对齐没补齐,导致节表数据错位。

4.2 壳代码执行后原始程序崩溃

现象:壳代码能跑,但跳回原始入口点后程序闪退。原因:壳代码覆盖了原始入口点前几个字节,但没有还原。很多壳会把原始入口点前 5 字节保存到壳数据区,跳转前写回。解决:在壳代码跳转前,把保存的原始字节写回原始入口点地址。注意如果原始入口点所在页面是只读的,要先改内存保护。

4.3 MFC 界面卡死或资源泄漏

现象:点击“加壳”按钮后界面无响应,或者多次操作后句柄数暴涨。原因:PE 解析和写文件是同步操作,大文件会阻塞 UI 线程。解决:把加壳逻辑放到工作线程,用AfxBeginThread或std::thread,通过自定义消息更新 UI。文件句柄和映射句柄要用 RAII 封装,确保异常时也能释放。

4.4 64 位程序加壳后无法运行

现象:32 位壳注入器处理 64 位 exe 后,程序无法启动。原因:32 位注入器解析 64 位 PE 时,可选头结构用错,ImageBase和AddressOfEntryPoint偏移不对。解决:注入器要同时支持 PE32 和 PE32+,用FileHeader.Machine判断,分别用IMAGE_NT_HEADERS32和IMAGE_NT_HEADERS64。壳代码也要分 32 位和 64 位两个版本。

4.5 杀毒软件误报

现象:加壳后的 exe 被安全软件直接删除或隔离。原因:壳代码的入口点重定向、节区可写可执行等特征与恶意软件相似。解决:这是加壳技术的固有特征,没有完全避免的方法。如果只是学习用途,建议在虚拟机或隔离环境测试,不要在生产环境分发。可以尝试减少可疑特征,比如不加密原始节区、不修改导入表,但效果有限。

5. 进阶技巧:用 MFC 做壳配置持久化与多方案切换

走到这里,壳的基本闭环已经通了。但实际用起来,每次改参数都要重新编译注入器很麻烦。我一般会在 MFC 工程里加一个配置持久化模块,用GetPrivateProfileString和WritePrivateProfileString读写 ini 文件,把目标路径、是否压缩、日志级别、壳代码版本存下来。这样切换方案时不用改代码,改 ini 就行。

更进一步,可以把壳代码做成多个版本,编译成不同的资源 ID,运行时根据配置选择。比如一个“最小壳”只做入口点重定向,一个“压缩壳”额外做节区压缩。压缩算法可以用简单的 RLE 或 LZ4,但要注意压缩后的节区在内存中要先解压再跳转,解压代码也要放进壳代码里。

验证壳是否生效,我习惯用调试器附加到加壳后的进程,在壳代码入口下断点,单步跟到跳转指令,确认eax指向原始入口点。另一个方法是比较加壳前后的节区数量和入口点 RVA,用dumpbin /headers就能看到。如果入口点 RVA 落在新节区范围内,说明重定向成功。

最后说一个我踩过的坑:早期版本我在壳代码里用了printf调试,结果壳代码依赖 CRT,注入后直接崩溃。后来改成把调试信息写到固定内存地址,注入器在外部读,或者干脆用OutputDebugString但确保壳代码静态链接了最小运行时。这个习惯让我后来写任何注入代码都先检查依赖,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 5:37:02

Java+OpenCV实战:摄像头实时人脸检测与识别全流程详解

这两年一直在搞图像相关的桌面端小项目&#xff0c;最常被问到的一个问题就是&#xff1a;用Java能不能直接调摄像头做人脸识别&#xff1f;答案当然能&#xff0c;而且不用绕弯路去写JNI调C&#xff0c;直接用Java版本的OpenCV就能干完这件事。借着这篇博文&#xff0c;我把自…

作者头像 李华
网站建设 2026/10/14 5:36:57

从App开发到Unity游戏开发:认知地图与AI辅助学习路径

1. 从界面思维到空间思维&#xff1a;一次被迫的认知重启做了几年 App 开发的人&#xff0c;第一次打开 Unity 编辑器&#xff0c;大概率会经历一种很微妙的挫败感。你明明精通生命周期管理、状态管理、网络请求封装、组件化拆分&#xff0c;甚至能徒手撸一个高性能列表&#x…

作者头像 李华
网站建设 2026/10/14 5:36:02

Ubuntu 18.04重装实战指南:ROS Melodic与NVIDIA驱动兼容性排障

1. 项目概述&#xff1a;为什么重装Ubuntu 18.04这件事&#xff0c;至今仍值得认真记一笔“重装Ubuntu 18.04笔记”——看到这个标题&#xff0c;你可能会下意识皱眉&#xff1a;2018年的系统&#xff1f;现在都2024年了&#xff0c;LTS版本都迭代到22.04、24.04了&#xff0c;…

作者头像 李华
网站建设 2026/10/14 5:35:54

Discuz论坛关注功能二次开发实战:从数据表设计到通知系统

1. 为什么Discuz站点需要"关注"功能Discuz是国内覆盖面很广的PHP论坛系统&#xff0c;X3.x系列在中小型社区里依然是主力。站长做到一定阶段都会遇到同一个问题&#xff1a;用户与用户之间除了"发帖、回帖、私信"&#xff0c;缺少一种轻量的关系沉淀方式。…

作者头像 李华
网站建设 2026/10/14 5:35:52

多Agent越加越慢?四大效率陷阱与重构实战指南

1. 从单打独斗到团队作战&#xff1a;我为什么执意要上多Agent架构上个月我接手了一个内部数据整理项目&#xff0c;初期用单个Agent跑业务流程&#xff0c;效果说实话还不错。但业务方很快提出新需求&#xff1a;每天要处理的不再是几十条零散请求&#xff0c;而是几百份格式各…

作者头像 李华