$npx -y skills add killvxk/cybersecurity-skills-zh --skill analyzing-packed-malware-with-upx-unpacker识别并解包 UPX 加壳及其他加壳恶意软件样本,以暴露原始可执行代码供静态分析使用。 涵盖标准 UPX 解包及处理阻止自动解压缩的修改版 UPX 头部。 适用于恶意软件解包、UPX 解压缩、去除加壳保护或为分析准备加壳样本等请求。
| 1 | # 使用 UPX Unpacker 分析加壳恶意软件 |
| 2 | |
| 3 | ## 适用场景 |
| 4 | |
| 5 | - 静态分析显示高熵节和极少的导入函数,表明二进制文件已加壳 |
| 6 | - PEiD、Detect It Easy 或 PEStudio 识别出 UPX 或其他已知加壳工具 |
| 7 | - 导入表仅包含 LoadLibrary 和 GetProcAddress(加壳二进制文件典型的运行时导入解析方式) |
| 8 | - 需要恢复原始二进制文件以在 Ghidra 或 IDA 中进行正常反汇编和反编译 |
| 9 | - 因恶意软件作者修改了 UPX 魔数或头部导致自动 UPX 解压缩失败 |
| 10 | |
| 11 | **不适用于**处理自定义加壳、基于 VM 的保护器(Themida、VMProtect),或通过调试进行动态解包更合适的样本。 |
| 12 | |
| 13 | ## 前置条件 |
| 14 | |
| 15 | - 已安装 UPX(Ultimate Packer for eXecutables)(`apt install upx-ucl` 或从 https://upx.github.io/ 下载) |
| 16 | - Detect It Easy (DIE),用于加壳识别 |
| 17 | - Python 3.8+,安装 `pefile` 库用于手动修复头部 |
| 18 | - x64dbg 或 x32dbg,用于自动化工具失败时的手动解包 |
| 19 | - PE-bear 或 CFF Explorer,用于 PE 头部检查和修复 |
| 20 | - 隔离的分析虚拟机(无网络连接) |
| 21 | |
| 22 | ## 工作流程 |
| 23 | |
| 24 | ### 步骤 1:识别加壳工具 |
| 25 | |
| 26 | 确认样本是否加壳并识别加壳工具: |
| 27 | |
| 28 | ```bash |
| 29 | # 使用 Detect It Easy 检查 |
| 30 | diec suspect.exe |
| 31 | |
| 32 | # 使用 UPX 检查(仅测试,不解包) |
| 33 | upx -t suspect.exe |
| 34 | |
| 35 | # 基于 Python 的熵值和加壳检测 |
| 36 | python3 << 'PYEOF' |
| 37 | import pefile |
| 38 | import math |
| 39 | |
| 40 | pe = pefile.PE("suspect.exe") |
| 41 | |
| 42 | print("节分析:") |
| 43 | for section in pe.sections: |
| 44 | name = section.Name.decode().rstrip('\x00') |
| 45 | entropy = section.get_entropy() |
| 46 | raw = section.SizeOfRawData |
| 47 | virtual = section.Misc_VirtualSize |
| 48 | print(f" {name:8s} 熵值:{entropy:.2f} 原始大小:{raw:>8} 虚拟大小:{virtual:>8}") |
| 49 | |
| 50 | # 检查 UPX 节名称 |
| 51 | section_names = [s.Name.decode().rstrip('\x00') for s in pe.sections] |
| 52 | if 'UPX0' in section_names or 'UPX1' in section_names: |
| 53 | print("\n[!] 检测到 UPX 节名称") |
| 54 | elif '.upx' in [s.lower() for s in section_names]: |
| 55 | print("\n[!] 检测到 UPX 变体节名称") |
| 56 | |
| 57 | # 检查导入数量(加壳二进制文件导入极少) |
| 58 | if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'): |
| 59 | total_imports = sum(len(e.imports) for e in pe.DIRECTORY_ENTRY_IMPORT) |
| 60 | print(f"\n总导入数:{total_imports}") |
| 61 | if total_imports < 10: |
| 62 | print("[!] 导入函数极少——可能已加壳") |
| 63 | else: |
| 64 | print("\n[!] 无导入目录——高度加壳") |
| 65 | PYEOF |
| 66 | ``` |
| 67 | |
| 68 | ### 步骤 2:尝试标准 UPX 解压缩 |
| 69 | |
| 70 | 尝试使用内置 UPX 解压功能: |
| 71 | |
| 72 | ```bash |
| 73 | # 标准 UPX 解压缩 |
| 74 | upx -d suspect.exe -o unpacked.exe |
| 75 | |
| 76 | # 如果 UPX 报 "not packed by UPX" 错误,说明头部可能已被修改 |
| 77 | # 用详细输出进行调试 |
| 78 | upx -d suspect.exe -o unpacked.exe -v 2>&1 |
| 79 | |
| 80 | # 验证解包后的文件 |
| 81 | file unpacked.exe |
| 82 | diec unpacked.exe |
| 83 | ``` |
| 84 | |
| 85 | ### 步骤 3:修复被篡改的 UPX 头部 |
| 86 | |
| 87 | 如果标准解压缩失败,修复被篡改的魔数: |
| 88 | |
| 89 | ```python |
| 90 | # 修复被篡改的 UPX 头部 |
| 91 | import struct |
| 92 | |
| 93 | with open("suspect.exe", "rb") as f: |
| 94 | data = bytearray(f.read()) |
| 95 | |
| 96 | # UPX 魔数:"UPX!"(0x55505821) |
| 97 | # 恶意软件作者通常会修改这些字节以阻止自动解包 |
| 98 | |
| 99 | # 搜索被修改的 UPX 签名 |
| 100 | upx_magic = b"UPX!" |
| 101 | modified_patterns = [b"UPX0", b"UPX\x00", b"\x00PX!", b"UPx!"] |
| 102 | |
| 103 | # 查找并还原节名称 |
| 104 | pe_offset = struct.unpack_from("<I", data, 0x3C)[0] |
| 105 | num_sections = struct.unpack_from("<H", data, pe_offset + 6)[0] |
| 106 | section_table_offset = pe_offset + 0x18 + struct.unpack_from("<H", data, pe_offset + 0x14)[0] |
| 107 | |
| 108 | print(f"PE 偏移:0x{pe_offset:X}") |
| 109 | print(f"节数量:{num_sections}") |
| 110 | print(f"节表偏移:0x{section_table_offset:X}") |
| 111 | |
| 112 | for i in range(num_sections): |
| 113 | offset = section_table_offset + (i * 40) |
| 114 | name = data[offset:offset+8] |
| 115 | print(f"节 {i}:{name}") |
| 116 | |
| 117 | # 还原二进制文件中的 UPX 魔数 |
| 118 | # 搜索 UPX 头部签名位置(通常在加壳数据末尾附近) |
| 119 | for i in range(len(data) - 4): |
| 120 | if data[i:i+3] == b"UPX" and data[i+3] != ord("!"): |
| 121 | print(f"在偏移 0x{i:X} 处发现被修改的 UPX 魔数:{data[i:i+4]}") |
| 122 | data[i:i+4] = b"UPX!" |
| 123 | print(f"已还原为:UPX!") |
| 124 | |
| 125 | # 如果节名称被修改也一并还原 |
| 126 | for i in range(num_sections): |
| 127 | offset = section_table_offset + (i * 40) |
| 128 | name = data[offset:offset+8].rstrip(b'\x00') |
| 129 | if name in [b"UPX0", b"UPX1", b"UPX2"]: |
| 130 | continue # 已正确 |
| 131 | # 检查常见的修改方式 |
| 132 | if name.startswith(b"UP") or name.startswith(b"ux"): |
| 133 | original = f"UPX{i}".encode().ljust(8, b'\x00') |
| 134 | data[offset:offset+8] = original |
| 135 | print(f"在 0x{offset:X} 处还原节名称为 {original}") |
| 136 | |
| 137 | with open("suspect_fixed.exe", "wb") as f: |
| 138 | f.write(data) |
| 139 | |
| 140 | print("\n已写入修复文件。重试:upx -d suspect_fixed.exe -o unpacked.exe") |
| 141 | ``` |
| 142 | |
| 143 | ### 步骤 4:使用调试器手动解包 |
| 144 | |
| 145 | 当自动解包完全失败时,使用动态解包: |
| 146 | |
| 147 | ``` |
| 148 | 使用 x64dbg 手动 UPX 解包: |
| 149 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| 150 | 1. 在 x64dbg 中加载加壳样本 |
| 151 | 2. 运行到入口点(系统断点后按 F9) |
| 152 | 3. UPX 解包桩模式: |
| 153 | a. PUSHAD(保存所有寄存器) |
| 154 | b. 解压缩循环(处理加壳的节) |
| 155 | c. 解析导入(LoadLibrary/GetProcAddress 调用) |
| 156 | d. POPAD(恢复寄存器) |
| 157 | e. JMP 到 OEP(原始入口点) |
| 158 | 4. 在 PUSHAD 后对 ESP 设置硬件断点: |
| 159 | - PUSHAD 后,在寄存器中右键点击 ESP -> 在内存中跟随 |
| 160 | - 在 [ESP] 地址处设置访问硬件断点 |
| 161 | - 运行(F9)——在 POPAD 前跳转到 OEP 时中断 |
| 162 | 5. 单步执行(F7/F8)直到到达 JMP 到 OEP |
| 163 | 6. 在 OEP 处:使用 Scylla 插件转储并修复导入: |
| 164 | - 插件 -> Scylla -> OEP = 当前 EIP |
| 165 | - 点击"IAT Autosearch" -> "Get Imports" |
| 166 | - 点击"Dump"保存解包后的二进制文件 |
| 167 | - 点击"Fix Dump"修复导入表 |
| 168 | ``` |
| 169 | |
| 170 | ### 步骤 5:验证解包后的二进制文件 |
| 171 | |
| 172 | 验证解包样本的有效性和完整性: |
| 173 | |
| 174 | ```bash |
| 175 | # 验证解包后的 PE 文件是否有效 |
| 176 | python3 << 'PYEOF' |
| 177 | import pefile |
| 178 | |
| 179 | pe = pefile.PE("unpacked.exe") |
| 180 | |
| 181 | # 检查节是否正常 |
| 182 | print("解包后节分析:") |
| 183 | for section in pe.sections: |
| 184 | name = section.Name.decode().rstrip('\x00') |
| 185 | entropy = section.get_entropy() |
| 186 | print(f" {name:8s} 熵值:{entropy:.2f}") |
| 187 | |
| 188 | # 验证导入已解析 |
| 189 | print(f"\n导入数量:") |
| 190 | if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'): |
| 191 | for entry in pe.DIRECTORY_ENTRY_IMPORT: |
| 192 | dll = entry.dll.decode() |
| 193 | count = len(entry.imports) |
| 194 | print(f" {dll}:{count} 个函数") |
| 195 | total = sum(len(e.imports) for e in pe.DIRECT |