简介:面向软件安全与逆向工程学习者,这份C++加壳实践资源提供了从零实现基础版壳的完整工程,重点讲解PE文件结构分析、壳代码编写、入口点修改、内存管理与错误处理等核心环节,适合熟悉C++语法并希望深入理解Windows程序加载机制的中级开发者。压缩包共38个文件,体积1.47MB,包含C++源码(h/cpp)、Visual Studio工程文件(sln/vcxproj)、已编译的exe与dll,以及加壳测试程序和Shell动态库,可直接在VS环境打开、编译并验证效果。已有122人学习。通过研读源码与实际运行,读者能清晰看到壳代码如何先于原程序执行,如何完成节区操作和导入表修复,最终把控制权交还原始入口;配套的测试程序也便于对比加壳前后行为,为后续扩展反调试、代码混淆等高级保护功能预留了清晰的改造路径。
1. 用C++写一个基础壳:这可能是你理解PE文件最直接的方式
先说结论:如果你还停留在“壳 = 压缩器”的认知,趁早改过来。壳(Packers)本质上是“程序加载器”,只是它加载的不是磁盘上的普通PE,而是一个被改写过、需要先解压再映射到内存的特殊镜像。用C++实现一个基础版壳,核心价值不是“缩小体积”,而是让你亲手摸一遍PE结构、节表、导入表、重定位表和加载器协议。我见过不少做逆向分析的人,看了一堆《加密与解密》的章节,遇到加壳样本还是只能靠工具机械脱壳,原因就是没亲手实现过哪怕一个最简陋的壳。这个基础版壳能解决什么?它能让你在半小时内建立“改节表 → 写loader → 重建镜像”的完整闭环,之后再去学VM、学混淆、学反调试,才有地基。
适合谁?已经会C++、知道LoadLibrary和GetProcAddress怎么用,但还没系统写过PE解析代码的人。如果你连导入表、重定位表都还没手写过,这个资源正好卡在你的能力边界上——它不教PE基础,它直接把你扔进“改PE”这摊浑水里,逼着你把概念变成代码。
2. 壳的两大流派与基础版选型:为什么从“压缩壳”切入
2.1 压缩壳 vs 加密壳:你选的不是方案,是工程量
网上聊加壳,张口闭口“VMP”“ASProtect”,那是商业级的加密壳,动不动几十万行代码,涉及虚拟机和反调试对抗,根本不是“基础版”能覆盖的范围。基础版壳的合理落脚点是压缩壳,技术栈就三件事:压缩原始PE的节区数据、生成一段加载器代码、把两段东西拼成新的PE文件。
压缩壳的原理听着玄,拆开看就是三步。第一步,把原始PE的所有节区(.text、.data、.rdata)的原始数据取出来,用压缩算法(比如zlib,或者自制一个简单的LZ77变体)压成一坨,存放进新PE的一个专门节区(比如叫.pack)。第二步,改写原始PE的入口点(AddressOfEntryPoint)到你自己写的loader代码,让程序跑起来时先执行loader。第三步,loader运行时解压节区数据到内存中对应的虚拟地址(VA),修好导入表和重定位表,最后跳回原始入口点(OEP),继续执行原程序。
这个设计里,导入表和重定位表的处理是两座大山。压缩壳难点不在压缩本身,而在“压缩之后程序还能不能正常跑”——原PE被改得面目全非,Windows加载器不再认识它,你必须自己把加载器该干的活干完。很多初学加壳的人到这里就懵了:怎么原程序Exe在加了壳之后双击点不开了?哪儿错了?不是压缩算法写错,多半是导入表没重建,或者重定位没修好。
2.2 基础版壳的目标范围:明确做什么,才能知道自己该学什么
一份能动手落地的“基础版壳”资源,应该把目标锁定在三个明确范围里:
- 输入文件:一个合法的32位PE可执行文件(x86,非DLL,非驱动)。
- 输出文件:一个完整的新PE,文件头和节表都被重写,入口点指向loader。
- 功能边界:支持占大多数比例的、无自定义节区名要求的普通GUI/CUI程序,不支持带异常处理、带TLS回调、带调试目录的“特殊”PE。
这三点看似收窄了场景,但足够让你走完一遍完整流程。我拆完这个项目后的直接感受是:它把PE的每个关键字段都逼着你去查文档、去写代码、去验证。比你看十篇《PE结构详解》都管用。
3. 动手拆壳:核心流程、关键结构与可复现步骤
3.1 你需要什么、代码长什么样、先跑通再改
资源包里一般会给你一份完整的C++工程,我拆的这个“基础版”结构大致如下(各家命名有差异,但骨架不变):
packer/ ├── main.cpp // 入口:读取PE、调用压缩与重建 ├── pe_parser.h // PE头解析的辅助结构体 ├── compress.cpp // 压缩层(一般直接用zlib) ├── loader_stub.asm // 用汇编写的loader骨架(也有用纯C的) └── build.bat // 编译脚本(g++/MSVC都可以)第一步别急着理解每一行,先把工程编译通过。以MinGW为例:
g++ -o packer.exe main.cpp pe_parser.cpp compress.cpp -lz -static-lz是链接zlib压缩库,-static是为了让生成的packer不依赖DLL,这是我习惯的选择——工具本身要尽量简单干净,免得换台机器跑不起来。编译过了之后,拿一个简单的“Hello World”级别的小型PE做测试:
packer.exe original.exe packed.exe跑完看一眼输出文件的体积和入口点,先建立直观认识。正常情况是文件变小不少(因为压缩了节数据),入口点指向一个偏僻的地址(loader的地址)。
注意:第一轮测试千万不要拿带数字签名、带资源的大型软件去搞,签名文件会被你压碎,资源段也没法妥善重建,直接报废。拿小程序试,跑通之后再做边界扩展。
3.2 最关键的加载器:loader 的完整逻辑与参数细节
Loader是这整个壳的心脏。它被Windows加载器视为“新PE的入口点”,所以它必须自己完成导入表重建、重定位修正,然后跳回OEP。这段代码你可以用汇编写,也可以用C写再内嵌汇编特征,但无论哪种,都要保证声明的节区属性可读可写可执行(RWX)。
以下是该资源里loader的伪码骨架(实际代码因工程而异,此处演示关键逻辑):
// loader_stub 逻辑伪码(以C语法表示) void __declspec(naked) LoaderMain(void) { // 1. 定位自己所在的基地址(ImageBase) // 通过 call/pop 技巧获取当前指令的绝对地址, 然后计算基址偏移 // 2. 获取原始PE的节表信息, 找到压缩数据所在的 .pack 节 // .pack 节有一个自定义头: 魔数 + 节数量 + 每节原始RVA/大小 + 压缩数据偏移 // 3. 逐个节区解压 for each section in original_sections: // 从 .pack 中的压缩块解压到原PE节对应的 RVA 地址 LzDecompress(src, dest, compressed_size, original_size); // 4. 重建导入表 // 遍历原PE的导入表描述符(IMAGE_IMPORT_DESCRIPTOR), // 为每个DLL调用 LoadLibraryA, 为每个函数调用 GetProcAddress // 5. 处理重定位表 // 遍历重定位块, 按类型修改绝对地址: *(DWORD*)(RVA + ImageBase) += Delta // 6. 跳回原始入口点(OEP) JumpTo(OEP); }参数说明:
- 3.2中的第2步是整个loader最难定位的部分——压缩数据在你生成的壳里是“内置”的,它存在于哪个RVA,你得在打包时就把这个偏移写死在loader里,或者用某个固定的节名(比如
.pack)来索引。我一般用“节名 + 节内偏移”双保险,逻辑写在打包器的代码里。 - 第4步的导入表重建,必须分两种情况:如果原PE有合法的导入表,解压之后loader需要遍历它;如果原PE用了延迟加载(Delay-Load),那你这个基础版壳得放弃——延迟加载的触发时机在运行时,loader没法预判。
- 第5步的重定位表是32位PE最容易漏的坑。如果原PE的ImageBase是
0x00400000,而系统把它加载到了别的基址(比如开了ASLR),差值Delta会被记录,loader必须遍历整个重定位表把所有带IMAGE_REL_BASED_HIGHLOW类型的地址都加上这个Delta。漏一个,程序要么崩,要么运行到某处数据错得离谱。
3.3 重建后PE的节表布局:怎么设计你的新PE
打包器在生成“壳之后的PE”时,节表布局一般是这样的:
| 节名 | 虚拟大小 | 原始大小 | 属性(Characteristics) |
|---|---|---|---|
| .text | 原代码段大小 | 稍大(含loader) | `EXECUTE |
| .pack | 压缩数据大小 | 压缩数据大小 | READ |
| .rdata | 原导入表合并进loader节后不再单独保留 | — | — |
这里的关键设计是:loader放在.text节的最前面,入口点设置为 loader 的RVA;.pack节存所有被压缩的原始节数据,外加一个自定义结构体(我在3.2里提过的偏移表)。为什么loader要放在.text而不用新节?因为Windows对节数量有限制(PE32中最多96个),能省则省,而且放一起方便把入口点指进去。
生成后的新PE,文件头里还应该有完整的导入表(指向kernel32.dll的LoadLibraryA、GetProcAddress),否则Windows加载器连你的loader都没法启动——因为LoadLibraryA本身就需要导入。这个“导入表只为loader服务、原程序导入全部延迟到loader重建”的设计,是基础版壳的标配。
4. 让壳真正跑起来的三个绕不开的环节:压缩、重定位与导入表的内外配合
4.1 压缩层:选zlib还是手写LZ77?稳定性优先
关于压缩算法,资源里一般会预留两个选项:直接集成zlib,或者抄一份简单的LZ77实现。我的建议是无脑用zlib,理由有三点:第一,zlib的解压函数uncompress稳定到几乎不需要测试;第二,zlib的压缩率对PE这种有大量可执行代码的文件来说很可观;第三,你手写的LZ77八成有边界bug,在涉及“程序自己解压自己”的场景里,压缩bug会导致运行时崩溃,而你debug的难度远高于修复库代码。
用zlib时需要注意字节对齐问题。压缩后的数据是原始的字节流,但解压时dest地址必须是对齐的——在VirtualAlloc出来的页上解压肯定没问题,但如果你把压缩块放在文件的非对齐偏移,读取时要小心。我一般会在.pack节内自己定义一个头结构,写入魔数、解压后原始大小、解压目标RVA、压缩数据长度,顺序如下:
struct PACK_BLOCK_HEADER { DWORD magic; // 'PKB1' DWORD original_size; // 解压后的字节数 DWORD compressed_size;// 压缩后字节数 DWORD dest_rva; // 解压目标RVA(相对ImageBase的偏移) DWORD section_index; // 原节序号, 用于后续重定位表查找 };这个头是你和loader之间的“协议”。打包时写入,loader启动时读取,两边必须严格一致。协议错一个字段,整个壳就废了。
注意:如果目标PE有资源节(.rsrc),压缩它时需要评估收益和风险。资源节通常已经包含压缩过的PNG、JPEG,再压一遍收益很小。而且Windows对资源段的访问走的是内存映射,加载器并不会主动解压,这个细节后文会详述。
4.2 重定位表的处理:得逐块算Delta,漏一个就前功尽弃
重定位是基础版壳实现中“最反直觉”的一块。很多初学者的误区是:只要把ImageBase设成原PE的ImageBase,不就完了吗?问题是Windows有ASLR(地址随机化),系统并不会保证加载到你要的基址上。所以任何PE启动时都可能被“挪”到别的位置,原PE不加重定位表倒是能直接跑(因为它不会在内存中移动),但一旦被你打包重写后,原来的重定位表如果失效或丢失,程序在内存中就会变成一堆错误指针。
我实现时遇到过最典型的一次踩坑:一个简单的MFC小程序加了壳后,运行到某个消息处理函数直接 “Access Violation”。排查了很久,最后定位在重定位表上。原因是我在loader里遍历重定位表时,把IMAGE_REL_BASED_HIGHLOW类型的项算错了Delta偏移——把delta + *pAddr写成了delta + pAddr,差了一个解引用,结果指针指到了奇怪的地址空间,运行到一半撞墙。
正确的重定位修正是逐块做的:
// 重定位表修复逻辑伪码 DWORD delta = currentImageBase - originalImageBase; if (delta == 0) return; // 基址没跑, 不需要修 PIMAGE_BASE_RELOCATION reloc = (PIMAGE_BASE_RELOCATION)(imageBase + RelocDir_RVA); while (reloc->VirtualAddress && reloc->SizeOfBlock) { DWORD count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* entries = (WORD*)((BYTE*)reloc + sizeof(IMAGE_BASE_RELOCATION)); for (DWORD i = 0; i < count; i++) { DWORD type = entries[i] >> 12; // 重定位类型 DWORD offset = entries[i] & 0xFFF; // 低12位是偏移 if (type == IMAGE_REL_BASED_HIGHLOW) { DWORD* fixup = (DWORD*)(imageBase + reloc->VirtualAddress + offset); *fixup += delta; // 修正绝对地址 } } reloc = (PIMAGE_BASE_RELOCATION)((BYTE*)reloc + reloc->SizeOfBlock); }这段代码值得逐行理解,不需要背,但必须知道:每个重定位块是一个页(4KB)内所有需要修正位置的集合,VirtualAddress是页的起始RVA,SizeOfBlock是整块大小,条目是WORD,高四位是类型,低十二位是页内偏移。基础版只处理HIGHLOW类型就够了,但DIR64类型在64位程序里是必须的——这又回到第2.2节“只做32位PE”的边界上了,64位壳的loader要处理的事更多,别一上来就贪全。
4.3 导入表重建:一个需要双向验证的“黑匣子”
Loader里的导入表重建,最省事的办法是“原样搬过去”——打包时解析原始导入表,把每个DLL的每个函数Index存好,loader运行时再手动构建一张新表。但这是笨办法。更常规的做法是:利用原导入表的数据,在loader里去遍历。
导入表重建的逻辑链是这样的:
// 伪码: import table rebuild PIMAGE_IMPORT_DESCRIPTOR imp = (PIMAGE_IMPORT_DESCRIPTOR)(base + ImpRVA); while (imp->Name) { char* dllName = (char*)(base + imp->Name); HMODULE hMod = LoadLibraryA(dllName); if (!hMod) return FALSE; // 依赖DLL缺失, 直接失败 // 遍历IAT DWORD* thunk = (DWORD*)(base + imp->FirstThunk); DWORD* origThunk = (DWORD*)(base + imp->OriginalFirstThunk); // 可能为0表示按序号导入 while (*thunk) { // 高位置1表示按序号导入: 取低位就是序号 DWORD funcAddr; if (*origThunk & 0x80000000) { WORD ord = (WORD)(*origThunk & 0xFFFF); funcAddr = (DWORD)GetProcAddress(hMod, (LPCSTR)MAKEINTRESOURCE(ord)); } else { PIMAGE_IMPORT_BY_NAME iName = (PIMAGE_IMPORT_BY_NAME)(base + *origThunk); funcAddr = (DWORD)GetProcAddress(hMod, iName->Name); } if (!funcAddr) return FALSE; // 函数不存在 *thunk = funcAddr; // 填入真实地址 thunk++; origThunk++; } imp++; }这里有几个参数细节值得注意。第一,OriginalFirstThunk指向的是导入名称表(INT,Import Name Table),里面存的是指向IMAGE_IMPORT_BY_NAME的RVA;FirstThunk指向的是导入地址表(IAT,Import Address Table),这个表在loader写入前原本可以由操作系统填充,但因为你的壳让Windows加载器不再解析原始导入表了,所以IAT必须在loader里手动填。第二,如果OriginalFirstThunk为NULL,那意味着这个DLL只按序号导入,直接把低位当序号传给GetProcAddress就行。第三,大多数现代程序还会用到IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT(延迟加载),基础版壳直接屏蔽——打开PE头看一眼这目录有没有内容,有就报错放弃,别硬撑。我拆这个壳项目时,第一个月基本就在这一块反复翻车,最后稳定下来的做法就是“先放弃,再逐个手工加支持”。
5. 避坑指南:这个基础版壳最容易踩的五个坑,以及怎么排查
5.1 现象:加壳后的程序双击闪退,进程根本没有起来
原因:大概率是loader自身所在的节区属性没有写入EXECUTE | READ,或者生成的新PE里导入表(只给loader用的k32导入)没建好,Windows加载器在初始化阶段就LoadLibrary失败,直接挂掉。
解决:检查生成后的PE文件——用dumpbin或者任何PE查看工具确认入口点(AddressOfEntryPoint)指向的节区属性有哪些。如果入口点是0x401000但在.idata这类非执行节里,那loader根本跑不起来。强制把loader所在的节属性改成0xE0(IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE)是最快修复方式。
5.2 现象:程序能起来,但一运行到某个功能模块就崩溃
原因:最常见的是重定位表漏修。这个功能模块恰好用到绝对地址,基址差了Delta,访问到错页直接崩。尤其注意全局对象、虚函数表(vftable)、函数指针数组——这些都是重定位的高发区。
解决:先用Windbg或OllyDbge加载跑一下,崩溃时看访问地址是不是落在一个“没有映射”的区域,然后回溯是不是某个指针值没加上Delta。如果崩溃点反复变化,几乎可以锁定是重定位问题,逐块检查loader修复逻辑。
5.3 现象:文件加了壳反而变大了
原因:这不算bug,更可能是资源节(.rsrc)或者已经压缩过的数据节(比如安装包里的嵌入数据)被强行压了一遍,压缩率极低甚至变为负收益;再加上loader节本身的开销,最终文件变大。
解决:打包前对每个原始节做一次“压缩率预估”,低于某个阈值(比如95%)就直接原样拷贝进新节,不压。这个阈值调成多少合适?我一般用0.90——低于90%的压缩率就没必要压了,省的这几KB没意义,还增加loader负担。
5.4 现象:加壳后的程序在某些机器上跑得好,某些机器上随机崩溃
原因:如果能排除重定位,那多半是用了不规范的“按地址直接跳转”——如果你的loader里有代码直接硬编码了某个绝对地址,而系统加载基址变了,那这个地址就废了。这和重定位问题本质类似,但出现在loader自身的数据里,而不是刚解压出来的原程序数据。
解决:loader里所有内部全局数据(比如压缩块大小、某个常量)都用相对寻址,不要写死绝对数值。最稳妥的是loader用纯位置无关代码(PIC),所有访问都通过call/pop拿当前地址再偏移。这也是为什么很多壳的loader喜欢用汇编写——用C写的时候不留意编译器,很容易生成绝对地址引用。
5.5 现象:加壳后程序提示“内存不足”或解压失败
原因:loader在解压时用的内存是通过VirtualAlloc分配的,如果原PE的节区大小(VirtualSize)和你的分配大小不一致,或者节区对齐(SectionAlignment)和文件对齐(FileAlignment)混用,解压时就会写越界。特别是“SectionAlignment > FileAlignment”的PE,节的实际映射大小会比磁盘上大,loader必须按虚拟大小分配。
解决:打包时记录每个节的VirtualSize,loader用VirtualAlloc分配对应大小的内存,并保证基址和ImageBase一致(先VirtualAlloc一块足够的区域,再把原镜像一段段拷进去)。这个“整体装载”方案比逐节分配更贴近Windows加载器的真实行为,也最不容易遇到对齐问题。
5.6 现象:用OD打开加壳后的程序,停在“OEP”,一按F9就变成异常
原因分析:这是最典型的“入口点还原”问题。你的壳虽然能解压,但解压完成后跳回原程序的入口点(OEP)时,此时原程序的所有节区虽然解压了,但Windows调试器并不认识这些新节区(它们是动态分配出来的,不是文件映射的),OD的“重定位基线”不对,导致断点和单步逻辑错乱。
解决:这种不影响正常运行,但影响调试体验。如果你想在OD里调试加壳后的程序,手动指定ImageBase,并把所有新节区设为“包含有效代码”,或者干脆用Scylla工具Dump后修复一遍IAT。这个坑基本是分析侧的问题,不是壳本身的实现错误,但知道这个坑,至少你不会在“明明能正常运行”的情况下浪费一晚上排错。
6. 进阶用法:把壳做成“可配置 + 可退化”的小工具,而不是玩具
当你把上面5章的流程跑通,一个基础版壳已经能处理大部分普通32位工具类程序了。但这时的壳还是“一刀切”的——无脑压缩所有节区、强制重定位、无脑重建导入表,性能差且兼容性一般。我建议你再花点时间做三件事,把它升级成一个能放进工具箱的实用程序。
第一,加一个“节区策略配置”:在打包命令行里允许按节名过滤,例如--no-compress .rsrc,.reloc,让资源节和重定位节直接原样存储。这个功能实现起来不难,就是在打包器的节循环里加一个名字匹配函数,但能显著提升对真实软件(尤其是带大量图标的GUI程序)的兼容性。我实际测试过,一个带1MB资源段的程序,无脑压缩后反而增大了3%,加了过滤之后体积正常,启动速度也更快。
第二,把loader里的“硬编码”参数改成“打包时重定位”模式。什么意思?基础版壳一般会把OEP地址、压缩块偏移等常量写死在loader汇编里,每次打包都要重新编译一次loader。进阶版应该让loader更像一个真正的解释器:打包器生成一个“参数区”,loader启动后从这个参数区读配置,而不是依赖编译期的常量。改动量不大,但从此你就能做成一个纯数据驱动的壳,换壳的思路就打开了。
第三,写一个简单的“加载验证”脚本,每次打包后自动运行加壳产物,检查退出码。很多打包器bug是回归性的,改动一处引入新bug,如果全靠人工点开程序去试,效率太低。我把这一环放在一个批处理里:
:: verify_pack.bat @echo off packer.exe %1 %2 || (echo PACK FAILED & exit /b 1) start /wait %2 if %errorlevel% neq 0 ( echo RUNTIME FAILED with errorlevel %errorlevel% exit /b 1 ) echo PACK VERIFY PASSED这个脚本简单到不值一提,但相当实用:既能挡住“压缩环节崩了”的情况,也能挡住“能压缩但运行不了”的情况。从那以后我每次改完loader或者打包器,都强制走一遍这个流程,两分钟跑完,有回归立刻发现,再也不靠“眼睛扫代码”找bug了。希望这个习惯能帮到你——先把基础版壳做对,再往深里走,你会发现PE这块的“确定性”已经比绝大多数号称懂PE的人强太多了。
本文还有配套的精品资源,点击获取