使用 UPX Unpacker 分析加壳恶意软件 是做什么的?
使用时机
- 静态分析显示高熵节且导入表极少,表明二进制文件被加壳
- PEiD、Detect It Easy 或 PEStudio 识别出 UPX 或其他已知加壳器
- 导入表仅包含 LoadLibrary 和 GetProcAddress(加壳二进制文件典型的运行时导入解析)
- 您需要恢复原始二进制文件,以便在 Ghidra 或 IDA 中进行正确的反汇编和反编译
- 因恶意软件作者修改了 UPX 魔数或头部信息,自动 UPX 解压缩失败
请勿使用于处理自定义加壳器、基于 VM 的保护程序(Themida、VMProtect)或更适合通过调试进行动态脱壳的样本。
前置条件
- 已安装 UPX(可执行文件终极打包工具)(
apt install upx-ucl或从 https://upx.github.io/ 下载) - Detect It Easy (DIE) 用于加壳器识别
- Python 3.8+ 及
pefile库,用于手动头部修复 - x64dbg 或 x32dbg,用于当自动化工具失败时进行手动脱壳
- PE-bear 或 CFF Explorer,用于 PE 头部检查和修复
- 无网络连接的隔离分析虚拟机
工作流程
第 1 步:识别加壳器
确定样本是否被加壳并识别加壳器:
# 使用 Detect It Easy 检查
diec suspect.exe
# 使用 UPX 检查(测试而不解包)
upx -t suspect.exe
# 基于 Python 的熵和加壳器检测
python3 << 'PYEOF'
import pefile
import math
pe = pefile.PE("suspect.exe")
print("节分析:")
for section in pe.sections:
name = section.Name.decode().rstrip('\x00')
entropy = section.get_entropy()
raw = section.SizeOfRawData
virtual = section.Misc_VirtualSize
print(f" {name:8s} 熵: {entropy:.2f} 原始大小: {raw:>8} 虚拟大小: {virtual:>8}")
# 检查 UPX 节名称
section_names = [s.Name.decode().rstrip('\x00') for s in pe.sections]
if 'UPX0' in section_names or 'UPX1' in section_names:
print("\n[!] 检测到 UPX 节名称")
elif '.upx' in [s.lower() for s in section_names]:
print("\n[!] 检测到 UPX 变种节名称")
# 检查导入数量(加壳二进制文件通常很少)
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
total_imports = sum(len(e.imports) for e in pe.DIRECTORY_ENTRY_IMPORT)
print(f"\n导入总数: {total_imports}")
if total_imports < 10:
print("[!] 导入非常少 - 可能已加壳")
else:
print("\n[!] 无导入目录 - 高度加壳")
PYEOF
第 2 步:尝试标准 UPX 解压缩
尝试内置的 UPX 解压缩:
# 标准 UPX 解压缩
upx -d suspect.exe -o unpacked.exe
# 如果 UPX 报错 "not packed by UPX",则头部可能已被修改
# 启用详细输出以进行调试
upx -d suspect.exe -o unpacked.exe -v 2>&1
# 验证解包后的文件
file unpacked.exe
diec unpacked.exe
第 3 步:修复被修改的 UPX 头部
如果标准解压缩失败,则修复被篡改的魔数:
# 修复被修改的 UPX 头部
import struct
with open("suspect.exe", "rb") as f:
data = bytearray(f.read())
# UPX 魔数: "UPX!" (0x55505821)
# 恶意软件作者通常会修改这些内容以防止自动解包
# 搜索被修改的 UPX 签名
upx_magic = b"UPX!"
modified_patterns = [b"UPX0", b"UPX\x00", b"\x00PX!", b"UPx!"]
# 查找并恢复节名称
pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
num_sections = struct.unpack_from("<H", data, pe_offset + 6)[0]
section_table_offset = pe_offset + 0x18 + struct.unpack_from("<H", data, pe_offset + 0x14)[0]
print(f"PE 偏移: 0x{pe_offset:X}")
print(f"节数量: {num_sections}")
print(f"节表偏移: 0x{section_table_offset:X}")
for i in range(num_sections):
offset = section_table_offset + (i * 40)
name = data[offset:offset+8]
print(f"节 {i}: {name}")
# 恢复二进制文件中的 UPX 魔数
# 搜索 UPX 头部签名位置(通常在加壳数据末尾附近)
for i in range(len(data) - 4):
if data[i:i+3] == b"UPX" and data[i+3] != ord("!"):
print(f"在偏移 0x{i:X} 处发现被修改的 UPX 魔数: {data[i:i+4]}")
data[i:i+4] = b"UPX!"
print(f"已恢复为: UPX!")
# 如果被修改,也恢复节名称
for i in range(num_sections):
offset = section_table_offset + (i * 40)
name = data[offset:offset+8].rstrip(b'\x00')
if name in [b"UPX0", b"UPX1", b"UPX2"]:
continue # 已正确
# 检查常见修改
if name.startswith(b"UP") or name.startswith(b"ux"):
original = f"UPX{i}".encode().ljust(8, b'\x00')
data[offset:offset+8] = original
print(f"已将 0x{offset:X} 处的节名称恢复为 {original}")
with open("suspect_fixed.exe", "wb") as f:
f.write(data)
print("\n已写入修复后的文件。重试: upx -d suspect_fixed.exe -o unpacked.exe")
第 4 步:使用调试器手动脱壳
当自动化解包完全失败时,使用动态脱壳:
使用 x64dbg 手动 UPX 脱壳:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 在 x64dbg 中加载加壳样本
2. 运行到入口点(系统断点,然后按 F9)
3. UPX 脱壳存根模式:
a. PUSHAD(保存所有寄存器)
b. 解压缩循环(处理加壳节)
c. 解析导入(LoadLibrary/GetProcAddress 调用)
d. POPAD(恢复寄存器)
e. JMP 到 OEP(原始入口点)
4. 在 PUSHAD 后对 ESP 设置硬件断点:
- PUSHAD 后,右键单击寄存器中的 ESP -> 在转储中跟随
- 在 [ESP] 地址处设置硬件访问断点
- 运行 (F9) - 将在 JMP 到 OEP 之前的 POPAD 处中断
5. 单步向前 (F7/F8) 直到到达 JMP 到 OEP
6. 在 OEP 处:使用 Scylla 插件转储并修复导入:
- 插件 -> Scylla -> OEP = 当前 EIP
- 点击 "IAT Autosearch" -> "Get Imports"
- 点击 "Dump" 保存解包后的二进制文件
- 点击 "Fix Dump" 修复导入表
第 5 步:验证解包后的二进制文件
验证解包后的样本是否有效且完整:
# 验证解包后的 PE 文件是否有效
python3 << 'PYEOF'
import pefile
pe = pefile.PE("unpacked.exe")
# 检查节是否正常
print("解包后的节分析:")
for section in pe.sections:
name = section.Name.decode().rstrip('\x00')
entropy = section.get_entropy()
print(f" {name:8s} 熵: {entropy:.2f}")
# 验证导入是否已解析
print(f"\n导入数量:")
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
for entry in pe.DIRECTORY_ENTRY_IMPORT:
dll = entry.dll.decode()
count = len(entry.imports)
print(f" {dll}: {count} 个函数")
total = sum(len(e.imports) for e in pe.DIRECTORY_ENTRY_IMPORT)
print(f" 总数: {total} 个导入")
# 比较文件大小
import os
packed_size = os.path.getsize("suspect.exe")
unpacked_size = os.path.getsize("unpacked.exe")
print(f"\n加壳后: {packed_size:>10} 字节")
print(f"解包后: {unpacked_size:>10} 字节")
print(f"比例: {unpacked_size/packed_size:.1f}x")
PYEOF
关键概念
| 术语 | 定义 |
|---|---|
| 加壳 | 压缩或加密可执行代码以减小文件大小并阻碍静态分析;二进制文件包含一个在运行时恢复代码的脱壳存根 |
| UPX | 可执行文件终极打包工具;开源可执行文件加壳器,由于免费且有效,常被恶意软件作者滥用 |
| 原始入口点 (OEP) | 加壳前恶意软件代码的真实起始地址;脱壳存根解压代码后跳转到 OEP |
| 导入重建 | 在将脱壳后的进程从内存中转储后,使用 Scylla 或 ImpRec 等工具重建导入地址表的过程 |
| PUSHAD/POPAD | 用于保存/恢复所有通用寄存器的 x86 指令;UPX 使用此模式在脱壳期间保持寄存器状态 |
| 节熵 | PE 节数据的随机性度量;加壳节的熵 > 7.0,而普通代码节平均为 5.0-6.5 |
| 魔数 | 文件内标识其格式的签名字节;UPX 使用 “UPX!”,恶意软件作者会修改它以阻止自动解压缩 |
工具与系统
- UPX:开源可执行文件加壳器,具有内建的解压缩能力,用于正确处理加壳的文件
- Detect It Easy (DIE):加壳器、编译器和链接器检测工具,可识别 PE、ELF 和 Mach-O 文件上的保护
- x64dbg/x32dbg:开源 Windows 调试器,通过动态执行和基于断点的 OEP 查找,用于手动脱壳
- Scylla:与 x64dbg 集成的导入重建工具,用于在内存转储后重建 IAT
- PE-bear:PE 文件查看器和编辑器,用于在脱壳后检查和修复 PE 头部
常见场景
场景:解包具有被修改 UPX 头部的恶意软件
上下文:某个恶意软件样本被节名称(UPX0、UPX1)识别为 UPX 加壳,但 upx -d 失败并显示 “CantUnpackException: header corrupted”。恶意软件作者修改了 UPX 魔数以防止自动解压缩。
方法:
- 在十六进制编辑器中打开二进制文件,搜索 UPX 头部区域(通常在加壳数据的末尾)
- 识别被修改的魔数(例如,“UPX!” 更改为 “UPX\x00” 或完全归零)
- 使用 Python 修复脚本恢复 “UPX!” 魔数并更正节名称
- 在修复后的二进制文件上重试
upx -d - 如果修复失败,则回退到使用 x64dbg 进行手动脱壳(PUSHAD -> 对 ESP 设置硬件断点 -> POPAD -> JMP OEP)
- 验证解包后的二进制文件具有正确的导入和合理的熵值
- 导入到 Ghidra 或 IDA 进行完整的静态分析
陷阱:
- 假设 UPX 是唯一的加壳器;二进制文件可能是双加壳的(UPX + 自定义层)
- 修改原始加壳样本而不是对副本进行操作
- 手动内存转储后未重建导入(转储后的二进制文件如果没有 IAT 修复将会崩溃)
- 忘记检查附加在 UPX 加壳 PE 节之后的覆盖数据
输出格式
脱壳分析报告
===========================
样本: suspect.exe
SHA-256: e3b0c44298fc1c149afbf4c8996fb924...
加壳器: UPX 3.96 (已修改头部)
加壳后二进制文件
节: UPX0 (熵: 0.00) UPX1 (熵: 7.89) .rsrc (熵: 3.45)
导入: 2 (kernel32.dll: LoadLibraryA, GetProcAddress)
文件大小: 98,304 字节
脱壳方法
方法: 头部修复 + UPX -d
头部修复: 在偏移 0x1F000 处恢复 UPX! 魔数
命令: upx -d suspect_fixed.exe -o unpacked.exe
结果: 成功
解包后二进制文件
节: .text (熵: 6.21) .rdata (熵: 4.56) .data (熵: 3.12) .rsrc (熵: 3.45)
导入: 147 (kernel32, user32, advapi32, wininet, ws2_32)
文件大小: 245,760 字节 (2.5x 扩展)
OEP: 0x00401000
验证
PE 有效: 是
导入已解析: 是 (跨 8 个 DLL 的 147 个函数)
可执行: 是 (在沙箱中运行不会崩溃)
下一步
- 将 unpacked.exe 导入到 Ghidra 进行完整反汇编
- 针对解包后的二进制文件运行 YARA 规则
- 将解包后的二进制文件提交到 VirusTotal 以获得改进的检测