资讯动态

从零手写基础PE壳:区段对齐、入口点与SizeOfImage排错

发布时间:2026/10/24 5:44:36 来源:尧图企业网站定制
简介这份代码包围绕C实现基础版程序壳展开面向具备一定C与Windows编程基础、希望入门软件保护与逆向工程的学习者。内容从PE文件结构分析入手覆盖壳代码编写、入口点跳转、内存加载与DLL导入处理、基础错误处理等环节并引导读者理解壳代码先于原程序执行、再交还控制权的整体流程。压缩包共38个文件、约1.47MB以h头文件和cpp源文件为主体配有exe测试程序、vcxproj工程文件以及dll、lib等依赖便于在Visual Studio中查看、编译和调试。现有123人学习适合作为基础壳实现的实践样本读者可对照源码梳理PE头修改、节区与导入表处理、控制权转移和加载执行的对应关系也能借助测试程序验证编译结果与运行表现目录围绕源码、工程配置与测试程序组织便于按模块阅读和二次实验为后续研究反调试、代码混淆等进阶主题打底。1. 第一次把加壳代码跑通程序没能启动那个瞬间我才算入门自己动手用 C 写一个基础壳之前我对加壳的理解就停留在加个区段、改个入口点这两句话上。真正把二进制改完、双击运行、然后看着进程毫无提示地消失我才意识到问题全都出在我没量过的那些数上区段对齐按 0x200 还是 0x1000、SizeOfImage 到底要不要重算、入口点写的是 RVA 还是文件偏移。这篇内容讲的就是我自己从零手写 PE 壳基础版时踩过的顺序问题先讲壳的最小构成再讲每一步怎么写、参数怎么算然后复盘五种让我返工的现象最后从分析视角回头看这个基础版留下了哪些一眼就能认出来的痕迹。准备自己动手写壳的人可以照着复现做样本分析的人可以拿它当最朴素的壳长什么样的参照物。2. 壳的最小构成把基础版拆成四个能单独验证的模块写壳最容易犯的错是一次把四件事全做完再运行出了问题根本不知道是哪一层坏了。我后来的做法是强制分层每层单独产出一个可验证的中间文件能通过检查点再进下一层。这不是流程洁癖是因为 PE 的字段互相咬合一个数改错会连带出三四个看似无关的症状。2.1 先弄清楚 PE 文件头里绕不开的三处偏移不管后面逻辑写多花壳的第一件事永远是解析。有三处位置必须先拿到手而且不能用任何硬编码替代。第一处是 DOS 头偏移 0x3C 处的e_lfanew它指向真正的 NT 头。很多人图省事直接写buf 0x80在多数由标准链接器生成的样本上能跑但遇到 DOS 头被扩写过、或者 stub 塞了富签名的文件就直接读错位置后面所有偏移全盘崩。第二处是IMAGE_FILE_HEADER里的NumberOfSections和SizeOfOptionalHeader。前者决定区段表有几个合法条目后者决定区段表的起点。我一般在解析时就把区段表指针算死IMAGE_FIRST_SECTION(nt)展开就是 NT 头之后加上SizeOfOptionalHeader字节不写死 0xE0 或 0xF0。第三处是可选头里的AddressOfEntryPoint、SectionAlignment、FileAlignment、SizeOfImage、SizeOfHeaders。入口点是 RVA不是文件偏移这两个数不相等混淆是新手最常见的错误之一。对齐的两个值决定后面所有尺寸计算SizeOfImage决定加载器认为整个映像占多大加区段后不重算它映射就会缺页。还有一点必须提前确认32 位和 64 位的可选头结构不一样。用Magic字段判断0x10b走IMAGE_OPTIONAL_HEADER320x20b走IMAGE_OPTIONAL_HEADER64。基础版一般只做其中一种但至少要在入口处识别出来并明确拒绝而不是拿 32 位结构体去套 64 位文件。2.2 区段表才是加壳真正动刀的地方壳和原始程序的关系本质上是在原文件末尾追加一块自己能控制的区域然后把执行流掐到那里。所以区段表不是我读一读就完了的东西而是我要往里写新条目的地方。每个区段描述符固定 40 字节字段顺序是8 字节名字、VirtualSize、VirtualAddress、SizeOfRawData、PointerToRawData、PointerToRelocations、PointerToLinenumbers、NumberOfRelocations、NumberOfLinenumbers、Characteristics。我需要关心的只有五个VirtualAddress区段在内存里的 RVA 起点按SectionAlignment通常 0x1000对齐。VirtualSize区段在内存中的实际大小不必对齐。SizeOfRawData区段在文件中的大小按FileAlignment通常 0x200对齐。PointerToRawData区段在文件中的起点同样按FileAlignment对齐。Characteristics属性。壳区段至少需要可读、可执行如果要修改自身内容比如解密还要可写。我最早就是漏了两套对齐这件事。内存对齐和文件对齐不同一个区段在文件里占 0x200 的整数倍映射到内存后占 0x1000 的整数倍。如果新段的VirtualAddress按文件对齐去算加载器会把两个区段映射到同一片内存第二个覆盖第一个症状就是程序行为随机、有时崩有时不崩。2.3 一个最小 Stub 该做什么、不该做什么基础版的 stub 我给自己划了很窄的边界只做两件事解密被加密的原始区段然后跳回原入口点。做完这两件事就退出不做导入表重建不做重定位修正不做 TLS 回调处理。这不是偷懒是能力边界。导入表重建意味着要把原始 IAT 拆掉、在壳里重建一份、再自己填LoadLibrary/GetProcAddress工作量比前面所有步骤加起来还大重定位修正意味着要考虑 ASLR 打开时映像基址变化所有写死的绝对地址全部失效TLS 回调意味着代码会在入口点之前就被执行而基础版的解密还没开始跑那就是必然崩。所以基础版的适用范围我写得很清楚能加自己编译出来的、不带 TLS、验证时关掉动态基址的简单程序。超出这个范围就该说不。这条边界我是被现实教育的——第一个版本我拿一个带 TLS 的程序去试调试器里断点停在一个莫名其妙的地址查了很久才发现是 TLS 回调在 OEP 之前就跑了而此时内存里还是密文。3. 加壳流程复现从读文件到落盘的五步流程按顺序固定为读入并校验、找最后一个区段、追加壳区段、加密原代码段、改入口点并写回。每一步我都尽量只做一件事读完立刻打印关键字段不打印就没法定位。下面三段代码是这条流程里最容易写错的部分。3.1 读入、校验与位宽判断先把整个文件一次读进内存。加壳要重排文件布局、追加内容、改多处头部字段流式处理反而让代码更乱。// 一次性读入并用 MZ / PE 签名 Magic 做三重校验 #include windows.h #include vector #include cstdio static bool LoadFile(const char* path, std::vectorBYTE buf) { HANDLE h CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (h INVALID_HANDLE_VALUE) return false; DWORD size GetFileSize(h, nullptr); // 基础版只处理 4GB 的文件 buf.resize(size); DWORD read 0; BOOL ok ReadFile(h, buf.data(), size, read, nullptr); CloseHandle(h); return ok read size; } static bool CheckPE(const std::vectorBYTE buf, bool is64) { if (buf.size() 0x40) return false; auto* dos (PIMAGE_DOS_HEADER)buf.data(); if (dos-e_magic ! IMAGE_DOS_SIGNATURE) return false; // MZ // e_lfanew 是 NT 头的真实偏移不能假设等于 0x80 if (dos-e_lfanew 0 || (size_t)dos-e_lfanew 0x18 buf.size()) return false; auto* nt (PIMAGE_NT_HEADERS32)(buf.data() dos-e_lfanew); if (nt-Signature ! IMAGE_NT_SIGNATURE) return false; // PE\0\0 is64 (nt-OptionalHeader.Magic IMAGE_NT_OPTIONAL_HDR64_MAGIC); return true; }LoadFile里把GetFileSize的返回值直接当容量用超过 4GB 的情况基础版不处理返回失败比悄悄截断安全。CheckPE的三重校验顺序不能颠倒先看 MZ再用e_lfanew定位并做越界检查最后验证 PE 签名。越界检查那一步是我加出来的因为有些损坏文件e_lfanew是个巨大值不做检查会直接读崩。is64一旦为真后面的所有结构体都必须换成 64 位版本。我最早的处理方式是在函数里 sprintf 一句暂不支持 64 位然后返回比让程序在后续流程里静默写坏头部强得多。3.2 追加区段对齐、SizeOfImage 和区段计数三件事必须同时改这一步是全身最容易出错的地方因为要连续改动头部三处漏一处就加载失败。// 在最后一个区段之后追加一个 RWX 区段返回新段描述符 static bool AddSection(std::vectorBYTE buf, const char* name, DWORD rawSize, PIMAGE_SECTION_HEADER out) { auto* dos (PIMAGE_DOS_HEADER)buf.data(); auto* nt (PIMAGE_NT_HEADERS32)(buf.data() dos-e_lfanew); auto* sec IMAGE_FIRST_SECTION(nt); auto alignUp [](DWORD v, DWORD a) { return (v a - 1) ~(a - 1); }; DWORD secAlign nt-OptionalHeader.SectionAlignment; // 内存对齐常见 0x1000 DWORD fileAlign nt-OptionalHeader.FileAlignment; // 文件对齐常见 0x200 PIMAGE_SECTION_HEADER last sec[nt-FileHeader.NumberOfSections - 1]; PIMAGE_SECTION_HEADER ns last 1; // 区段表里的空位 ZeroMemory(ns, sizeof(IMAGE_SECTION_HEADER)); memcpy(ns-Name, name, strlen(name) 8 ? 8 : strlen(name)); // VirtualAddress 走内存对齐PointerToRawData 走文件对齐两者不能混 ns-VirtualAddress alignUp(last-VirtualAddress last-VirtualSize, secAlign); ns-SizeOfRawData alignUp(rawSize, fileAlign); ns-PointerToRawData alignUp(last-PointerToRawData last-SizeOfRawData, fileAlign); ns-VirtualSize rawSize; // 内存实际用量不必对齐 ns-Characteristics IMAGE_SCN_CNT_CODE | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE; buf.resize(ns-PointerToRawData ns-SizeOfRawData, 0); nt-FileHeader.NumberOfSections 1; // 加载器靠 SizeOfImage 决定映射范围不重算新段就映射不进来 nt-OptionalHeader.SizeOfImage alignUp(ns-VirtualAddress ns-SizeOfRawData, secAlign); nt-OptionalHeader.CheckSum 0; // 改了内容原校验和失效 out ns; return true; }几个参数的取法值得单独说。rawSize传的是加密后的壳代码长度不是原始区段长度也不是用户输入的值要按实际写入的字节数算。VirtualSize给实际值给对齐后的值在多数加载器上也能跑但和规范不符某些分析工具会报异常。SizeOfImage必须重算成新段 RVA 加新段内存占用再按内存对齐向上取整这一行漏掉加载器不会把新段映射进来跳过去就是非法访问。Characteristics用0xE0000020等价于代码、可执行、可读、可写的组合正式一点的壳会去掉可写属性把解密改成对目标段操作但基础版允许自己改自己更简单。CheckSum置 0 是我吃过的亏。改完内容不重置校验和某些加载路径会做完整性检查然后给一个和加壳完全无关的错误排查方向直接跑偏。3.3 加密原代码段并重定向入口点加密我用最朴素的按字节异或密钥一个字节写死在 stub 里。目的不是抗分析是让这段内容不是明文这件事成立这样流程才是完整的。// 对指定区段的原始数据整体异或密钥与 stub 内解密循环必须一致 static void XorSection(std::vectorBYTE buf, PIMAGE_SECTION_HEADER s, BYTE key) { BYTE* p buf.data() s-PointerToRawData; // 注意用文件偏移不是 RVA for (DWORD i 0; i s-SizeOfRawData; i) p[i] ^ key; } // 把 stub 写进新段并把入口点指过去 static void RedirectEntry(std::vectorBYTE buf, PIMAGE_SECTION_HEADER ns, DWORD oldOEP, const std::vectorBYTE stub) { memcpy(buf.data() ns-PointerToRawData, stub.data(), stub.size()); auto* dos (PIMAGE_DOS_HEADER)buf.data(); auto* nt (PIMAGE_NT_HEADERS32)(buf.data() dos-e_lfanew); // AddressOfEntryPoint 是 RVA写文件偏移会让加载器跳到错误位置 nt-OptionalHeader.AddressOfEntryPoint ns-VirtualAddress; nt-OptionalHeader.CheckSum 0; (void)oldOEP; // 旧入口点由 stub 内部以常量形式携带见下文 }XorSection里那行注释是重点PointerToRawData是文件偏移VirtualAddress是内存 RVA异或操作作用于磁盘上的字节必须用前者。我第一版用了 RVA结果加密了文件中间一段完全不相关的内容程序运行时行为诡异但不会立刻崩比直接崩更难查。RedirectEntry里我把oldOEP标了(void)保留形参是因为实际传递方式有两种一种是把它作为常量编进 stub 的机器码里另一种是写在新区段开头的固定偏移处由 stub 运行时自己读。基础版用第一种更直接但每次改样本都要重新编译 stub第二种灵活一点。注意AddressOfEntryPoint只写 RVA不要写成PointerToRawData RVA。stub 本身的逻辑用伪代码表示就是四步保存通用寄存器、按常量定位被加密的段、循环异或还原、跳到原始入口点。定位那一步需要两个常量被加密段的 RVA 和它的SizeOfRawData。这两个值在加壳时算出来编进 stub。跳回原入口点时也不能用绝对地址要用映像基址 OEP RVA而映像基址在带 ASLR 的环境里是变的所以基础版验证时先把动态基址关掉或者让 stub 用相对跳转思路绕开。写回文件之后不要立刻运行。我现在的习惯是先回读一遍把新的区段表、SizeOfImage、入口点打印出来和预期值逐个比一遍确认无误再执行。这个检查动作后面救过我很多次。4. 常见问题排查五个让我返工的记录下面五条都是我自己真实撞过的每条按现象、原因、解决写。只要写壳大概率会中其中至少两条。4.1 加壳后进程直接消失没有任何报错现象双击之后进程一闪而过事件日志里只有一条无信息的异常退出记录调试器挂上去也停在系统模块里。原因我第一个版本把新段的VirtualAddress按文件对齐算了加载器把壳段和最后一个原始段映射到重叠的内存区域覆盖了原段的头部数据跳到入口点时指令已经是垃圾。解决把所有对齐计算抽成单独一个函数只保留alignUp(value, alignment)一个实现内存对齐和文件对齐分别调用它绝不手写位运算。同时在写回前打印整张区段映射表RVA、文件偏移、大小人眼扫一遍有没有重叠。4.2 文件体积暴涨从几百 KB 变成好几 MB现象加完壳文件大了十倍以上明明只追加了几百字节的 stub。原因buf.resize()时用的长度是PointerToRawData SizeOfRawData但PointerToRawData我误用了内存对齐的VirtualAddress拼上去等于在文件里硬生生造出一个几 MB 的空洞。解决明确区分两条计算链。文件偏移链用PointerToRawData SizeOfRawData内存地址链用VirtualAddress VirtualSize两条链各自向上对齐中间不交叉。加壳后拿原始文件大小和最终大小做个比值检查超过合理范围就停下来看。4.3 在我本机能跑换一台机器就崩现象开发机上一切正常换一台测试机启动就崩或者功能跑一半出错。原因stub 里跳回原入口点时写死了绝对地址本机验证时关掉了动态基址所以恰好命中换台机器基址不同就跳飞了。解决所有跳转一律基于当前映像基址 RVA计算绝不在 stub 里写绝对地址。验证阶段可以做两轮第一轮关掉动态基址确认逻辑正确第二轮打开动态基址确认地址计算正确。两轮都过才算数。4.4 加完壳被安全软件拦了现象文件刚落地就被隔离或者运行时被挂起。原因新区段同时可写可执行、区段名明显不是链接器风格、被加密段熵值接近 8、导入表项数极少这几个特征叠在一起静态规则很容易命中。解决从工程角度去掉新区段的可写属性改成对目标段做解密、把区段名改成链接器常见风格、只加密代码段不动资源段、保留完整的导入表。要强调的是这些做法在真实对抗里只是降低误报率不是免杀基础版也不该往这个方向走。提示如果你在做的是样本分析而不是自己写壳上面这些特征反过来就是最好的识别入口第 5 章会展开。4.5 调试时断点停在一个完全没见过的地址现象在原始入口点下断点永远不命中程序却能跑起来。原因目标程序带 TLS 回调回调在入口点之前就被系统调用了而那时我的解密还没执行内存里是密文。回调里的代码在解密前的数据上跑行为必然异常断点自然也停在别处。解决加壳前先检查数据目录里的 TLS 目录项不为空就直接拒绝明确告诉使用者这个文件超出基础版范围。这类范围判定要放在流程最前面不要等改完文件再发现。5. 从分析视角回头检查这个基础版留下了哪些可以辨认的痕迹写完之后我换了个角度把它当成一个待分析的样本看看自己能多快认出它。结论是基础版几乎在所有维度上都留下了明显特征。这个练习的价值不在于把自己的壳做得多隐蔽而在于知道朴素实现长什么样遇到真实样本时能快速判断自己在看的是哪一档。5.1 静态特征区段布局与熵值最显眼第一个特征在区段表里。原始区段之后多出一个区段大小很小几百字节到几 KB属性里带可执行名字不是.text/.rdata/.data/.reloc这类链接器标准名。同时AddressOfEntryPoint落在最后一个区段里而不是.text里。这两点一致出现基本可以直接判定是加壳。第二个特征是熵。被异或加密的段字节分布接近均匀熵值会明显高于正常的代码段。算熵用几十行脚本就够# 计算指定文件区间默认整个文件的香农熵越接近 8 越像加密/压缩数据 import math, sys def entropy(data): if not data: return 0.0 freq [0] * 256 for b in data: freq[b] 1 n len(data) return -sum((c / n) * math.log2(c / n) for c in freq if c) with open(sys.argv[1], rb) as f: blob f.read() print(whole :, round(entropy(blob), 4)) print(text :, round(entropy(blob[0x400:0x400 0x2000]), 4)) # 起始偏移需按样本调整entropy统计 256 个字节值的出现频率再算信息熵全文件熵值受头部和资源影响会偏低所以更实用的是按区段分别算。正常编译器产出的代码段熵一般在 5 到 6.5 之间压缩或加密过的段通常 7.5 以上。区间偏移不是通用的必须按实际区段表来取我上面写的 0x400 只是示意。第三个特征是导入表。基础版没有重建导入表原始导入表还是原来那些但如果壳用到了系统 API比如VirtualProtect就会多出一两个来历明确的导入项。反过来如果是完整的壳导入表项数会少得可疑因为调用都被动态解析了。5.2 动态特征找原始入口点的两条常见思路分析一个朴素壳最直接的目标是找到原始入口点。常见思路有两条。一条是从壳入口点开始单步或下硬件执行断点跟到解密循环结束、看到一次远跳转落点就是原始入口点。基础版的 stub 没有花指令、没有多层跳转、没有反调试这个循环通常几十条指令就结束跟起来很快。另一条是从内存入手。运行时看哪些段的页属性从可读写变回可执行、或者哪些区域的内容在短时间内从高熵变成正常的指令序列变化点往往就在解密位置附近。这条思路对没有单步跟踪条件的场景更实用。两条思路的共同点是先确定解密在哪一段上发生再确定解密之后跳去哪里。基础版的加密是逐字节异或、范围覆盖整个被加密段所以在内存里抓解密前后两份快照做差分能直接看到变化区域范围就是被加密段。5.3 想让它不那么容易被认出来可控的三个改动如果目标是练手而不是对抗我建议只做三个改动每一个都能对应一个明确的识别特征。第一去掉新区段的可写属性。改成在 stub 里对目标段临时改属性再解密但这又依赖导入表会引入新的特征。所以更干净的做法是只加密数据段而不是代码段代码段保持明文解密时只对数据区做异或。第二区段名换成链接器风格的常见名字避免一眼可疑。这一条收益很低因为区段数量、大小、属性的组合本身就够明显了名字只是锦上添花。第三把入口点附近做轻度指令替换或插入无意义的算术等价指令。这一条对静态特征没有帮助只是让单步跟踪的人稍微慢一点。到此为止。再往上就进入加壳与脱壳的对抗性研究范畴需要处理导入表重建、API 重定向、多态变形那是另一个量级的工作而且和基础版的定位相冲突——基础版的价值在于流程清晰每一步都能用肉眼验证而不是在于难以分析。6. 一个能省下大量返工的动作写回前的自校验最后说一个我自己固化成习惯的动作它比任何优化都更省时间。在写回文件之前我会做一次半加载自校验把内存里的缓冲区当成加载后的映像按区段表把每个区段搬到ImageBase VirtualAddress对应的虚拟位置然后从新的入口点开始用一个小解释器把 stub 的前若干条指令的行为模拟一遍——只需要模拟读常量、异或一段内存、跳转这三类操作跳到目标地址后检查目标地址是否落在某个合法区段的范围内并且那段内存里确实是解密后的内容。这个检查听起来复杂实际写出来就是一百行左右的代码一个rvaToPointer辅助函数加上一次范围判断。但它能在文件落地之前抓出四类错误入口点算错跳到区段外、被加密段范围算错解密后内容不对、对齐算错两个区段映射重叠、stub 里的常量与实际写入不一致。我之前是靠改完跑一遍来验证的跑一遍崩了就去查查完再跑。一次完整循环三四分钟一天改二十次就是一个多小时全花在重复劳动上。改成写回前自校验之后绝大多数低级错误在几秒内就被拦下来了真正需要调试器介入的只剩逻辑层面的问题。从那以后我每次动 PE 文件——不管是加壳、改区段、还是只改一个入口点——都强制走一遍内存里模拟加载 入口点落点检查 区段范围检查再落盘。这套检查本身也成了我理解 PE 映射规则最有效的方式因为它是唯一一个能让我把文件布局和内存布局两套视图对齐着看的动作。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