壳与脱壳基础
壳与脱壳基础
本文适合
CTF 逆向工程 入门学习者。学完你能:判断程序是否加壳,区分压缩壳和保护壳,并完成 UPX 或简单自解密壳的最小脱壳验证
壳是包在程序外层的一段保护或压缩逻辑。加壳后,真正代码可能不会直接出现在静态反编译结果里,而是在运行时解压、解密或重建。
一句话判断
如果程序静态字符串很少、入口点像解压循环、导入表异常少、段名或熵异常,而运行后又能正常输出业务内容,就要怀疑加壳。
脱壳的核心目标是拿到运行时恢复后的真实代码,而不是盲目找“万能脱壳器”。
题目中常见信号
strings几乎搜不到成功失败提示,但程序运行时有明显输出。file/DIE/ExeInfo PE 提示 UPX、packer、protector 或 unknown packed。- ELF/PE 段名异常,如
UPX0、UPX1、.aspack、.vmp0、.themida。 - 导入表只剩
LoadLibrary、GetProcAddress、VirtualProtect、mprotect等加载相关函数。 - 入口附近是大循环、内存写入、解密、解压或跳转到远处代码。
- 运行到某个时刻后,内存中出现静态文件里没有的字符串和代码。
核心概念
壳通常把“真实程序”包成两层:
- 壳代码:负责解密、解压、修复导入表、反调试、跳转。
- 原程序代码:运行时恢复后才接近真实业务逻辑。
脱壳要找到 OEP,也就是壳完成恢复后跳到原程序的位置。拿到 OEP 后,再 dump 内存、修复导入表,才能让 IDA/Ghidra 更像分析正常程序一样分析它。
CTF 入门优先处理简单压缩壳和自写壳。VMProtect/Themida 这类虚拟化保护通常要换成行为分析、动态插桩或还原 VM 指令,不适合用同一套简单脱壳流程硬拆。
最小分析流程
- 先用
file、DIE、strings、readelf -S/objdump -h判断是否疑似加壳。 - 如果是 UPX,先尝试
upx -d,再重新 strings 和反编译验证。 - 如果不是标准 UPX,动态运行到解压/解密结束,观察关键字符串是否出现。
- 找壳跳转到 OEP 的位置:大循环结束、恢复寄存器后、
VirtualProtect/mprotect后、跳到低地址代码段。 - 在 OEP 下断点,dump 进程内存,必要时修复 IAT/导入表。
- 用 dump 后文件重新打开分析,确认成功失败字符串、导入函数和业务逻辑是否恢复。
最小验证示例
标准 UPX:
file packed
strings -a packed | head
upx -d packed -o unpacked
strings -a unpacked | grep -Ei "correct|wrong|flag|password"如果脱壳后字符串数量明显增加,并且 IDA/Ghidra 能看到正常 main 和判断逻辑,就完成最小验证。
简单自写壳可以动态验证:
gdb ./packed
starti单步或下断在 mprotect/VirtualProtect 后,运行到疑似 OEP,再检查内存:
x/20i $rip
find &__executable_start, +0x100000, "Correct"如果运行后内存中出现静态文件没有的 Correct,说明真实代码或数据已经被恢复。
常见利用 / 解题路线
路线总览:
- 标准 UPX 路线:
upx -d脱壳,重新做 strings/xref/反编译。 - UPX 变种路线:恢复段名或手动找 OEP,dump 后修复。
- 自解密路线:断在解密循环结束或内存权限修改后,dump 恢复后的代码段。
- API 动态解析路线:跟
LoadLibrary/GetProcAddress,记录真实 API,再修复导入表。 - 运行时字符串路线:不急着 dump 全文件,先在运行时 dump 解密后的字符串或常量。
- 虚拟化保护路线:若是真 VMProtect/Themida,优先行为分析和关键函数 hook,不把简单脱壳当唯一道路。
壳解决什么问题
正常程序的代码和数据直接存在文件中。
加壳程序把原始代码压缩、加密或虚拟化,再放入一个加载器。
程序运行时,加载器先恢复真实代码,再跳过去执行。
对逆向分析者来说,看到的入口可能只是壳的入口,不是真正业务逻辑。
压缩壳和保护壳
压缩壳主要为了减小体积,例如常见 UPX。
保护壳主要为了阻碍分析,可能包含:
- 反调试。
- 代码加密。
- API 动态解析。
- 控制流混淆。
- 虚拟化。
- 自校验。
CTF 入门常见的是简单压缩壳或自写壳。
如何识别壳
常见迹象:
- 字符串很少,正常程序通常有大量字符串常量。
- 导入表异常少,可能只有
LoadLibrary和GetProcAddress。 - 入口点附近不像正常编译器生成代码,通常是解压/解密循环。
- 段名异常,如 UPX 使用
UPX0、UPX1,其他壳可能有自定义段名。 - 熵很高,加密或压缩后的数据熵接近 8.0(最大值)。
- 运行时才出现关键字符串,静态搜索找不到。
- 反编译结果大量无意义或无法反编译。
但这些只是信号,不能单独证明。
# 使用 Detect It Easy (DIE) 检测壳类型
diec challenge.exe
# 使用 ExeInfo PE 查看壳信息
# 图形界面工具,显示入口点、段信息、可能的壳类型
# 使用 strings 检查字符串数量
strings challenge.exe | wc -l
# 正常程序通常有几百到几千个字符串
# 加壳程序可能只有几十个
# 使用 entropy 工具检查文件熵
binwalk -E challenge.exe
# 熵接近 8.0 的区域可能是加密/压缩数据
# 查看段信息
readelf -S challenge # Linux ELF
objdump -h challenge # 通用工具
# UPX 典型段名: UPX0, UPX1
# ASPack: .aspack
# Themida: .themida脱壳的核心
脱壳不是魔法,它的目标是拿到程序运行时恢复后的真实代码。
常见步骤:
- 找到壳入口:文件入口点通常是壳代码的起始位置。
- 让程序运行到解密或解压完成:在调试器中单步跟踪,找到循环解压的结束点。
- 找到跳转到原始入口的位置:壳完成工作后会执行
jmp或ret跳到 OEP。 - dump 内存:将恢复后的代码段和数据段导出到文件。
- 修复导入表:原始的 IAT(导入地址表)已被壳破坏,需要重建。
- 修复重定位信息:如果程序加载基址与默认不同,需要修复地址引用。
入门时可以先理解”运行后内存中可能比文件中更接近真实程序”。
# UPX 脱壳(最简单的壳)
upx -d packed_challenge # 直接脱壳
# 使用 GDB 手动脱壳(以 UPX 为例)
gdb ./packed_challenge
# 1. 运行程序让它完成解压
run
# 2. 找到 OEP(通常是跳转到原始代码的 jmp 指令)
# UPX 的特征是 "popal; jmp OEP"
# 3. 在 OEP 下断点,重新运行
break *0x401000 # 假设 OEP 在 0x401000
run
# 4. dump 内存(需要 GDB 插件如 dump)
# 使用 peda 的 dump 命令
dump memory dump.bin 0x400000 0x4a0000
# 使用 x64dbg 脱壳(Windows)
# 1. F9 运行到程序入口
# 2. F8 单步跟踪,找到大循环
# 3. 在循环结束后的 jmp 处 F2 下断点
# 4. 运行到断点,使用 Scylla 插件 dump 进程并修复 IAT
# 修复导入表工具
# ImpREC (Import REConstructor) - Windows
# Scylla - x64dbg 内置
# rebuild_iat.py - 自定义 Python 脚本OEP 是什么
OEP 是 Original Entry Point,原始入口点。
加壳程序的文件入口通常是壳入口。
壳执行完后会跳到 OEP。
找到 OEP 是脱壳的重要目标,因为那里才是原程序开始执行的位置。
寻找 OEP 的常用技巧:
- 单步跟踪法:从入口开始单步,遇到大的循环就跳过,最终找到跳转到代码段低地址的指令。
- ESP 定律:壳在开始时会保存寄存器(如
pushad),在解压完成后恢复(如popal)。在popal后的跳转通常是 OEP。 - 内存断点:对代码段设内存访问断点,当壳写入恢复后的代码时中断。
- 二次断点法:在
VirtualProtect或mprotect返回后下断点,壳修改内存保护属性后通常很快跳到 OEP。
# GDB 中使用内存断点寻找 OEP
gdb ./packed_challenge
# 对 .text 段设写断点(壳需要写入解压后的代码)
awatch *0x401000
run
# 中断后单步跟踪,找到跳转到低地址的指令
# IDA 中寻找 OEP
# 1. 打开程序,IDA 自动识别壳类型
# 2. 使用 Debugger -> Start process
# 3. F8 单步跟踪大循环
# 4. 在疑似 OEP 处 F2 下断点
# 5. Edit -> Segments -> Rebase program 修复地址更多壳类型
常见壳分类
压缩壳(Packer):
UPX 最常见的开源压缩壳
ASPack Windows 可执行文件压缩
PECompact 商业压缩壳
MEW 轻量级压缩壳
FSG 快速小型压缩壳
保护壳(Protector):
Themida 高强度保护,包含虚拟化
VMProtect 代码虚拟化保护
Enigma 商业保护壳
Obsidium 中等强度保护
Armadillo 常见商业壳
.NET 壳:
Dotfuscator .NET 混淆器
ConfuserEx 开源 .NET 保护
.NET Reactor 商业 .NET 保护
Java/Android 壳:
ProGuard Android 默认混淆
DexGuard 商业 Android 保护
DexProtector 商业 Android 保护
Bangcle 国内 Android 加固
Jiagu 国内 Android 加固各壳的识别特征
# UPX 特征
# 段名: UPX0, UPX1
# 字符串: "UPX!"
readelf -S challenge | grep UPX
# ASPack 特征
# 段名: .aspack
# 导入: LoadLibrary, GetProcAddress
# Themida 特征
# 段名: .themida
# 高熵值,大量反调试
# VMProtect 特征
# 段名: .vmp0, .vmp1
# 大量虚拟化指令
# Detect It Easy (DIE) 综合检测
diec challenge.exe
# 输出示例:
# PE32
# Compiler: Microsoft Visual C++
# Linker: Microsoft Linker
# Protector: UPX 3.96Android 壳识别
# 查看 APK 是否加固
# 方法1: 检查 classes.dex 数量
unzip -l app.apk | grep classes
# 正常: classes.dex
# 加固: classes.dex, classes2.dex, ...
# 方法2: 检查 Application 类
# 加固壳通常会替换 Application 类
# 查看 AndroidManifest.xml 中的 android:name
# 方法3: 检查 lib 目录
# 加固壳通常包含 native 加载库
unzip -l app.apk | grep "\.so$"
# 常见加固库:
# libjiagu.so (360加固)
# libDexHelper.so (梆梆加固)
# libexec.so (腾讯加固)
# libsecexe.so (阿里加固)脱壳脚本
UPX 自动脱壳
# 标准 UPX 脱壳
upx -d packed_challenge
# UPX 被修改(段名被改)时的脱壳
# 方法1: 恢复段名后脱壳
# 将修改后的段名改回 UPX0, UPX1
# 方法2: 手动脱壳
# 用 GDB 在 OEP 下断点
gdb ./packed_challenge
# 运行到解压完成
run
# 找到 OEP(UPX 特征: popal 后的跳转)
# dump 内存x64dbg 自动脱壳脚本
# x64dbg Python 脚本脱壳框架
# 适用于简单压缩壳
def find_oep_by_popal(process):
"""通过 popal 指令找 OEP"""
import re
# popal 操作码: 0x61
# 在代码段搜索 0x61 后跟 jmp 的模式
code_start = 0x401000 # 需要根据实际程序调整
code_size = 0x10000
code = process.read(code_start, code_size)
# 搜索 popal (0x61) 后跟 near jmp (0xe9)
pattern = b'\x61\xe9'
pos = code.find(pattern)
if pos != -1:
jmp_offset = int.from_bytes(code[pos+2:pos+6], 'little')
oep = code_start + pos + 6 + jmp_offset
return oep
return None
def dump_process(process, base_address, size, output_file):
"""dump 进程内存"""
data = process.read(base_address, size)
with open(output_file, 'wb') as f:
f.write(data)
print(f"[+] Dumped {size} bytes from {base_address:#x} to {output_file}")
def fix_iat(dump_file, original_file, iat_entries):
"""修复导入表"""
# iat_entries: list of (rva, dll_name, function_name)
# 使用 Scylla 或 ImpREC 的命令行版本修复
# 这里展示手动修复思路
with open(dump_file, 'rb') as f:
data = bytearray(f.read())
for rva, dll, func in iat_entries:
# 在导入表中找到对应条目,写入正确的函数地址
print(f"[+] Fixing IAT: {dll}!{func} at RVA {rva:#x}")
with open(dump_file, 'wb') as f:
f.write(data)Frida 动态脱壳
// Frida 动态 dump 脚本
// 适用于 Android 壳
// 等待壳解密完成
Java.perform(function() {
// hook Application.onCreate
var Application = Java.use("android.app.Application");
Application.onCreate.implementation = function() {
console.log("Application.onCreate called");
this.onCreate();
// dump 解密后的 dex
var DexFile = Java.use("dalvik.system.DexFile");
// 获取已加载的 dex
var classLoader = Java.use("java.lang.ClassLoader");
// 遍历并 dump
};
});
// dump 内存中的 dex
function dump_dex() {
var libart = Process.findModuleByName("libart.so");
// 找到 DEX 文件在内存中的位置
// 搜索 dex magic: "dex\n035"
var ranges = Process.enumerateRanges("r--");
ranges.forEach(function(range) {
try {
var header = Memory.readUtf8String(range.base, 4);
if (header === "dex\n") {
console.log("Found DEX at: " + range.base);
var size = Memory.readInt(range.base.add(32));
var dexData = Memory.readByteArray(range.base, size);
// 保存到文件
}
} catch(e) {}
});
}内存 dump 修复
dump 后常见问题
1. 导入表损坏:
- 症状:IDA 无法识别 API 调用
- 修复:使用 ImpREC 或 Scylla 重建 IAT
2. 重定位信息丢失:
- 症状:地址引用错误
- 修复:手动修复或使用重定位修复工具
3. 段信息不完整:
- 症状:dump 文件缺少某些段
- 修复:重新 dump,确保包含所有段
4. 入口点错误:
- 症状:IDA 显示的入口不是 OEP
- 修复:手动设置入口点为 OEP使用 Scylla 修复 IAT
Scylla 使用步骤:
1. 在 x64dbg 中运行到 OEP
2. 打开 Scylla 插件
3. 点击 "IAT Autosearch" 自动搜索导入表
4. 点击 "Get Imports" 获取导入函数列表
5. 检查导入列表是否完整
6. 点击 "Dump" dump 进程
7. 点击 "Fix Dump" 修复 dump 文件
8. 在 IDA 中打开修复后的文件验证使用 ImpREC 修复 IAT
ImpREC 使用步骤:
1. 在调试器中运行到 OEP
2. 打开 ImpREC,选择目标进程
3. 输入 OEP 地址
4. 点击 "IAT AutoSearch"
5. 点击 "Get Imports"
6. 检查并修复无效导入
7. 点击 "Fix Dump"UPX 变种
UPX 修改版识别
常见 UPX 变种:
1. 段名修改:把 UPX0/UPX1 改成其他名字
2. Magic 修改:把 "UPX!" 改成其他字符串
3. 入口混淆:修改入口点附近的代码
4. 多层 UPX:多次 UPX 压缩
识别方法:
1. 检查文件熵(UPX 压缩后熵值较高)
2. 搜索 UPX 特征字符串
3. 检查段属性(可写且可执行的段)
4. 检查导入表(通常只有 LoadLibrary/GetProcAddress)修改版 UPX 脱壳
# 方法1: 恢复 UPX 标识后用 upx -d
# 用十六进制编辑器修改段名和 magic
# 方法2: 手动脱壳
# 1. 找到解压循环结束点
# 2. 找到 popal 或类似恢复寄存器的指令
# 3. 找到跳转到 OEP 的指令
# 4. 在 OEP 下断点
# 5. dump 内存
# 方法3: 使用通用脱壳器
# QEMU 用户模式模拟脱壳
# 或使用脱壳框架如 de4dot (.NET)常见失败原因
- 看到反编译乱码就认为题目坏了:先查壳、熵、段名和运行时字符串。
- 不运行程序就判断逻辑不存在:很多真实逻辑只有运行时恢复到内存。
- 把所有混淆都叫壳:壳是外层加载/恢复逻辑,控制流平坦化、字符串加密可能只是混淆。
- dump 后不验证:重新 strings、查导入表、找成功失败分支,确认 dump 文件可分析。
- 不修复导入表:Windows PE dump 后 API 识别异常时,要用 Scylla/ImpREC 修 IAT。
- OEP 找错:如果 dump 后仍全是壳代码,说明停得太早;如果程序已跑完,说明停得太晚。
- UPX 变种直接
upx -d:失败后检查段名、magic 和 OEP,不要就此放弃。
迷你案例
题目 ELF 用 strings 只能看到 UPX!,没有 Correct/Wrong。先尝试:
upx -d chal -o chal.unpacked
strings -a chal.unpacked | grep -Ei "correct|wrong"脱壳后出现 Input key:、Correct!、Wrong!。再用 IDA 打开 chal.unpacked,对 Correct! 查看 xref,找到 check_key 函数。这个案例的闭环是:静态字符串异常 -> 识别 UPX -> 标准脱壳 -> 字符串恢复 -> 回到普通逆向流程。