Windows安全攻防-手写一个PE壳

admin 2026-01-22 00:38:30 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文章手把手示范为64位PE写简易加壳器:用汇编写Stub保存现场、XOR循环解密、再跳回OEP;C++加壳器定位.text、加密前4KB、新增.pack节植入Stub并劫持EntryPoint;附脱壳器逆向还原OEP、剥节。仅演示未处理导入表/重定位,适合入门理解PE结构与壳流程,项目已开源。 综合评分: 78 文章分类: 二进制安全,安全开发,红队,免杀,逆向分析


cover_image

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文件进行了什么操作,主要进行的操作如下:

  1. 编写一个壳代码(Stub),负责在运行时解密和恢复原始程序。
  2. 加密/压缩原始 EXE 的代码段(.text)和数据段
  3. 在 PE 文件的末尾添加一个新的段(Section),把 Stub 代码放进去
  4. 修改 PE 头中的 AddressOfEntryPoint(OEP) 指向壳代码运行时,壳代码先解密/解压原始程序,再跳转到原始入口点(OEP)

这里再说一下OEP是什么?虽然上一篇文章中进行了介绍,但可能有朋友没看过,OEP就是PE文件结构中NT头中的AddressOfEntryPoint字段,表示程序执行开始的起始位置,程序开始运行的第一条指令的地址,这个字段的值是相对于ImageBase的偏移量

编写 Stub (引导代码)

Stub 是加壳中最核心的部分。它必须足够小,且不依赖任何复杂的环境(因为它运行时,操作系统还没给程序完全初始化好)。通常用 汇编语言 (Assembly) 或 C语言 (配合特殊编译选项) 来写。 Stub 的逻辑流程如下:

  1. 保存现场: 保存所有寄存器的状态 (PUSHAD),防止破坏堆栈。
  2. 定位数据: 找到被加密的代码段在内存中的起始地址和长度。
  3. 解密循环: 使用解密算法(如 XOR 异或)还原代码。
  4. 恢复现场: 恢复寄存器状态 (POPAD)。
  5. 跳转: 跳回原始入口点 (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()) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"无法打开文件: "&nbsp;<< filepath <<&nbsp;std::endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;std::streamsize size = file.tellg();
&nbsp; &nbsp; file.seekg(0,&nbsp;std::ios::beg);

&nbsp; &nbsp;&nbsp;if&nbsp;(size ==&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"文件为空"&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }

&nbsp; &nbsp; buffer.resize((size_t)size);
&nbsp; &nbsp;&nbsp;if&nbsp;(!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"读取文件失败"&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }

&nbsp; &nbsp; ParseHeaders();

&nbsp; &nbsp;&nbsp;if&nbsp;(pDosHeader->e_magic != IMAGE_DOS_SIGNATURE) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"无效的 DOS 签名"&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;if&nbsp;(pNtHeaders->Signature != IMAGE_NT_SIGNATURE) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"无效的 PE 签名"&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;if&nbsp;(pNtHeaders->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"仅支持 x64 PE 文件 (PE32+)"&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }

&nbsp; &nbsp; isLoaded =&nbsp;true;
&nbsp; &nbsp;&nbsp;return&nbsp;true;
}

