
简介面向软件保护与逆向工程学习者这份用C实现的壳基础版可供初步接触加壳技术的开发者理解程序自修改与保护原理。压缩包共38个文件整体约1.47MB主要包括10个头文件与8个C源文件同时附带可执行文件、动态链接库、Visual Studio工程文件及少量文档便于直接编译、调试与对照学习。目前已有122人浏览/学习该资源。源码完整呈现了PE文件结构分析、入口点跳转修改、壳代码注入等关键技术点配合测试程序可以观察到程序先执行壳逻辑再转交原入口的完整过程。通过自行编译与断点跟踪还能梳理加壳过程中的内存管理、导入表处理等实现细节直观理解加壳与脱壳的对抗思路为后续研究反调试、代码混淆和软件保护打下实践基础。 “壳”这个东西在安全圈和逆向圈里几乎是基本功一样的存在。我当年第一次搞懂它不是看论文也不是读什么《加密与解密》的大部头而是被一个面试官问住了PE文件双击之后系统到底怎么知道从哪里开始执行我知道是AddressOfEntryPoint他又追问那如果我把这个入口指向我自己写的一段代码再把原来的入口藏起来程序会怎样——会先执行我的代码再回到原来的地方。这就是壳。这篇博客分享我用C写的一个基础版壳完整走一遍“加壳器 壳stub 被保护程序”的闭环。它能做什么把一个exe的关键代码段做异或加密新增一个节区放还原代码修改程序入口运行时先解密再跳回原始入口。它不是商业壳那种带虚拟机、反调试、反dump的重型武器而是帮你彻底搞懂PE加载、导入表、重定位表这些概念的最小可运行实现。适合对C有一定基础、想把PE结构和程序加载过程真正揉进脑子里的人。1. 一个基础版壳到底在干什么先建立整体认知很多人对“壳”有误解觉得它是某种玄学加密或神秘代码。其实壳本质上是一个“临时代理人”程序本来由系统直接加载并跳转到入口壳出现之后加载流程变成了——系统先跳转到壳的入口壳的代码在内存里把原始程序解密、还原、修好各种表最后再把控制权交还给真正的程序入口。1.1 壳的生命周期从加壳到运行的完整链路裸的PE程序加载流程是系统读文件 → 解析PE头 → 把节区映射到内存 → 跳转AddressOfEntryPoint。加壳之后流程变成了两段式的第一段发生在磁盘上由加壳器完成。它读取原始PE文件把原始代码段用XOR或压缩算法处理掉然后新增一个节区里面放两样东西一是壳的启动代码stub二是加密后的原始数据外加一份配置信息。最后把PE头的AddressOfEntryPoint改成stub的入口写回磁盘。到这一步被保护文件的原始代码已经看不出真实指令了。第二段发生在运行时由stub完成。系统加载这个被加壳的exe跳转入口进入stub的第一条指令。stub要做的事情清单大概是读取新增节区尾部的配置拿到原始入口RVA、加密数据偏移和大小调用VirtualAlloc申请一块可读写的内存把加密数据解密/还原到这块内存修复原始程序的导入表IAT让API调用能正常解析修复重定位表应对ASLR导致的基址漂移把控制权交给原始入口OEP这个过程虽然技术点密集但每一条都是可验证、可调试的。写一遍壳等于把《Windows PE权威指南》里的重点章节亲手重做了一遍。1.2 为什么用C而不是纯C或汇编选择C有几个实际原因。第一壳stub里需要频繁操作PE结构体C可以直接include windows.h各种IMAGE_*结构体拿来就用不用自己手写结构定义。第二加壳器部分涉及文件读写、内存分配、加密逻辑C的标准库和STL让这些操作写起来很顺手。第三后续想扩展——比如加个反调试、嵌入一个LZMA解压——C生态里的现成库最多。但有一点必须说明壳stub的代码风格和普通C程序不一样。stub里尽量少用CRT运行时因为被加壳程序加载时CRT初始化函数还没跑。我用的是最笨也最可靠的办法stub入口用一个裸函数不做任何全局对象构造只做最基础的手工操作。2. 动手前必须吃透的PE结构不熟悉这些写不了壳写壳不像写普通业务代码不懂底层格式化细节寸步难行。PE结构里大多数字段加壳器都用不上但有几个定位是绝对不能搞错的。2.1 从DOS头到节表加壳器到底要读哪些数据一个标准的PE文件开头是IMAGE_DOS_HEADER里面99%的字段都不用管只有一个关键字段e_lfanew它指向真正的NT头在文件中的偏移。加壳器第一件事就是通过它定位到NT头。NT头分三部分签名4字节PE\0\0、IMAGE_FILE_HEADER、IMAGE_OPTIONAL_HEADER。重点在OptionalHeader里AddressOfEntryPoint原始程序入口RVA这是壳要修改的核心位置ImageBase程序默认加载基址重定位修复时要拿它做差值SizeOfImage整个镜像在内存中的大小新增节区后必须变大SectionAlignment和FileAlignment内存对齐和文件对齐写新增节区时用节表紧跟在NT头后面每个节区对应一个IMAGE_SECTION_HEADER里面最关键的是VirtualAddress内存RVA、Misc.VirtualSize内存大小、PointerToRawData文件偏移、SizeOfRawData文件大小。加壳器要新增节区本质上就是在这个数组末尾追加一项再修正NumberOfSections。2.2 RVA到文件偏移的换算公式写一次踩一次坑的经典位置PE加载到内存后节区按VirtualAddress排列有对齐间隙文件里按PointerToRawData排列同样有对齐。所以同一个数据在内存里的地址和在文件里的偏移是不同的。加壳器在磁盘上改文件需要用文件偏移stub运行在内存里需要把RVA转成内存地址。换算思路不复杂遍历节表找到目标RVA落在哪个节区范围内然后用公式 fileOffset PointerToRawData (rva - VirtualAddress)。这个代码我建议直接封装成工具函数因为后面修复导入表、重定位表都要反复调用。DWORD RvaToOffset(IMAGE_NT_HEADERS* ntHeaders, DWORD rva) { IMAGE_SECTION_HEADER* section IMAGE_FIRST_SECTION(ntHeaders); for (int i 0; i ntHeaders-FileHeader.NumberOfSections; i, section) { DWORD size max(section-Misc.VirtualSize, section-SizeOfRawData); if (rva section-VirtualAddress rva section-VirtualAddress size) { return section-PointerToRawData (rva - section-VirtualAddress); } } return rva; }为什么强调这是踩坑位因为很多教程直接拿RVA当文件偏移用导致修改后的文件一运行就崩。我第一次做加壳器就是在这里翻了车后面排查了很久才发现是换算漏了对齐判断。3. C实现加壳器的完整逻辑加壳器是纯文件层面的操作不动内存逻辑相对线性读入文件 → 校验PE → 修改关键字段 → 新增节区 → 写入数据 → 存盘。我拆分成了几个清晰的步骤。3.1 读取、校验、定位开始的30行代码决定成败用C读取整个PE文件到vector 然后强转成IMAGE_DOS_HEADER*校验DOS签名和NT签名。这一步不能省因为不是所有exe都是有效PE很多破损文件会让后续所有指针计算错乱。校验通过后拿到NT头指针和节表指针。接下来要确定三件事原始入口属于哪个节区、原始节区中哪些内容需要加密、新增节区应该放在文件哪个位置。原始入口归属节区决定了“壳要把哪块数据藏起来”我通常的做法是把.text节区整体加密因为代码几乎都在里面。如果程序有多个代码节区可以全部处理但基础版先拿.text练手。3.2 新增节区不是简单接在末尾要重排布局新增节区最容易出错的地方是对齐。文件里每个节区的PointerToRawData必须是FileAlignment的整数倍内存里每个节区的VirtualAddress必须是SectionAlignment的整数倍。所以在追加节区时要先把前一个节区的文件结束位置向上取整到FileAlignment内存结束位置向上取整到SectionAlignment。下面是新增节区的核心代码逻辑// 假设已经定位到最后一个节表项 lastSection IMAGE_SECTION_HEADER* last sections[headers-FileHeader.NumberOfSections - 1]; IMAGE_SECTION_HEADER* news last 1; strcpy_s((char*)news-Name, pack); news-Misc.VirtualSize stubSize encryptedSize sizeof(ShellConfig); news-VirtualAddress AlignUp(last-VirtualAddress max(last-Misc.VirtualSize, last-SizeOfRawData), headers-OptionalHeader.SectionAlignment); news-SizeOfRawData AlignUp(news-Misc.VirtualSize, headers-OptionalHeader.FileAlignment); news-PointerToRawData AlignUp(last-PointerToRawData last-SizeOfRawData, headers-OptionalHeader.FileAlignment); news-Characteristics IMAGE_SCN_CNT_CODE | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ; headers-FileHeader.NumberOfSections; headers-OptionalHeader.SizeOfImage AlignUp(news-VirtualAddress news-Misc.VirtualSize, headers-OptionalHeader.SectionAlignment);注意SizeOfImage必须同步更新否则加载器可能报“内存映像大小异常”。另一个容易被忽略的细节是新增节区在写入时原文件末尾到newSection.PointerToRawData之间可能有一段空洞需要在写文件时用零填充补上否则加的节区位置错乱。3.3 加密保护XOR轮换加密演示基础版壳我选用最朴素的XOR加密用一个轮转key对.text节区逐字节异或。这当然谈不上高强度但它的代码量小、逻辑直观、便于和stub里的解密逻辑一一对应非常适合教学。如果后续想升级可以换成RC4或AESstub里多写一段解密函数就行。void XorEncrypt(BYTE* data, DWORD size, BYTE key) { BYTE k key; for (DWORD i 0; i size; i) { data[i] ^ k; k (BYTE)(k * 7 11); } }密钥我直接写在ShellConfig里stub运行时会读取并用相同的轮换算法还原。加密完成后把stub机器码、加密后的原始代码、ShellConfig三块内容依次填充到新增节区。其中ShellConfig记录的是stub运行时的“行动指南”原始入口RVA、加密数据在新增节区中的起始位置、长度、密钥、以及原始重定位表数据等。4. stub实现从解密到返回OEP的完整路径stub是被加壳程序运行时首先执行的部分它没有CRT、没有全局对象必须像裸机代码一样谨慎。这里涉及到三个核心任务解密原代码、修复IAT、修复重定位。4.1 stub入口与最小依赖只保留必要API调用stub最尴尬的要求是它自己要调用Windows API但此刻程序可能处于“被阉割”状态IAT还没完全恢复。基础版壳的做法是加壳器在修改PE时保留原始导入表中的kernel32.dll和两个关键API条目——LoadLibraryA和GetProcAddress。这样stub启动后能通过正常流程调用这两个API拿到函数地址后再去加载原始程序依赖的其他DLL。这是最简单也最稳定的方案。很多商业壳会把导入表完全抹掉stub通过PEB遍历手动查找kernel32基址再手工解析导出表找GetProcAddress工作量一下子翻倍。基础版不折腾这个先跑通全过程最重要。4.2 解密与跳转准备把原始代码放回它该在的地方stub进入后首先要从ShellConfig里拿到原始入口RVA和加密数据的地址。然后调用VirtualAlloc分配一块和原始.text节区大小一致的内存把加密数据复制过去并逐字节异或还原。这一步完成后得到的是一块“活过来”的原始代码但它还在裸奔状态不能直接跳转必须先修IAT和重定位。设计上我特意不让stub覆盖原始节区而是新开一块独立内存存放解密结果好处是原始节区保持混乱状态别人dump下来看到的还是加密数据坏处是多一次拷贝性能略降但对基础版无伤大雅。4.3 IAT修复让API地址正确落位导入表描述了程序要调用哪些DLL、哪些函数。加壳并没有破坏导入表结构本身但原始IAT中的函数地址是在加载时才被系统填充的现在stub接管了流程系统没机会填这些地址所以stub要自己手工填一遍。完成IAT修复的思路是遍历IMAGE_IMPORT_DESCRIPTOR数组对每个DLL调用LoadLibraryA加载然后遍历INTOriginalFirstThunk或IATFirstThunk通过GetProcAddress取函数地址写回IAT数组。PIMAGE_IMPORT_DESCRIPTOR imp (PIMAGE_IMPORT_DESCRIPTOR)( (BYTE*)base Rva2Addr(nt, dataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress)); for (; imp-Name ! 0; imp) { const char* dllName (const char*)((BYTE*)base Rva2Addr(nt, imp-Name)); HMODULE dll LoadLibraryA(dllName); if (!dll) continue; PIMAGE_THUNK_DATA origThunk imp-OriginalFirstThunk ? (PIMAGE_THUNK_DATA)((BYTE*)base Rva2Addr(nt, imp-OriginalFirstThunk)) : (PIMAGE_THUNK_DATA)((BYTE*)base Rva2Addr(nt, imp-FirstThunk)); PIMAGE_THUNK_DATA iatThunk (PIMAGE_THUNK_DATA)((BYTE*)base Rva2Addr(nt, imp-FirstThunk)); for (; origThunk-u1.AddressOfData ! 0; origThunk, iatThunk) { if (origThunk-u1.Ordinal IMAGE_ORDINAL_FLAG) { iatThunk-u1.Function (ULONGLONG)GetProcAddress(dll, (LPCSTR)(origThunk-u1.Ordinal 0xFFFF)); } else { PIMAGE_IMPORT_BY_NAME ibn (PIMAGE_IMPORT_BY_NAME)( (BYTE*)base Rva2Addr(nt, (DWORD)origThunk-u1.AddressOfData)); iatThunk-u1.Function (ULONGLONG)GetProcAddress(dll, ibn-Name); } } }4.4 重定位修复最容易被忽略但必崩的环节重定位是个大坑。现代Windows默认开启ASLRexe每次加载的基址都可能不同。如果程序里存在绝对地址引用例如某个全局变量地址写死在代码里基址一漂移这些引用全错。系统在设计时用重定位表记录所有需要修正的绝对地址位置加载时统一修改。stub里修复重定位的流程其实不复杂计算当前实际基址和ImageBase的差值delta遍历重定位块对每个条目按类型把对应位置的4字节或8字节数据加上delta。但前提是加壳器在加密前必须把原始重定位表数据和边界信息都完整保存下来否则stub无从下手。重定位表里IAT修复是两套独立逻辑顺序不能反。先修重定位再修IAT或者先修IAT再修重定位都可以但绝对不能漏掉重定位。我第一次做的时候偷懒没处理重定位结果在开了ASLR的现代Windows上十次运行有八次进main函数前就崩了。5. 测试结果与踩坑实录第一次运行直接崩了代码写完编译加壳双击进程起来之后迅速消失——连弹窗都没有。说实话第一次看到这个结果我并不意外反而是“终于踩到该踩的坑了”的感觉。排查过程比写代码本身更有价值。5.1 崩溃排查链路跟着内存走一遍第一步用x64dbg加载被加壳的exe断在系统断点然后F9跑到程序断点看当前EIP。正常情况应该停在新节区的stub入口。如果停在了0x00000000或某个随机地址多半是AddressOfEntryPoint的RVA在文件转内存时算错了。第二步在stub里设断点单步执行。很多崩溃发生在IAT修复阶段。我是用日志大法在stub里调用OutputDebugStringA打印当前修复进度配合DebugView查看。这个小技巧帮我快速定位到问题是出在解压、IAT还是重定位阶段。第三步锁定问题后逐段验证。我的情况是IAT修复完后正常跳到了OEP但程序在调用某个API时崩溃最后发现是重定位没做。因为函数地址正确但函数内部依赖的全局变量地址是绝对地址没修正就必然崩。踩坑后的结论调试壳程序不能用“猜”的方式要善用日志输出和调试器下条件断点把每一步的状态都纳入监控崩溃位置就会指向真正的病灶。5.2 兼容性与杀软误报的那些事壳写完后我做了简单的兼容性测试xp时代的旧exe、新编译的32位程序、64位程序各来一遍。经验是64位程序在重定位表处理上用的是IMAGE_REL_BASED_DIR64类型stub里必须加分支否则x64程序必崩。另外基础版壳对资源丰富的exe支持不好因为资源节区涉及内存映射强行加密可能破坏资源访问。杀软误报也是绕不开的话题。只要是“修改入口点加密原始数据”这种操作模式很容易触发杀软静态特征。我自己测试时用的极小测试程序Windows Defender也照样报毒。这一点需要提前说明壳技术本身是中性的但如果用它处理别人的程序涉及的是逆向破解和违规绕过那性质就变了。我写这个壳的目的是学习和防御——比如防自己的程序被轻易篡改、做恶意软件对抗研究。合规底线一定要守住别拿壳去做侵权的事。6. 后续扩展方向从基础壳到可以实战的壳跑通这个基础版之后你可以从以下几个方向往外延伸每一条都有充足的学习空间。6.1 从XOR到混合算法XOR加密挡不住一分钟的静态分析想提高门槛可以换成RC4流加密、AES-128 CBC或者先压缩再加密。压缩算法可以选zlib、LZMA或者专为壳设计的aPLib后者压缩率高且解压代码短小常用于壳场景。stub里加解压逻辑也没多复杂但体积会变大需要平衡。6.2 增加反调试与反内存dump商业壳常见的加固手段包括检测PEB里BeingDebugged标志、检查NtQueryInformationProcess的调试端口、对关键代码做CRC自校验、通过线程反反调试。这些都有公开资料可以研究写壳的过程很适合把这些技术逐个实验一遍。6.3 变成加密壳的“壳中壳”更高阶的玩法是写一个“壳能加壳自己”加壳器本身的代码也是被壳保护的执行时壳stub再拉起来一个壳stub层层嵌套。这种可执行文件每运行一次还原流程都不同静态分析复杂度指数级增长。不过我要坦白说基础版壳最大的价值不是“防住谁”而是帮你彻底打通从文件格式到内存加载的整条知识链。网上讲PE格式的帖子很多但“看懂”和“亲手改过”完全是两回事。我写这个壳之前对导入表、重定位这些概念停留在背面试题的层面跑通一遍之后这些东西变成了肌肉记忆面试再问PE加载细节我直接能画出完整的内存布局图。最后分享一个从这次实战里沉淀下来的心得调试壳程序时第一反应千万别是“重装系统”或者“把代码推翻重写”而是冷静地在关键节点埋日志、设断点一步步缩小范围。壳的代码量不大但每一行都踩在PE格式的细节上排查一次崩崩溃你对Windows加载过程的理解就会深一层。这个项目我没用任何重型第三方库全程C和Windows API手写总代码量大概一千行出头是周末两天能认真做完的项目强烈推荐感兴趣的人试一遍。本文还有配套的精品资源点击获取