简介:这份C++语言实现的加壳基础版代码包,面向初入软件保护与逆向工程领域的开发者,提供一套可运行、可改写的加壳实现。压缩包共三十八个文件,总体积仅1.47MB,核心包括十个C++头文件、八个cpp源文件,以及sln和vcxproj工程文件,可直接用Visual Studio开发环境打开;附带生成好的加壳程序可执行文件、Shell动态库和加壳测试程序可执行文件,省去自行编译的麻烦。目前已有122人学习下载。阅读并运行CyxvcProtect工程,可清楚看到加壳流程中PE文件头部分析、壳代码编写、入口点跳转等关键环节的具体做法;配套的测试程序能直接验证加壳效果,帮助理解原程序内存加载、DLL动态库注入与控制权移交。这一基础版本虽未加入复杂的反调试和代码混淆,但足以成为深入掌握Windows系统可执行文件结构、动手实践“壳”原理的清晰起点。 看到“用C++实现的壳(基础版)”这个标题,很多刚接触C++的朋友第一反应是:这跟破解、逆向脱壳有什么关系?其实关系不大,或者说这只是其中很窄的一个用途。写一个基础版加壳工具,是理解操作系统如何加载PE文件、程序入口点如何被接管、可执行文件内部结构究竟长什么样的最佳实践。这篇文章我不会去讨论任何跟破解、绕过保护有关的内容,只从正向开发的角度,把“壳”的原理和C++实现讲清楚。适合已经学过C++基础语法、想接触底层可执行文件结构的读者,看完之后可以在自己的机器上徒手写一个能跑的最小壳。
1. 壳的本质与基础工作流程
1.1 加壳到底在做什么
很多资料对“壳”的描述很玄乎,动不动就说“压缩壳”“加密壳”“保护壳”。抛开这些营销词汇,一个壳在技术层面做的事只有两件:一是把原始程序的数据按某种方式变形,让它不再是“明文”躺在磁盘上;二是让程序启动时,由一段额外的代码先执行,把变形后的数据恢复成原始形态,再跳回原始程序去继续跑。
可以这样理解:原始程序就好比一只剥了壳的鸡蛋,直接暴露在磁盘上,谁拿到都能直接分析;加壳相当于给它包了一层蛋壳,外面的人看到的是一个带着壳的、看不出里面结构的整体。程序运行时,蛋壳先破裂,把里面的鸡蛋送到锅里,然后再开始正常烹饪。这个“蛋壳”在加壳场景里,就是一段嵌入到可执行文件中的自解密代码,业内通常叫stub、壳代码或者引导代码。
那这段stub什么时候执行?这就引出了PE文件里的一个关键字段——入口点(AddressOfEntryPoint)。操作系统加载EXE时,并不是直接跳到代码段的开头,而是读取PE头里记录的入口点地址,跳到那里开始执行。所以加壳器的核心思路就变得非常清晰:把原始入口点保存下来,然后把入口点改成一个新的地址,指向我们插入的stub。stub执行完之后,再跳回保存的原始入口点。
1.2 最小可运行壳的组成
用C++写一个基础版壳,最少需要三个组成部分:
- 加壳器(Packer):一个C++程序,负责读取目标EXE,解析PE结构,加密或压缩原始数据,生成新的EXE文件。
- 壳代码(Stub):一段被嵌入到新EXE里的引导代码,在真正的程序入口点之前执行,负责解密或解压原始代码,然后跳转到原始入口点。
- 原始程序(Target):被加壳的目标文件,通常是一个普通的Windows EXE,比如一个用C++写的小控制台程序。
加壳器运行完,输出的那个文件已经是一个“带壳EXE”了。它的内容至少包含PE头、stub代码块、被加密的原始程序数据。运行这个带壳EXE时,顺序是:系统Loader启动 -> 入口点指向stub -> stub解密数据 -> 跳回原始入口点 -> 原始程序正常运行。
这里有一个重要的认知:加壳工具的核心价值不是让程序体积变小多少,而是“改变程序在磁盘上的静态表现”。因此,一个完整的壳需要处理很多PE层面的细节,基础版我们只处理最核心的入口点修改和简单数据变形。
2. 核心原理:PE文件结构与入口点移交
2.1 PE文件头关键字段
Windows下的EXE/DLL都遵循PE格式。PE文件开头是一个64字节的DOS头,其中最关键是偏移0x3C处的e_lfanew字段,它记录了真正的PE头(NT头)在文件中的偏移地址。NT头里有一个可选头(OptionalHeader),里面存储了入口点RVA、镜像基址、节区对齐等关键信息。
我自己早期在学习时最容易绕晕的就是RVA(相对虚拟地址)和文件偏移(File Offset)的关系。RVA表示程序被加载到内存后,某个位置相对于镜像基址的偏移;文件偏移则是这个位置在磁盘文件里的字节偏移。二者通过节区表来换算:
- 文件偏移 = 节区在文件中的起始偏移 + (RVA - 节区起始RVA)
这个公式看起来简单,但非常容易算错,尤其是当数据落在文件头区域或者某个节区边界上时。后面写代码时我会专门验证这一点。
2.2 加壳器需要处理的最小步骤
一个最基础的加壳器,处理流程可以拆成以下几步:
- 读取目标EXE到内存缓冲区。
- 检查DOS签名(
MZ)和NT签名(PE\0\0),确保文件确实是合法的PE。 - 解析节区表,找到现有节区数量、文件对齐和节区对齐。
- 在文件末尾添加一个新节区(例如名字叫
.pack),这个节区用来存放stub代码和加密后的原始数据。 - 原样保留原始程序的所有节区,但把它们的原始内容全部用XOR或简单算法加密。
- 把stub代码写到新节区的开头,设置节区属性为“可读+可执行+可写”。
- 修改
AddressOfEntryPoint,让它指向stub在新节区中的RVA。 - 把原始入口点地址以参数形式传递给stub,比如以汇编immediate值的方式写到stub代码里。
- 生成新EXE,保存到磁盘。
注意第4步和第7步是整个方案的核心逻辑:加壳器不修改原始节区的数量以外的结构,只是添加一个节区并替换入口点。这样做的原因是,操作系统加载EXE时会按节区表逐个映射,我们新增的节区文件偏移和数据大小必须在文件对齐边界上对齐,否则Loader会把文件解析失败。
2.3 为什么基线壳不用IAT和重定位
不少读者可能听说过IAT(导入地址表)、重定位表这样的术语,会问:基础版里怎么不处理?原因是,当一个EXE文件的节区顺序和内容基本保持不变时,Windows加载器会在stub被调用之前就完成DLL加载和IAT修复。也就是说,PE加载器看到的还是一个结构完整的EXE:它仍然有原始的导入表、原始的资源、原始的重定位表。这些表可能被XOR加密了,那问题来了——stub还没解密之前,加载器怎么知道IAT在哪里?
这在基础版里其实有个取巧方案:加壳器只加密“作为原始程序代码段文本的节区内容”,而对于导入表、资源这些被Loader直接使用的数据结构,可以在内存中先“模拟加载”或者干脆跳过。但这样做就不是一个完整的加壳器了,一旦入口点跳到stub解密完后再跳回原始入口点,如果IAT已经被加密,Loader在启动时就崩溃了。
所以在First Shell版本里,我采用一个更加稳健的策略:剥壳时并不涉及实际的加载过程。我们做一个“基础版”壳,重点关注PE结构操作和stub跳转,而不是做一个能保护高强度商业软件的东西。加密范围选择“执行代码所在的.text节区”,同时保留原IAT等数据不变,这样Loader能在启动阶段正常完成初始化。这个取舍在后面测试时非常关键,也是初学者最容易踩的坑之一。
3. 用C++动手实现一个基础版壳
3.1 环境准备与工程结构
我选择的环境是Windows 10/11 + Visual Studio 2022,编译目标为Win32 x86 Release,因为x86支持内联汇编,写stub时更直观,便于讲解原理。实际工作中现代壳的stub也是汇编或机器码为主的,但这篇文章的主题是C++,所以我尽量用C++代码来展示加壳器,stub部分使用C++内联汇编完成核心跳转,这样既贴近标题,又能让读者看明白每一条指令在做什么。
工程结构是什么样?我用两个项目:
Packer:加壳器项目,生成一个命令行工具packer.exe。TargetApp:目标测试程序,就是一个普通的控制台程序,编译成target.exe。- 在
Packer项目中,将一段stub机器码嵌入到一个静态数组里,加壳时直接写入新节区。
这个做法很简单——先写好stub指令,提取出字节码,填充到加壳器里的数组,然后加壳时写入新节区。
3.2 加壳器的核心代码实现
先从文件读取和PE头解析开始。以下是加壳器的核心逻辑,关键步骤直接写在代码里。
#include <windows.h> #include <stdio.h> #include <vector> // 极简stub示例:原始数据XOR 0x5A,解密后jmp到原始入口点 // 这里先用内联汇编示意,后面讲到stub细节再展开 BYTE g_stubBytes[] = { // 暂时留空,下一节生成 }; // 读取文件到内存 std::vector<BYTE> ReadFileToBuffer(const char* path) { FILE* fp = nullptr; fopen_s(&fp, path, "rb"); if (!fp) return {}; fseek(fp, 0, SEEK_END); long size = ftell(fp); fseek(fp, 0, SEEK_SET); std::vector<BYTE> buffer(size); fread(buffer.data(), 1, size, fp); fclose(fp); return buffer; } bool IsValidPe(const std::vector<BYTE>& buf) { if (buf.size() < 0x40) return false; IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)buf.data(); if (dos->e_magic != IMAGE_DOS_SIGNATURE) return false; IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(buf.data() + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return false; return true; } int main(int argc, char** argv) { if (argc != 3) { printf("Usage: packer.exe <input.exe> <output.exe>\n"); return -1; } std::vector<BYTE> fileData = ReadFileToBuffer(argv[1]); if (!IsValidPe(fileData)) { printf("Invalid PE file.\n"); return -1; } PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)fileData.data(); PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(fileData.data() + pDos->e_lfanew); PIMAGE_FILE_HEADER pFileHdr = &pNt->FileHeader; PIMAGE_OPTIONAL_HEADER pOpt = &pNt->OptionalHeader; printf("Original EntryPoint RVA: 0x%X\n", pOpt->AddressOfEntryPoint); // 这里是第一步:记录原始入口点,后续stub要跳到这里 DWORD originalEntryRva = pOpt->AddressOfEntryPoint; // 这里省略添加节区、加密数据、写入stub等逻辑,下文逐个展开 return 0; }这段代码功能很单一:验证PE文件结构,读取原始入口点。真正做加壳至少还需要添加一个节区。添加节区时要注意,不能只是把节区号加一,还必须让新节区的VirtualAddress、SizeOfRawData、PointerToRawData等字段符合文件对齐规则,否则生成的EXE在运行时会被Loader拒绝。
下面是添加新节区的核心逻辑:
IMAGE_SECTION_HEADER* AddSection(PIMAGE_NT_HEADERS pNt, const char* sectionName, DWORD virtualSize, DWORD rawSize) { // 计算现有节区表末尾 WORD numSections = pNt->FileHeader.NumberOfSections; PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt); PIMAGE_SECTION_HEADER pLast = pSec + (numSections - 1); // 新节区起始的虚拟地址 = 上一节区对齐后的地址 DWORD newVirtualAddress = (pLast->VirtualAddress + pLast->Misc.VirtualSize + pOpt->SectionAlignment - 1) & ~(pOpt->SectionAlignment - 1); // 新节区在文件中的起始位置 DWORD newFileOffset = (pLast->PointerToRawData + pLast->SizeOfRawData + pOpt->FileAlignment - 1) & ~(pOpt->FileAlignment - 1); PIMAGE_SECTION_HEADER pNew = pSec + numSections; memset(pNew, 0, sizeof(IMAGE_SECTION_HEADER)); memcpy(pNew->Name, sectionName, 8); pNew->VirtualAddress = newVirtualAddress; pNew->Misc.VirtualSize = virtualSize; pNew->SizeOfRawData = rawSize; pNew->PointerToRawData = newFileOffset; pNew->Characteristics = IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_WRITE; pNt->FileHeader.NumberOfSections++; // 这些值在生成文件时需要同步更新 return pNew; }这里有个很容易出错的地方:新节区的文件偏移必须是FileAlignment的整数倍,虚拟地址必须是SectionAlignment的整数倍。有些初学者直接把新节区塞到文件末尾,却没有对齐,Loader会直接报“不是有效的Win32应用程序”。这个错误我在初学PE处理时踩过很多次,花一晚上排查才发现是对齐问题。
3.3 壳代码Stub的编写
stub是整个壳的灵魂。基础版的stub只需要做两件事:解密原始代码段、跳回原始入口点。最简单的数据变形就是XOR,解密时再XOR一遍就能恢复。
现在问题来了:stub要操作原始代码段,它得知道原始代码段的起始位置和大小。这两个值在上面的加壳器里能拿到,比如.text节区的VirtualAddress和SizeOfRawData。stub在内存中的地址,可以通过call/pop ebx这类位置无关代码拿到。
为了让代码可读性更高,我用C++内联汇编实现一个最小stub,C++部分负责声明变量,汇编部分负责实际解密和跳转。
__declspec(naked) void StubEntry() { __asm { // 保存寄存器环境 pushad // 获取当前指令地址,用于计算相对位置 call get_base get_base: pop ebx sub ebx, offset get_base // 假设加密数据起始RVA为0x1000,大小为0x2000 // 镜像基址通过fs:[0x30]获取,这里简化为获取当前模块基址 mov eax, fs:[0x30] mov edx, dword ptr [eax + 0x08] // ImageBase(PEB偏移0x08) // 计算实际解密地址 lea esi, [edx + 0x1000] // 加密数据起始VA mov ecx, 0x2000 // 数据大小 mov al, 0x5A // XOR密钥 decrypt_loop: xor byte ptr [esi], al inc esi loop decrypt_loop // 恢复寄存器环境 popad // 跳转到原始入口点,这里的0x12345678会被加壳器动态修补 jmp dword ptr [originalEntryRva] } }上面这段代码里有个关键点:jmp dword ptr [originalEntryRva]。这个占位地址在加壳器写stub时,会被替换成真实的原始入口点地址。最简单的替换方式是在加壳器的stub字节数组里预留4字节空间,然后按偏移写入真实地址。如果用C++内联汇编,想做到动态修补比较麻烦,通常的做法是先用汇编把stub编译好,再用dumpbin提取机器码,放进C++字节数组里处理。这也是为什么真实加壳工具里stub都是直接以机器码形式存在,而不是运行时编译。
如果你只是想快速验证“跳转”这一步,也可以偷懒:把stub定义为函数指针,但这样在运行带壳程序时,stub不在加壳器进程里,而是位于新EXE的新节区里,所以机器码必须完整。我这里提供的汇编代码逻辑是正确的,你提取机器码时需要把0x1000、0x2000、0x12345678这些占位值全部标记出来,然后写入时动态替换。
3.4 生成带壳文件
加壳器最后要做的是把所有数据组装成一个新文件。组装顺序是:
- 文件头+节区表(修改后的)
- 原始文件头之后,按原始内容的顺序写入各个原始节区,其中被加密的节区写加密后的数据
- 最后写入新节区:新节区开头是stub机器码,后面是对齐填充
这里有一个细节:把原始文件的节区数据从文件偏移拷贝到新文件时,不能简单地按顺序从头到尾复制整个文件。因为新增节区后,后面的节区文件偏移如果不变,Loader会认为数据到了新节区之后,但新节区还没有文件偏移。所以正确做法是:
- 解析原始文件的每个节区,得到各自的
PointerToRawData、SizeOfRawData。 - 把文件头缓冲区的节区表更新,修改新节区的字段。
- 生成一个新文件:先写入文件头,然后逐个写入原始各个节区的原始字节,再追加新节区的数据。
在实际操作中,有个更省事的方案:把原始文件的所有节区视为一个连续文件,只在文件末尾追加新节区,但需要把新节区的PointerToRawData指向原文件末尾并做文件对齐。同时修改SizeOfImage为加新节区后的内存映像大小。这个方案能跑,但生成的EXE里可能有大量无用的文件对齐空隙,不影响运行,只是体积会变大。
我个人在实际项目中更喜欢“重组文件”方案:重新规划所有节区的文件偏移。这样得到的新文件结构更干净,Debug时也更直观。但“重组文件”很容易出错,因为节区表里记录的PointerToRawData、VirtualAddress以及数据目录中的某些RVA都必须做偏移调整。对于基础版,我更推荐“附加新节区”方案。下面给出“附加新节区”的核心写文件代码。
bool SavePackedFile(const char* outputPath, const std::vector<BYTE>& headerAndSections, const std::vector<BYTE>& newSectionData, const IMAGE_SECTION_HEADER& newSec, const std::vector<BYTE>& rawOldData) { FILE* fp = nullptr; fopen_s(&fp, outputPath, "wb"); if (!fp) return false; // 写出修改后的PE头和所有旧节区数据 fwrite(headerAndSections.data(), 1, headerAndSections.size(), fp); // 这里在实际代码里需要遍历旧节区,按原始偏移写出 fwrite(rawOldData.data(), 1, rawOldData.size(), fp); // 对齐文件指针到新节区的文件偏移 fseek(fp, newSec.PointerToRawData, SEEK_SET); fwrite(newSectionData.data(), 1, newSectionData.size(), fp); fclose(fp); return true; }这个示例简化了旧节区的写出过程。真正写时要注意:旧节区的内容在文件里可能有空洞,直接用fwrite(headerAndSections.data(), 1, size, fp)会把头后面所有的字节都写进去。如果原始文件是Release版,通常文件内容紧凑,没有太大问题。但如果文件里有数字签名或其他附加数据,就不能简单追加,需要更精细处理。基础版可以直接忽略证书表,反正只是个实验工具。
3.5 运行测试与预期现象
编一个简单的TargetApp,比如打印一行字:
#include <cstdio> int main() { printf("Hello, Shell!\n"); getchar(); return 0; }用Release x86编译出target.exe,然后用packer.exe target.exe packed.exe加壳。如果一切正常,运行packed.exe应该输出和原来一样的文字。如果程序闪退,或者提示内存访问异常,说明stub里的地址计算或解密范围有问题。
我自己第一次成功跑通时,体会最明显的是AddressOfEntryPoint的变化。用dumpbin /headers查看原始EXE,入口点是0x1020之类的地址;查看加壳后的EXE,入口点变成新节区的地址,比如0x601000。这就是“入口点移交”最直观的体现。你不需要逆向工具,仅靠dumpbin和运行现象,就能判断壳是否生效。
4. 壳的进阶方向与常见问题排查
4.1 为什么真正的壳要处理IAT、重定位和反调试
基础版所以简单,是因为它绕过了大量PE细节。如果我们想把壳做得更接近商业壳,需要解决的问题会成倍增加:
- 导入表处理:如果对导入表所在节区加密,Loader在stub执行前就会尝试解析IAT,但IAT里的函数名已经被加密了,Loader会失败。所以商业壳通常在stub里手动修复IAT,或者对导入表所在节区特殊处理,保留明文。
- 重定位表:EXE通常不强制要求重定位,但DLL必须处理。加壳一个DLL时,如果原始节区被加密,重定位表的地址可能也变了,需要在stub执行前后重新修正。
- 反调试:很多壳会调用
IsDebuggerPresent、检查PEB的BeingDebugged字段等,但这属于对抗方向。如果你是做软件保护产品,这些技术是必需的;但作为学习壳的原理,反调试不是重点。 - 段切换与内存保护:基础版stub把解密数据直接写回原代码段,如果原代码段内存属性是只读的,写入会触发访问冲突。所以加壳器往往会把新节区或原节区权限改成“可写可执行”,或者在stub里调用
VirtualProtect修改内存属性。
我在这里特别说明一下权限问题,这是初学者最容易卡住的地方之一。基础版里,新节区我直接设置为READ | WRITE | EXECUTE,所以stub能正常写入;但在真实程序中,修改.text节区内存属性会触发DEP(数据执行保护)或其它安全机制,所以商业壳通常会把解密后的代码放到新申请的内存区,再用VirtualProtect赋予执行权限。这是壳实现中“为什么能写入”和“怎么能执行”的正解。
4.2 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 运行报“不是有效的Win32应用程序” | 新节区偏移未按FileAlignment对齐;PE头字段未正确更新 | 检查节区PointerToRawData和VirtualAddress是否对齐 |
| 运行时立即崩溃,没有任何输出 | stub跳转地址错误;解密范围不对 | 用dumpbin确认OEP的实际地址,检查stub跳转指令是否写对 |
| 能启动,但输出乱码 | 解密密钥或范围错误,代码执行了加密后的数据 | 跟原始文件逐字节对比解密结果,确认解密逻辑 |
| Loader报“内存访问冲突” | 写入只读节区或跨节区边界 | 在新节区里解密临时副本,或设置可写权限 |
| 加壳后程序体积异常大 | 未正确使用文件对齐或重写了大量空洞 | 使用“附加新节区”方案,避免重排节区 |
这里最值得说的其实是“解密范围不对”。基础版用XOR加密时,我建议先只加密.text节区,因为代码集中在里面;如果你加密了.rdata,负责存常量字符串的节区,那么printf的格式串也被改变了,stub必须把.rdata内容也解密回来才能正常输出。低耦合的做法是:加壳器在stub参数里写入多个待解密区域,stub循环解密所有区域。这样虽然增加了stub复杂度,但能保证程序数据完整恢复。
4.3 如何继续完善这个壳
如果按“基础版”继续往下走,推荐按以下顺序尝试升级:
- 用
zlib或LZ4替换XOR加密,从“加密壳”升级为“压缩壳”,这需要stub里内置解压算法,工作量和难度会大很多。 - 添加“手动修复IAT”功能,让壳能加密包含IAT的节区。
- 将stub从C++内联汇编改为纯汇编或机器码,增强位置无关特性,避免编译器生成多余指令。
- 加入简单的反调试检测,比如在stub里调用
NtQueryInformationProcess检查调试器状态。
我强烈建议读者从第2条开始做,因为IAT修复涉及对PE导入表、IMAGE_IMPORT_DESCRIPTOR、IMAGE_THUNK_DATA等结构的理解,是完整掌握壳原理的分水岭。这一步做通了,你对PE文件的理解会上一个台阶,再去看商业壳的源码或分析报告会轻松很多。
关于“壳”学习的心得
我到现在还记得第一次成功给自写程序加壳时的状态:入口点被改成了新节区地址,stub解密后跳到原始入口,程序输出正常,那种兴奋感持续了一整个晚上。这个基础版壳代码量不大,但已经涉及了文件格式解析、内存布局、寄存器状态保存、控制流跳转这些C++课上不会深入讲的内容。
如果你也是C++学习者,想验证自己有没有真正搞懂指针和地址,不妨动手写一写这个壳。写的过程中你大概率会遇到各种奇怪的崩溃和加载错误,但正是这些错误,会逼着你去查PE规范、去理解Loader的行为。这个过程比单纯刷面试题有价值得多。最后再分享一个调试技巧:准备一份目标EXE的十六进制备份,加壳后每出现一次崩溃,就把加壳后文件里对应位置的字节和备份对比一遍,往往能快速定位是加密范围算错了还是偏移写错了。这个习惯,我刚学的时候帮了我大忙。
本文还有配套的精品资源,点击获取