遍历 Section Headers,找到包含代码的段(通常叫 .text

// 1. 寻找 .text 段
PIMAGE_SECTION_HEADER pTargetSection =&nbsp;nullptr;
PIMAGE_NT_HEADERS64 pNtHeaders = pe.GetNtHeaders();
PIMAGE_SECTION_HEADER pSections = IMAGE_FIRST_SECTION(pNtHeaders);

for&nbsp;(int&nbsp;i =&nbsp;0; i < pNtHeaders->FileHeader.NumberOfSections; i++) {
&nbsp; &nbsp;&nbsp;// 通过判断节属性中是否包含可执行标志来定位代码段
&nbsp; &nbsp;&nbsp;if&nbsp;(pSections[i].Characteristics & IMAGE_SCN_MEM_EXECUTE) {
&nbsp; &nbsp; &nbsp; &nbsp; pTargetSection = &pSections[i];
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break;
&nbsp; &nbsp; }
}

对代码段进行加密

//定义异或加密逻辑
void&nbsp;Packer::XorData(unsigned&nbsp;char* data,&nbsp;size_t&nbsp;size,&nbsp;unsigned&nbsp;char&nbsp;key)&nbsp;{
&nbsp; &nbsp;&nbsp;for&nbsp;(size_t&nbsp;i =&nbsp;0; i < size; i++) {
&nbsp; &nbsp; &nbsp; &nbsp; data[i] ^= key;
&nbsp; &nbsp; }
}
// 初始化加密参数
&nbsp;unsigned&nbsp;char&nbsp;key =&nbsp;0xAA;&nbsp;// xor密钥
&nbsp;DWORD targetOffset = pTargetSection->PointerToRawData; &nbsp;// 获取text节在文件中的物理偏移

&nbsp;// 为了避免破坏混在代码段中的数据(如 IAT, .xdata 等),
&nbsp;// 只从 EntryPoint 开始加密一小段代码。
&nbsp;DWORD epRVA = pNtHeaders->OptionalHeader.AddressOfEntryPoint;&nbsp;// 获取原始入口点 (OEP) 的相对虚拟地址 (RVA)
&nbsp;DWORD encryptStartRVA = pTargetSection->VirtualAddress;&nbsp;// 默认从text节的开头开始加密

&nbsp;// 检查 OEP 是否落在目标节的范围内 (VirtualAddress <= epRVA < VirtualAddress + Size)
&nbsp;if&nbsp;(epRVA >= pTargetSection->VirtualAddress &&
&nbsp; &nbsp; &nbsp;epRVA < pTargetSection->VirtualAddress + pTargetSection->Misc.VirtualSize) {
&nbsp; &nbsp; &nbsp;// 如果 OEP 在节内,则从 OEP 处开始加密,确保入口点代码被保护
&nbsp; &nbsp; &nbsp;encryptStartRVA = epRVA;
&nbsp; &nbsp; &nbsp;// 计算 OEP 距离节开头的相对偏移
&nbsp; &nbsp; &nbsp;DWORD offsetInSec = epRVA - pTargetSection->VirtualAddress;
&nbsp; &nbsp; &nbsp;// 修正文件中的实际物理偏移
&nbsp; &nbsp; &nbsp;targetOffset += offsetInSec;
&nbsp;}

&nbsp;// 固定加密大小为 4096 字节(或者剩余大小)
&nbsp;DWORD maxEncryptSize =&nbsp;4096;
&nbsp;DWORD remainingSize = (pTargetSection->VirtualAddress + pTargetSection->Misc.VirtualSize) - encryptStartRVA; &nbsp;// 计算从加密起点到节末尾剩下的空间大小
&nbsp;DWORD encryptSize = (remainingSize < maxEncryptSize) ? remainingSize : maxEncryptSize;

//执行加密操作
XorData(pe.GetBuffer() + targetOffset, encryptSize, key);&nbsp;// pe.GetBuffer() 返回整个文件的内存映射,+ targetOffset 定位到要加密的代码块

// 添加可写属性 (IMAGE_SCN_MEM_WRITE)
pTargetSection->Characteristics |= IMAGE_SCN_MEM_WRITE;

此时需要修改这个段的属性(Characteristics),加上 IMAGE_SCN_MEM_WRITE(可写)标志。因为默认的代码段是只读的,如果运行时 Stub 试图解密(写入)它,程序会直接崩溃。

植入Stub

这里有两种常见做法:

  1. 缝隙植入: 找 PE 文件中的“空隙”(Code Caves),通常是段与段之间的 00 填充区。如果 Stub 很小,可以塞进去。
  2. 新增节 (New Section): 在 PE 文件末尾添加一个新的 Section Header 和对应的 Section Data,把 Stub 的二进制机器码写进去。 在这里我们使用第二种方式,将上述的壳代码设置为一个节,并将其命名为.pack
// 1. 计算所需空间并创建新的节 (Section)
DWORD stubCodeSize =&nbsp;sizeof(stub_template); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 汇编解密引擎的大小
DWORD stubTotalSize = stubCodeSize +&nbsp;sizeof(StubParams);&nbsp;// 总大小 = 引擎代码 + 配置参数
PIMAGE_SECTION_HEADER pPackSection = pe.AddSection(".pack", stubTotalSize,
&nbsp; &nbsp; IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_CNT_CODE);&nbsp;// 给予读取、执行、代码属性

if&nbsp;(!pPackSection) {
&nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"添加节失败"&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp;&nbsp;return&nbsp;false;
}

// 2. 准备内存缓冲区和文件偏移
BYTE* buffer = pe.GetBuffer(); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 获取整个 PE 文件的内存映射缓冲区
DWORD packOffset = pPackSection->PointerToRawData; &nbsp; &nbsp;// 获取新节在文件中的起始偏移

// 3. 将 Stub 模板拷贝到新节中
memcpy(buffer + packOffset, stub_template, stubCodeSize);

// 4. 修正指令偏移 (自定位逻辑)
// 计算 lea 指令到配置数据区的相对偏移。
// 这里的 17 和 13 是基于汇编指令硬编码的偏移,用于让汇编代码能找到其下方的 StubParams
int32_t&nbsp;leaOffset = stubCodeSize -&nbsp;17;
memcpy(buffer + packOffset +&nbsp;13, &leaOffset,&nbsp;4);

// 5. 计算并修正 JMP 原始入口点 (OEP) 的偏移
DWORD originalOEP = pNtHeaders->OptionalHeader.AddressOfEntryPoint;&nbsp;// 获取程序原来的入口
DWORD stubStart = pPackSection->VirtualAddress; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// Stub 在内存中的起始 RVA
DWORD nextIP = stubStart + stubCodeSize; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// JMP 指令下一条指令的地址
int32_t&nbsp;jmpOffset = originalOEP - nextIP; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 计算相对偏移:目标地址 - 当前下一条指令地址
memcpy(buffer + packOffset + stubCodeSize -&nbsp;4, &jmpOffset,&nbsp;4); &nbsp; &nbsp; &nbsp;// 将偏移写入 Stub 末尾的 JMP 指令中

// 6. 填充解密所需的配置参数 (StubParams)
StubParams params;
long&nbsp;long&nbsp;dataRVA = stubStart + stubCodeSize;&nbsp;// 参数区在内存中的 RVA
long&nbsp;long&nbsp;targetRVA = encryptStartRVA; &nbsp; &nbsp; &nbsp; &nbsp;// 被加密数据的起始 RVA
params.targetRelOffset = targetRVA - dataRVA;&nbsp;// 计算加密数据相对于参数区的偏移
params.size = encryptSize; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 加密数据的大小
params.key = key; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 解密密钥

// 将填充好的结构体拷贝到 Stub 代码之后
memcpy(buffer + packOffset + stubCodeSize, &params,&nbsp;sizeof(StubParams));

// 7. 劫持程序入口点 (OEP)
pNtHeaders = pe.GetNtHeaders();
pNtHeaders->OptionalHeader.AddressOfEntryPoint = pPackSection->VirtualAddress;&nbsp;// 将入口指向我们的 Stub

std::cout&nbsp;<<&nbsp;"加壳完成。新入口点: 0x"&nbsp;<<&nbsp;std::hex << pNtHeaders->OptionalHeader.AddressOfEntryPoint <<&nbsp;std::endl;

//保存加壳后的PE文件
pe.Save(outputPath);

在上述代码中最关键的两个步骤:

  1. 记录旧的 AddressOfEntryPoint(这是 OEP),把它直接硬编码进 Stub,确保壳运行后能正确找到程序运行的入口点

  2. 修改 pNtHeaders->OptionalHeader.AddressOfEntryPoint,将其指向新植入的 Stub 的起始地址(RVA)。 查看加壳后的PE文件,可以看到其新增了一个.pack节:

    同时可以看到OPERVA9000h,位于pack节内

脱壳代码

bool&nbsp;Packer::Unpack(const&nbsp;std::string& inputPath,&nbsp;const&nbsp;std::string& outputPath)&nbsp;{

&nbsp; &nbsp; PEHelper pe;
&nbsp; &nbsp;&nbsp;if&nbsp;(!pe.Load(inputPath))&nbsp;return&nbsp;false;&nbsp;// 加载目标 PE 文件到内存缓冲区

&nbsp; &nbsp; PIMAGE_NT_HEADERS64 pNtHeaders = pe.GetNtHeaders();
&nbsp; &nbsp; PIMAGE_SECTION_HEADER pLastSection = pe.GetLastSection();&nbsp;// 获取最后一个节区

&nbsp; &nbsp;&nbsp;// 1. 验证壳标志:检查最后一个节的名字是否为 ".pack"
&nbsp; &nbsp;&nbsp;if&nbsp;(!pLastSection ||&nbsp;strncmp((char*)pLastSection->Name,&nbsp;".pack",&nbsp;5) !=&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"未找到 .pack 节,该文件可能未加壳或格式不支持"&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"发现 .pack 节,正在进行静态脱壳..."&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp;&nbsp;// 2. 提取解密参数
&nbsp; &nbsp;&nbsp;// packOffset 是 .pack 节在文件中的起始位置
&nbsp; &nbsp; DWORD packOffset = pLastSection->PointerToRawData;
&nbsp; &nbsp; StubParams params;
&nbsp; &nbsp;&nbsp;// 参数区紧跟在 stub_template 代码之后,将其拷贝出来
&nbsp; &nbsp;&nbsp;memcpy(&params, pe.GetBuffer() + packOffset +&nbsp;sizeof(stub_template),&nbsp;sizeof(StubParams));

&nbsp; &nbsp;&nbsp;// 3. 定位加密数据区域
&nbsp; &nbsp;&nbsp;// 计算参数区在内存中的虚拟地址 (RVA)
&nbsp; &nbsp;&nbsp;long&nbsp;long&nbsp;dataRVA = pLastSection->VirtualAddress +&nbsp;sizeof(stub_template);
&nbsp; &nbsp;&nbsp;// 根据参数中的相对偏移量计算出加密数据的实际 RVA
&nbsp; &nbsp;&nbsp;long&nbsp;long&nbsp;targetRVA = dataRVA + params.targetRelOffset;

&nbsp; &nbsp;&nbsp;// 将内存地址 (RVA) 转换为文件偏移 (Offset),以便直接修改缓冲区
&nbsp; &nbsp; DWORD targetOffset = pe.RvaToOffset((DWORD)targetRVA);
&nbsp; &nbsp;&nbsp;if&nbsp;(targetOffset ==&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;std::cerr&nbsp;<<&nbsp;"无效的目标 RVA,无法定位加密数据"&nbsp;<<&nbsp;std::endl;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;// 4. 执行静态解密
&nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"解密地址: 0x"&nbsp;<<&nbsp;std::hex << targetRVA <<&nbsp;" 大小: "&nbsp;<< params.size <<&nbsp;std::endl;
&nbsp; &nbsp;&nbsp;// 再次调用 XOR 逻辑(异或两次即还原)
&nbsp; &nbsp; XorData(pe.GetBuffer() + targetOffset, (size_t)params.size, (unsigned&nbsp;char)params.key);

&nbsp; &nbsp;&nbsp;// 5. 还原原始入口点 (OEP)
&nbsp; &nbsp;&nbsp;int32_t&nbsp;jmpOffset;
&nbsp; &nbsp;&nbsp;// 从 Stub 的最后 4 个字节中读取之前计算好的 JMP 指令偏移量
&nbsp; &nbsp;&nbsp;memcpy(&jmpOffset, pe.GetBuffer() + packOffset +&nbsp;sizeof(stub_template) -&nbsp;4,&nbsp;4);

&nbsp; &nbsp;&nbsp;// OEP = JMP指令下一条指令的地址 + 相对偏移
&nbsp; &nbsp; DWORD stubEndRVA = pLastSection->VirtualAddress +&nbsp;sizeof(stub_template);
&nbsp; &nbsp; DWORD originalOEP = stubEndRVA + jmpOffset;

&nbsp; &nbsp;&nbsp;// 将入口点修改回原始的 OEP
&nbsp; &nbsp; pNtHeaders->OptionalHeader.AddressOfEntryPoint = originalOEP;

&nbsp; &nbsp;&nbsp;// 6. 抹除加壳痕迹(剥壳)
&nbsp; &nbsp; pNtHeaders->FileHeader.NumberOfSections--;&nbsp;// 节数量减 1
&nbsp; &nbsp;&nbsp;// 重新计算镜像大小,将其对齐到最后一个节之前的地址
&nbsp; &nbsp; pNtHeaders->OptionalHeader.SizeOfImage = PEHelper::Align(pLastSection->VirtualAddress, pNtHeaders->OptionalHeader.SectionAlignment);
&nbsp; &nbsp;&nbsp;// 清空节表项,彻底从文件结构中移除 .pack 节
&nbsp; &nbsp;&nbsp;memset(pLastSection,&nbsp;0,&nbsp;sizeof(IMAGE_SECTION_HEADER));

&nbsp; &nbsp;&nbsp;std::cout&nbsp;<<&nbsp;"脱壳完成。恢复原始入口点 (OEP): 0x"&nbsp;<<&nbsp;std::hex << originalOEP <<&nbsp;std::endl;

&nbsp; &nbsp;&nbsp;// 7. 保存脱壳后的 PE 文件
&nbsp; &nbsp;&nbsp;return&nbsp;pe.Save(outputPath);
}

运行流程对比

加壳后的 PE 文件运行流程:

  • 加载器介入:操作系统像往常一样加载 PE 文件。但由于加壳工具修改了 PE 头,此时内存中的代码段(.text)可能是加密的、乱码的。

  • 控制权被劫持:加载器跳转到 AddressOfEntryPoint注意:此时的入口点不再是 OEP,而是指向了 Stub 的起始地址。

  • Stub 执行(解密阶段)

  • 环境保存:Stub 首先使用 pushad/pushfq 等指令保存现场寄存器状态。

  • 自定位 (Delta Offset):Stub 确定自己在内存中的实际位置。

  • 动态获取 API:Stub 可能会通过遍历 PEB 或导出表,动态获取 VirtualProtectLoadLibrary 等关键函数的地址。

  • 解密还原: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壳》

评论:0   参与:  0