文章总结: 文章手把手示范为64位PE写简易加壳器:用汇编写Stub保存现场、XOR循环解密、再跳回OEP;C++加壳器定位.text、加密前4KB、新增.pack节植入Stub并劫持EntryPoint;附脱壳器逆向还原OEP、剥节。仅演示未处理导入表/重定位,适合入门理解PE结构与壳流程,项目已开源。 综合评分: 78 文章分类: 二进制安全,安全开发,红队,免杀,逆向分析
Windows安全攻防-手写一个PE壳
原创
R0x7e R0x7e
剑外思归客
2026年1月21日 21:25 江苏
本文仅用于信息安全学习与授权环境下的研究与测试。严禁将文中技术用于未授权加壳/脱壳、绕过安全检测、恶意代码制作或传播等任何违法用途。因使用本文内容导致的系统异常、数据损坏或法律责任均由读者自行承担,作者不承担任何责任。
壳
在Windows平台的软件安全与逆向工程领域,壳(Packers)是一个绕不开的核心概念。尤其对于恶意软件分析、软件保护和红蓝对抗而言,理解PE壳的工作机制至关重要。本文将系统性地讲解PE壳的本质、工作原理,并编写一个简单的加壳器,以及手动对加壳的程序进行脱壳,承接上一篇文章《PE文件基础》,从而深入了解PE文件结构,以及学习壳的基础知识。
真正实现一个自解密的壳需要处理:导入表、重定位、内存保护属性修改等,本文仅进行简单的演示,理解其中的原理。
壳的本质
所谓的壳,就是在原程序上包括一层代码,并修改二进制文件的入口点,将原本的程序代码进行加密,防止逆向或隐藏相关数据段的内容,而这段所谓的代码,则是负责在运行当前exe程序时,对相应的加密数据进行解密,从而还原exe的内容,定位到程序原本的入口点,并执行,在此过程中略微复杂,这里仅进行粗略概述。
常见的壳
UPX:纯压缩壳 ASPack / PECompact / FSG:压缩 + 简单加密 Themida / WinLicense:反调试、反虚拟机,强混淆,动态 API 解析 VMProtect:虚拟化保护,将指令翻译成自定义字节码,由 VM 引擎解释执行
编写一个加壳程序
编写一个加壳程序前,首先需要明确,加壳程序对PE文件进行了什么操作,主要进行的操作如下:
- 编写一个壳代码(Stub),负责在运行时解密和恢复原始程序。
- 加密/压缩原始 EXE 的代码段(.text)和数据段
- 在 PE 文件的末尾添加一个新的段(Section),把 Stub 代码放进去
- 修改 PE 头中的
AddressOfEntryPoint(OEP)指向壳代码运行时,壳代码先解密/解压原始程序,再跳转到原始入口点(OEP)
这里再说一下OEP是什么?虽然上一篇文章中进行了介绍,但可能有朋友没看过,OEP就是PE文件结构中NT头中的
AddressOfEntryPoint字段,表示程序执行开始的起始位置,程序开始运行的第一条指令的地址,这个字段的值是相对于ImageBase的偏移量
编写 Stub (引导代码)
Stub 是加壳中最核心的部分。它必须足够小,且不依赖任何复杂的环境(因为它运行时,操作系统还没给程序完全初始化好)。通常用 汇编语言 (Assembly) 或 C语言 (配合特殊编译选项) 来写。 Stub 的逻辑流程如下:
- 保存现场: 保存所有寄存器的状态 (
PUSHAD),防止破坏堆栈。 - 定位数据: 找到被加密的代码段在内存中的起始地址和长度。
- 解密循环: 使用解密算法(如 XOR 异或)还原代码。
- 恢复现场: 恢复寄存器状态 (
POPAD)。 - 跳转: 跳回原始入口点 (OEP)。 这里给出汇编版本的壳代码,在实际项目编写中可以将其转换为16进制的
shellcode版本
; 保存寄存器上下文
push rbx ; 压栈保存通用寄存器
push rcx
push rdx
push rsi
push rdi
push r8
push r9
pushfq ; 压栈保存 RFLAGS 标志寄存器(保护状态位不被后续指令破坏)
; 定位数据区
; 使用 RIP 相对寻址找到紧随其后的配置数据块
; 这里的 0x00000000 是一个占位符,实际运行时会被修正为指向数据结构的偏移
lea rbx, [rip + 0] ; RBX = 数据区的起始绝对地址
; 加载解密参数
; RBX 指向的内存结构为:
; [RBX] : TargetRelativeOffset (8 bytes) - 加密数据相对于 RBX 的偏移
; [RBX + 8] : Size (8 bytes) - 加密数据的字节长度
; [RBX + 16]: Key (8 bytes) - 异或密钥
mov rcx, [rbx] ; RCX = 加密数据相对于 RBX 的偏移
mov r8, [rbx + 8] ; R8 = 待处理的数据长度 (Size)
mov r9, [rbx + 16] ; R9 = 密钥 (Key)
; 计算加密数据的绝对地址:TargetAddr = DataBaseAddr + RelOffset
add rcx, rbx ; 计算完成之后,RCX 指向内存中实际要解密的首地址
; 解密循环 xor
decode_loop:
test r8, r8 ; 检查剩余长度 R8 是否为 0
jz decode_done ; 如果为 0,跳转到结束处
xor byte ptr [rcx], r9b ; 将 RCX 指向的字节与 R9 的低 8 位进行异或解密
inc rcx ; 指针后移 1 字节
dec r8 ; 长度计数器减 1
jmp decode_loop ; 跳回循环开始继续处理
decode_done:
;恢复寄存器上下文
popfq ; 恢复标志寄存器
pop r9
pop r8
pop rdi
pop rsi
pop rdx
pop rcx
pop rbx
; 转移控制权
; 这是一个相对近跳转 (JMP rel32),用于返回原始程序的入口点 (OEP)
; 0x00000000 在实际部署时会被注入器计算并填充为:(OEP地址 - 当前RIP地址)
jmp 0x00000000 ; 跳转至 OEP
编写加壳器
加壳器的主要功能用来处理原始文件、加密代码、修改 PE 头的工具。 读取目标exe文件,将其映射到内存,并解析 DOS Header, NT Headers 和 Section Headers。
bool PEHelper::Load(const std::string& filepath) {
std::ifstream file(filepath, std::ios::binary | std::ios::ate);
if (!file.is_open()) {
std::cerr << "无法打开文件: " << filepath << std::endl;
return false;
}
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
if (size == 0) {
std::cerr << "文件为空" << std::endl;
return false;
}
buffer.resize((size_t)size);
if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
std::cerr << "读取文件失败" << std::endl;
return false;
}
ParseHeaders();
if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE) {
std::cerr << "无效的 DOS 签名" << std::endl;
return false;
}
if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE) {
std::cerr << "无效的 PE 签名" << std::endl;
return false;
}
if (pNtHeaders->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
std::cerr << "仅支持 x64 PE 文件 (PE32+)" << std::endl;
return false;
}
isLoaded = true;
return true;
}
遍历 Section Headers,找到包含代码的段(通常叫 .text)
// 1. 寻找 .text 段
PIMAGE_SECTION_HEADER pTargetSection = nullptr;
PIMAGE_NT_HEADERS64 pNtHeaders = pe.GetNtHeaders();
PIMAGE_SECTION_HEADER pSections = IMAGE_FIRST_SECTION(pNtHeaders);
for (int i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++) {
// 通过判断节属性中是否包含可执行标志来定位代码段
if (pSections[i].Characteristics & IMAGE_SCN_MEM_EXECUTE) {
pTargetSection = &pSections[i];
break;
}
}
对代码段进行加密
//定义异或加密逻辑
void Packer::XorData(unsigned char* data, size_t size, unsigned char key) {
for (size_t i = 0; i < size; i++) {
data[i] ^= key;
}
}
// 初始化加密参数
unsigned char key = 0xAA; // xor密钥
DWORD targetOffset = pTargetSection->PointerToRawData; // 获取text节在文件中的物理偏移
// 为了避免破坏混在代码段中的数据(如 IAT, .xdata 等),
// 只从 EntryPoint 开始加密一小段代码。
DWORD epRVA = pNtHeaders->OptionalHeader.AddressOfEntryPoint; // 获取原始入口点 (OEP) 的相对虚拟地址 (RVA)
DWORD encryptStartRVA = pTargetSection->VirtualAddress; // 默认从text节的开头开始加密
// 检查 OEP 是否落在目标节的范围内 (VirtualAddress <= epRVA < VirtualAddress + Size)
if (epRVA >= pTargetSection->VirtualAddress &&
epRVA < pTargetSection->VirtualAddress + pTargetSection->Misc.VirtualSize) {
// 如果 OEP 在节内,则从 OEP 处开始加密,确保入口点代码被保护
encryptStartRVA = epRVA;
// 计算 OEP 距离节开头的相对偏移
DWORD offsetInSec = epRVA - pTargetSection->VirtualAddress;
// 修正文件中的实际物理偏移
targetOffset += offsetInSec;
}
// 固定加密大小为 4096 字节(或者剩余大小)
DWORD maxEncryptSize = 4096;
DWORD remainingSize = (pTargetSection->VirtualAddress + pTargetSection->Misc.VirtualSize) - encryptStartRVA; // 计算从加密起点到节末尾剩下的空间大小
DWORD encryptSize = (remainingSize < maxEncryptSize) ? remainingSize : maxEncryptSize;
//执行加密操作
XorData(pe.GetBuffer() + targetOffset, encryptSize, key); // pe.GetBuffer() 返回整个文件的内存映射,+ targetOffset 定位到要加密的代码块
// 添加可写属性 (IMAGE_SCN_MEM_WRITE)
pTargetSection->Characteristics |= IMAGE_SCN_MEM_WRITE;
此时需要修改这个段的属性(Characteristics),加上
IMAGE_SCN_MEM_WRITE(可写)标志。因为默认的代码段是只读的,如果运行时 Stub 试图解密(写入)它,程序会直接崩溃。
植入Stub
这里有两种常见做法:
- 缝隙植入: 找 PE 文件中的“空隙”(Code Caves),通常是段与段之间的
00填充区。如果 Stub 很小,可以塞进去。 - 新增节 (New Section): 在 PE 文件末尾添加一个新的 Section Header 和对应的 Section Data,把 Stub 的二进制机器码写进去。 在这里我们使用第二种方式,将上述的壳代码设置为一个节,并将其命名为
.pack节
// 1. 计算所需空间并创建新的节 (Section)
DWORD stubCodeSize = sizeof(stub_template); // 汇编解密引擎的大小
DWORD stubTotalSize = stubCodeSize + sizeof(StubParams); // 总大小 = 引擎代码 + 配置参数
PIMAGE_SECTION_HEADER pPackSection = pe.AddSection(".pack", stubTotalSize,
IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_CNT_CODE); // 给予读取、执行、代码属性
if (!pPackSection) {
std::cerr << "添加节失败" << std::endl;
return false;
}
// 2. 准备内存缓冲区和文件偏移
BYTE* buffer = pe.GetBuffer(); // 获取整个 PE 文件的内存映射缓冲区
DWORD packOffset = pPackSection->PointerToRawData; // 获取新节在文件中的起始偏移
// 3. 将 Stub 模板拷贝到新节中
memcpy(buffer + packOffset, stub_template, stubCodeSize);
// 4. 修正指令偏移 (自定位逻辑)
// 计算 lea 指令到配置数据区的相对偏移。
// 这里的 17 和 13 是基于汇编指令硬编码的偏移,用于让汇编代码能找到其下方的 StubParams
int32_t leaOffset = stubCodeSize - 17;
memcpy(buffer + packOffset + 13, &leaOffset, 4);
// 5. 计算并修正 JMP 原始入口点 (OEP) 的偏移
DWORD originalOEP = pNtHeaders->OptionalHeader.AddressOfEntryPoint; // 获取程序原来的入口
DWORD stubStart = pPackSection->VirtualAddress; // Stub 在内存中的起始 RVA
DWORD nextIP = stubStart + stubCodeSize; // JMP 指令下一条指令的地址
int32_t jmpOffset = originalOEP - nextIP; // 计算相对偏移:目标地址 - 当前下一条指令地址
memcpy(buffer + packOffset + stubCodeSize - 4, &jmpOffset, 4); // 将偏移写入 Stub 末尾的 JMP 指令中
// 6. 填充解密所需的配置参数 (StubParams)
StubParams params;
long long dataRVA = stubStart + stubCodeSize; // 参数区在内存中的 RVA
long long targetRVA = encryptStartRVA; // 被加密数据的起始 RVA
params.targetRelOffset = targetRVA - dataRVA; // 计算加密数据相对于参数区的偏移
params.size = encryptSize; // 加密数据的大小
params.key = key; // 解密密钥
// 将填充好的结构体拷贝到 Stub 代码之后
memcpy(buffer + packOffset + stubCodeSize, ¶ms, sizeof(StubParams));
// 7. 劫持程序入口点 (OEP)
pNtHeaders = pe.GetNtHeaders();
pNtHeaders->OptionalHeader.AddressOfEntryPoint = pPackSection->VirtualAddress; // 将入口指向我们的 Stub
std::cout << "加壳完成。新入口点: 0x" << std::hex << pNtHeaders->OptionalHeader.AddressOfEntryPoint << std::endl;
//保存加壳后的PE文件
pe.Save(outputPath);
在上述代码中最关键的两个步骤:
-
记录旧的
AddressOfEntryPoint(这是 OEP),把它直接硬编码进 Stub,确保壳运行后能正确找到程序运行的入口点 -
修改
pNtHeaders->OptionalHeader.AddressOfEntryPoint,将其指向新植入的 Stub 的起始地址(RVA)。 查看加壳后的PE文件,可以看到其新增了一个.pack节:同时可以看到
OPE的RVA为9000h,位于pack节内
脱壳代码
bool Packer::Unpack(const std::string& inputPath, const std::string& outputPath) {
PEHelper pe;
if (!pe.Load(inputPath)) return false; // 加载目标 PE 文件到内存缓冲区
PIMAGE_NT_HEADERS64 pNtHeaders = pe.GetNtHeaders();
PIMAGE_SECTION_HEADER pLastSection = pe.GetLastSection(); // 获取最后一个节区
// 1. 验证壳标志:检查最后一个节的名字是否为 ".pack"
if (!pLastSection || strncmp((char*)pLastSection->Name, ".pack", 5) != 0) {
std::cerr << "未找到 .pack 节,该文件可能未加壳或格式不支持" << std::endl;
return false;
}
std::cout << "发现 .pack 节,正在进行静态脱壳..." << std::endl;
// 2. 提取解密参数
// packOffset 是 .pack 节在文件中的起始位置
DWORD packOffset = pLastSection->PointerToRawData;
StubParams params;
// 参数区紧跟在 stub_template 代码之后,将其拷贝出来
memcpy(¶ms, pe.GetBuffer() + packOffset + sizeof(stub_template), sizeof(StubParams));
// 3. 定位加密数据区域
// 计算参数区在内存中的虚拟地址 (RVA)
long long dataRVA = pLastSection->VirtualAddress + sizeof(stub_template);
// 根据参数中的相对偏移量计算出加密数据的实际 RVA
long long targetRVA = dataRVA + params.targetRelOffset;
// 将内存地址 (RVA) 转换为文件偏移 (Offset),以便直接修改缓冲区
DWORD targetOffset = pe.RvaToOffset((DWORD)targetRVA);
if (targetOffset == 0) {
std::cerr << "无效的目标 RVA,无法定位加密数据" << std::endl;
return false;
}
// 4. 执行静态解密
std::cout << "解密地址: 0x" << std::hex << targetRVA << " 大小: " << params.size << std::endl;
// 再次调用 XOR 逻辑(异或两次即还原)
XorData(pe.GetBuffer() + targetOffset, (size_t)params.size, (unsigned char)params.key);
// 5. 还原原始入口点 (OEP)
int32_t jmpOffset;
// 从 Stub 的最后 4 个字节中读取之前计算好的 JMP 指令偏移量
memcpy(&jmpOffset, pe.GetBuffer() + packOffset + sizeof(stub_template) - 4, 4);
// OEP = JMP指令下一条指令的地址 + 相对偏移
DWORD stubEndRVA = pLastSection->VirtualAddress + sizeof(stub_template);
DWORD originalOEP = stubEndRVA + jmpOffset;
// 将入口点修改回原始的 OEP
pNtHeaders->OptionalHeader.AddressOfEntryPoint = originalOEP;
// 6. 抹除加壳痕迹(剥壳)
pNtHeaders->FileHeader.NumberOfSections--; // 节数量减 1
// 重新计算镜像大小,将其对齐到最后一个节之前的地址
pNtHeaders->OptionalHeader.SizeOfImage = PEHelper::Align(pLastSection->VirtualAddress, pNtHeaders->OptionalHeader.SectionAlignment);
// 清空节表项,彻底从文件结构中移除 .pack 节
memset(pLastSection, 0, sizeof(IMAGE_SECTION_HEADER));
std::cout << "脱壳完成。恢复原始入口点 (OEP): 0x" << std::hex << originalOEP << std::endl;
// 7. 保存脱壳后的 PE 文件
return pe.Save(outputPath);
}
运行流程对比
加壳后的 PE 文件运行流程:
-
加载器介入:操作系统像往常一样加载 PE 文件。但由于加壳工具修改了 PE 头,此时内存中的代码段(
.text)可能是加密的、乱码的。 -
控制权被劫持:加载器跳转到
AddressOfEntryPoint。注意:此时的入口点不再是 OEP,而是指向了 Stub 的起始地址。 -
Stub 执行(解密阶段):
-
环境保存:Stub 首先使用
pushad/pushfq等指令保存现场寄存器状态。 -
自定位 (Delta Offset):Stub 确定自己在内存中的实际位置。
-
动态获取 API:Stub 可能会通过遍历 PEB 或导出表,动态获取
VirtualProtect、LoadLibrary等关键函数的地址。 -
解密还原:Stub 按照预设的算法(如 XOR 或 AES)将加密的代码段解密,并还原回内存中。
-
修复与扫尾:
-
Stub 可能会手动修复 IAT 或重定位信息(如果壳比较复杂)。
-
恢复现场:使用
popad/popfq还原寄存器。 -
跳转回 OEP:Stub 执行完毕后,通过一条
jmp或ret指令,精准地跳转回原始的 OEP。 -
程序真正运行:此时内存中的代码已是明文,程序开始像没加壳一样正常工作。
未加壳的PE文件运行流程:
- 映射文件:操作系统加载器(Loader)读取 PE 文件的
Section Table(节表),将各个节(如.text代码节、.data数据节)映射到内存中的虚拟地址。 - 修复导入表 (IAT):加载器读取
Import Table,加载程序依赖的 DLL 文件(如kernel32.dll),并将这些 DLL 中函数的实际内存地址填入程序的 IAT 表中。 - 重定位 (Relocation):如果程序没有加载到它预期的基地址(ImageBase),加载器会根据重定位表修正代码中的绝对地址。
- 跳转至 OEP:一切准备就绪后,控制权交给
OptionalHeader.AddressOfEntryPoint指向的地址,即 OEP (Original Entry Point)。 - 正常执行:程序从主函数开始运行。
当前文章中示例的不足点
本示例仅加密了部分代码,且未处理导入表。需要注意:Windows Loader 会在进入入口点(包括 Stub)之前解析导入表,因此仅加密代码段并不会阻止 DLL 导入绑定;但如果壳进一步加密或破坏
.idata/Import Directory/INT/IAT 等导入相关结构,程序可能在进入 Stub 前就无法完成装载。与此同时,如果 Stub 自身需要调用系统 API(如VirtualProtect),它不能盲目依赖宿主程序的 IAT(IAT 可能被加密、篡改或尚未修复)。因此高级壳常通过 PEB/Ldr 定位kernel32.dll并解析导出表动态获取 API 地址,或在 Stub 中实现微型 Loader:在解密原始数据后手动修复导入表/IAT,再跳转到原始 OEP 执行。
当然本文仅为通过一个简单的示例来理解加壳和方式,过于复杂的操作反而使理解起来更加困难。
本文中演示的项目地址位于:https://github.com/R0x7e/SimplePacker
By: 《剑外思归客》公众号
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:剑外思归客 R0x7e R0x7e《Windows安全攻防-手写一个PE壳》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论