简介:这是一款面向逆向工程师、安全分析人员及开发者的自动查壳与脱壳辅助工具,专注PE结构解析,可快速获取编译器信息、是否加壳、入口点地址、输入输出表等关键数据,并针对常见加密方式给出脱壳引导,便于用户理解加密思路并开展后续分析。工具同时内置资源提取与文件类型识别能力,既能从PE中提取图片、EXE、压缩包、MSI、SWF等资源,也能鉴定bmp、jpg、mp4、7z、rar等众多格式,实战用途广泛。资源包共180个文件,压缩后约13.1MB,包含大量dll运行库、27个eis脚本、语言文件lng及若干配置文档、示例代码,整体结构便于直接部署和对照学习。已有120人学习下载,对正在入门查壳脱壳或需要快速分析未知程序的用户而言,是一份实用且易上手的工具集合。
1. 自动查壳脱壳工具:先把“能不能全自动”这层边界说清楚
拿到一个陌生 exe,第一反应不是直接开调试器,而是先把它丢进查壳工具里看清楚:这是什么编译器编出来的,加没加壳,入口点被改到哪去了,输入表输出表还剩多少。这里说的 PE 是 Windows 的 Portable Executable 文件格式,不是装机用的 PE 系统。自动查壳脱壳工具就是把这套“体检动作”固化成软件,让开发人员在分析未知程序、排查自身程序加壳后的异常、或者做逆向分析时,不用每次手动翻十六进制头文件。但我必须先泼一盆冷水:自动查壳是可靠的,自动脱壳只对压缩壳这类简单壳有效,遇到 Themida、VMProtect 这类加密壳,所谓“一键脱壳”基本是玄学。把这层边界先立住,后面每一步操作才不会白费。
2. 读懂 PE 文件头:把入口点、编译器信息、输入输出表先读出来
查壳工具的第一步,本质上是对 PE 文件头做一次全面体检。你不需要把 Windows PE 规范背下来,但必须知道四个核心位置:DOS 头里的e_lfanew指向真正的 NT 头,NT 头里有文件头和可选头,可选头尾部挂着数据目录数组,这个数组里每一项都对应一张表——导出表、导入表、重定位表、资源表都在里面。查壳工具的所有判断都建立在这些字段之上。
2.1 先用最小命令把最基本的信息拉出来
我一般会用一个很小的 Python 脚本作为所有分析的地基。它的任务就是把 PE 头里最核心的字段先读一遍:入口点地址、镜像基址、链接器版本、子系统版本、区段数量。这些字段是后面判断加壳类型时最常用的证据。
import pefile def dump_pe_header(path: str) -> None: pe = pefile.PE(path, fast_load=False) # 入口点地址是一个 RVA,实际运行时需要加上 ImageBase ep_rva = pe.OPTIONAL_HEADER.AddressOfEntryPoint image_base = pe.OPTIONAL_HEADER.ImageBase # 编译器信息:链接器主/次版本,判断是 VS 还是 MinGW 编译的重要线索 linker_ver = (pe.OPTIONAL_HEADER.MajorLinkerVersion, pe.OPTIONAL_HEADER.MinorLinkerVersion) # 子系统版本:老程序常见 4.0,新程序 6.0 以上 subsystem_ver = (pe.OPTIONAL_HEADER.MajorSubsystemVersion, pe.OPTIONAL_HEADER.MinorSubsystemVersion) print(f"EntryPoint RVA : 0x{ep_rva:08X}") print(f"ImageBase : 0x{image_base:08X}") print(f"Linker Version : {linker_ver[0]}.{linker_ver[1]}") print(f"Subsystem Ver : {subsystem_ver[0]}.{subsystem_ver[1]}") print(f"Number of Sections: {pe.FILE_HEADER.NumberOfSections}") for section in pe.sections: name = section.Name.rstrip(b'\x00').decode('latin-1') print(f" Section {name:<8} " f"VirtAddr=0x{section.VirtualAddress:08X} " f"VirtSize=0x{section.Misc_VirtualSize:08X}") if __name__ == "__main__": dump_pe_header("target.exe")注意这行:ep_rva是 RVA 而不是绝对地址,真正在调试器里下断点时要写成image_base + ep_rva。链接器版本本身不直接等于编译器版本,但有很强的提示作用:比如 VS2019 生成的 64 位程序常见链接器版本是 14.29,你看文件头里这个字段就能快速缩小工具链范围。区段列表也是关键,正常 VC 程序通常是.text.rdata.data.pdata,一旦出现UPX0、.aspack这种名字,加壳嫌疑就非常大。
2.2 输入表和输出表:判断文件“还有没有原始结构”的关键
加壳工具为了压缩体积,通常会rewrite整个镜像,把原本的输入表、输出表数据打散。所以查壳工具里“输入表输出表”这一项,其实是在验证文件结构的完整性。我遇到的场景里,很多壳会保留一个“假输入表”来骗过加载器,但里面只剩下LoadLibrary、GetProcAddress两个函数——这是壳的典型特征,原始程序的业务 API 全被藏起来了。
import pefile def dump_import_export(path: str) -> None: pe = pefile.PE(path, fast_load=False) print("=== IMPORT TABLE ===") if hasattr(pe, "DIRECTORY_ENTRY_IMPORT"): for entry in pe.DIRECTORY_ENTRY_IMPORT: # entry.dll 是依赖的模块名 dll_name = entry.dll.decode('latin-1') print(f"[{dll_name}]") for imp in entry.imports: # imp.name 为 None 时按序号导入 if imp.name: print(f" {imp.name.decode('latin-1')}") else: print(f" ordinal #{imp.ordinal}") else: print("No import table found.") print("\n=== EXPORT TABLE ===") if hasattr(pe, "DIRECTORY_ENTRY_EXPORT"): for sym in pe.DIRECTORY_ENTRY_EXPORT.symbols: if sym.name: print(f" {sym.name.decode('latin-1')}") else: print("No export table found.") if __name__ == "__main__": dump_import_export("target.exe")输出表(导出表)通常是 DLL 才有,但查壳工具里依然要检查,因为有些壳会把整个 PE 打包成“壳体内的数据”,像 ASPack、UPX 这类壳甚至会保留少量导出函数来维持基本调用。判断逻辑是这样的:如果一个 exe 同时存在输出表,且输入表里只剩两个 API,那基本可以断定加壳了。正常编译器生成的 exe 通常没有导出表,除非程序被设计成插件宿主或者驱动。参数上不用调整,这个脚本就是读头信息,但要注意fast_load=False才能保证 pefile 把数据目录完整解析出来,默认的fast_load=True会跳过部分解析。
2.3 编译器指纹:从区段名字到特征字节
查壳工具面板上显示“编译器信息”,背后往往是几套指纹的组合:区段特征、链接器版本、特定编译器的启动代码样式。VC 系程序入口代码典型是一段call到__scrt_common_main_seh的流程,MinGW 则是__mingw_CRTStartup风格,Borland 老程序入口常带GetModuleHandle调用。这些启动代码的字节样式就是编译器指纹。
| 编译器/工具链 | 区段特征 | 链接器版本参考 | 入口风格 |
|---|---|---|---|
| MSVC (Visual Studio) | .text.rdata.data.pdata | 14.x / 10.x / 7.x | call__scrt_common_main_seh |
| MinGW-w64 | .text.rdata.data.idata | 2.x / 3.x | call__mingw_CRTStartup |
| Borland Delphi | .text.data.reloc(无.rdata) | 少见 | push ebp; mov ebp, esp风格 |
| UPX 加壳后 | UPX0UPX1UPX2 | 壳自己伪造 | pushad/pushfd |
这里有个很常见的翻车点:别把“UPX1 区段名”当成编译器信息输出给用户。很多查壳工具界面上写“编译器/加壳工具”是同一个字段,但实际情况是壳工具会篡改 PE 头里的链接器版本字段来伪装。我一般会同时读区段特征和入口字节,只有两者都符合某个工具链特征时才下结论,单一证据直接判定非常容易误报。查壳工具里编译器信息这块,本质就是个指纹评分系统,不是简单的字符串匹配。
3. 查壳三件套:特征库、熵值计算与入口点指令模式
查壳不是猜,是靠三组证据互相印证。第一组是区段名和壳厂商特征字符串;第二组是区段数据的熵值——加壳相当于把原始数据重新编码,编码后数据的随机性会显著升高;第三组是入口点附近的前几条指令,压缩壳几乎必然以pushad或pushfd开场。三组证据同时命中,才能判定“这个文件加壳了”,并且大概率能认出来是哪个壳。
3.1 特征库匹配:区段名、特征字符串和文件尾部指纹
查壳工具的特征库并不神秘,它就是一张“壳指纹对照表”。区段名是最显眼的特征,但也是最好伪装的,所以真正的特征库会去文件里搜索更多细节:UPX 会在区段头部写入UPX!标记字符串,ASPack 在覆盖层里有ASPack字样,Themida 会创建名为.themida的区段。老壳为了兼容性会在区段头保留原始名称的部分残留,这也可以作为交叉验证依据。
import re SHELL_PATTERNS = { "UPX": [rb"UPX[0-9]?", rb"UPX!"], "ASPack": [rb"ASPack", rb".aspack"], "Themida": [rb"Themida", rb".themida"], "Enigma": [rb"Enigma", rb".enigma1", rb".enigma2"], "Armadillo": [rb"Armadillo", rb".anticrack"], } def scan_shell_fingerprint(raw: bytes) -> list: hits = [] for name, patterns in SHELL_PATTERNS.items(): for p in patterns: if re.search(p, raw): hits.append(name) break return hits with open("target.exe", "rb") as f: data = f.read() result = scan_shell_fingerprint(data) print("Shell fingerprint:", result if result else "not found")这段代码的逻辑是直接在文件二进制里做正则匹配,没有解析 PE 结构,所以速度快,适合做第一轮粗筛。注意我在匹配模式里同时放了“区段名”和“文件内嵌字符串”两种类型,因为有的壳会把区段名伪装成.text,但文件某处仍然残留自己家族的签名串。参数上,rb"UPX[0-9]?"这个模式会匹配UPX0、UPX1等常见区段名,也会匹配文件尾部可能存在的UPX1压缩块标记;re.search对比re.match更合适,因为特征字符串不一定出现在文件开头。特征库这轮命中的结果只用来提示“可能是什么壳”,最终判定必须等到熵值和入口指令算完。
3.2 熵值计算:量出“这段数据有多像密文”
加壳的本质是重新编码,经过压缩或加密后的数据,字节分布会趋于均匀,信息熵接近 8。普通可执行代码因为指令集和数据结构存在统计规律,熵值通常在 5 到 7 之间。所以把每个区段的字节抓出来算一遍 Shannon 熵,高于 7 就要高度警惕。但这里有个血泪经验:纯数学熵值区分不了“UPX 压缩过的代码”和“编译器优化得很规整的 C++ 模板展开代码”,它只能告诉你“这段数据不对劲”,没法告诉你是谁干的。
import math def compute_entropy(blob: bytes) -> float: if not blob: return 0.0 freq = [0] * 256 for b in blob: freq[b] += 1 length = len(blob) ent = 0.0 for count in freq: if count > 0: p = count / length ent -= p * math.log2(p) return ent with open("target.exe", "rb") as f: data = f.read() # 常见做法:只取区段原始数据,跳过 PE 头和区段头那部分 section_entropy_map = {} pe = pefile.PE(data=data) for sec in pe.sections: blob = sec.get_data() ent = compute_entropy(blob) name = sec.Name.rstrip(b'\x00').decode('latin-1') section_entropy_map[name] = ent print(f"{name}: entropy = {ent:.3f}")逻辑需要注意的是sec.get_data()拿到的已经是按 VirtualSize 对齐的区段内容,不用自己切偏移。各壳的熵值有典型的分布区间:UPX 压缩区段通常 7.5 以上,Themida 的虚拟化区段能到 7.9 甚至接近 8.0;ASPack 的区段熵值略低,约 6.8 到 7.2。我自己定的阈值规则是:熵值大于 7.0 且区段名不是.text,才判定为“可疑”;如果.text段本身的熵值超过 6.8,说明整个代码段都被翻译过,大概率是加密壳而非压缩壳。这个阈值不是死的,Debug 版按 /Od 编译的 VC 程序熵值也可能上 6.5,别一看到 6.5 就报“加壳”,误报多了工具就没人信了。
3.3 入口点指令模式:看一眼开头就知道是不是压缩壳
压缩壳最经典的入口指令是pushad(机器码60)或者pushfd(9C),这条指令把寄存器上下文全保存到栈上,然后壳代码开始解压原始代码段。等解压完毕,壳再popad/popfd恢复现场,跳到原程序入口 OEP。所以查壳工具在读入口点地址后,一定会反汇编前几字节做模式匹配。这个证据的有效性很高,因为要伪造它意味着壳得先去模拟一套完整的寄存器保存逻辑,得不偿失。
import pefile from capstone import Cs, CS_ARCH_X86, CS_MODE_64 def disasm_entry(path: str, n_insns: int = 5) -> None: pe = pefile.PE(path) ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint image_base = pe.OPTIONAL_HEADER.ImageBase # 从入口点开始,按大小取 32 字节足够覆盖前几条指令 code = pe.get_memory_mapped_image()[ep:ep + 32] md = Cs(CS_ARCH_X86, CS_MODE_64) if pe.FILE_HEADER.Machine == 0x14C: # i386 md = Cs(CS_ARCH_X86, CS_MODE_32) print(f"Entry at {image_base + ep:#x}") count = 0 for insn in md.disasm(code, image_base + ep): print(f" 0x{insn.address:08X}: {insn.mnemonic} {insn.op_str}") count += 1 if count >= n_insns: break if __name__ == "__main__": disasm_entry("target.exe")这里我依赖 Capstone 引擎做反汇编,没有手写硬编码的指令表,好处是遇到各种变长指令时不会出现解析错位。逻辑上先读入口点 RVA,从内存映射中取该偏移之后一小段字节,按机器类型选择 32 位还是 64 位模式。pe.get_memory_mapped_image()返回的是按区段 VirtualAddress 排布好的镜像,直接用ep索引即可。看输出时重点关注第一个助记符:如果是pushad,那基本是压缩壳;如果是jmp到下一行某个高地址,可能是导入表劫持;如果是正常的call到__scrt_common_main_seh,反而可以认为没加壳。前三条指令内出现pushad且紧跟着call或者mov ebp, esp的壳初始化逻辑,就可以直接判定加壳了。参数n_insns=5用来控制输出数量,压缩壳前 5 条指令基本能走完“保存现场到准备解压”的流程。
三条证据组合下来,判定逻辑就清晰了:特征库命中加上入口为pushad时,不用等熵值结果就能报“疑似 UPX/ASPack 这类压缩壳”;特征库没命中但.text段熵值大于 7.0,需要手动查区段头和入口,大概率是没收录进特征库的加密壳;三者全命中时,输出具体壳家族名称的置信度可以到 90% 以上。这套三件套组合是查壳工具里最常见的做法,自己写工具时不要只靠其中一项,单独拿熵值说事很容易翻车。
4. 自动脱壳的完整路径:定位 OEP、内存转储与导入表重建
查壳只是诊断,脱壳才是治疗。但脱壳之前得先给壳分类,因为不同类型的壳,能用的自动化手段完全不同。压缩壳如 UPX、ASPack,壳代码在程序运行时会把原始代码解压到内存,然后跳到原始入口;加密壳如 Enigma、Armadillo 会逐块解码,边执行边解开;虚拟化壳如 VMProtect、Themida 的 VM 是把原始指令翻译成自定义字节码,内存里根本没有“原始代码”。最后一类就别指望自动脱壳了,能做的只有内存转储之后慢慢还原,那是几周甚至几个月的工作量。本小节我把压缩壳的自动脱壳全流程讲透。
4.1 先给壳分类:哪类壳值得自动脱,哪类死了这条心
| 壳类型 | 典型代表 | 是否适合自动脱壳 | 理由 |
|---|---|---|---|
| 压缩壳 | UPX、ASPack、FSG | 适合 | 原始代码完整解压到内存,OEP 跳转规律明显 |
| 加密壳 | Enigma、Armadillo、Obsidium | 半自动 | 代码按页解密,需要等运行到 OEP 时才能完整转储 |
| 虚拟化壳 | VMProtect、Themida VM | 基本不适合 | 指令已被翻译为虚拟机字节码,转储出来也没有原始指令 |
| 保护壳(反调试侧重) | 老版本 Themida、Safengine | 看情况 | 先处理反调试,绕过之后按加密壳思路处理 |
压缩壳能自动脱,核心原因是它们的逻辑可以概括为一句:解压全部区段,做一次长跳转。UPX 的入口块几乎千篇一律,ESP 定律或内存断点一打,两步就到 OEP。加密壳则不同,原始代码是分块解密的,你从入口开始单步跟踪,会发现代码区段边执行边写入,早一点 dump 下来的文件缺了很多代码段数据。所以决定“值不值得自动脱壳”,先看查壳三件套给出的壳类型。如果显示 Themida 或 VMProtect,趁早调整预期:自动工具只能帮你完成内存转储,后面的代码还原要人工分析。
4.2 定位 OEP 的三招:ESP 定律、内存断点、单步跟踪
OEP(Original Entry Point)是壳跳转到原始代码的那条指令地址。定位 OEP 是脱壳里最关键的一步,dump 早了会拿到一个满是壳代码的文件,dump 晚了可能已经在原始代码里执行过头。我在实操中最常用 ESP 定律,它适用前提是入口处有pushad或pushfd。操作步骤如下:
- 在调试器(x64dbg 或 OllyDbg)中加载目标程序,停在入口点。
- 单步执行一次
pushad指令,此时 ESP 地址固定下来。 - 对该 ESP 地址下硬件访问断点(不是软件断点,软件断点会被壳的代码修改干扰)。
- 按运行键,壳解压完成后执行
popad,硬件断点触发在popad附近。 - 再单步几次,找到跳向原始代码区的那个
jmp或retn,落点就是 OEP。
内存断点法适合入口不是pushad的壳,逻辑是对原始代码段设内存访问断点。先看区段表找到原始.text段对应地址,对这个地址范围下内存断点,断点命中时说明壳已经把原始指令准备妥当了。这个方法对 UPX 同样有效,但当壳存在反调试时经常触发各种自校验。单步跟踪法是兜底方案,从入口开始 F8 一步步走,看到jmp转到一个全新地址就停下来确认。这个方法最慢,但遇到小众壳时只有它可靠。
# x64dbg 中的脚本化 ESP 定律示意(python 风格,用在 x64dbg 的脚本引擎里) # 实际执行时逐条命令操作,下断后手动按运行即可 bp 入口地址 run # 停在入口后,手动步过 pushad # pushad 执行后,读取 ESP 当前值并下硬件断点 set_hardware_breakpoint esp, access run # 断点触发位置附近应出现 popad,我们在此手动单步观察跳转目标这里不硬凑完整脚本,因为 x64dbg 的硬件断点操作在命令栏里更直观:先执行r查看寄存器,获取 ESP 值后运行bph esp, r下硬件访问断点,再按 F9。断点触发后再用F8步过几条指令,找到落点。为什么强调用硬件断点而不是软件断点:壳代码在运行过程中会解密、改写代码段,软件断点执行时往指令字节里写 0xCC 的操作可能触发自校验,而硬件断点靠 CPU 调试寄存器实现,不修改指令内容,被反调试系统发现的风险要小很多。
4.3 内存转储与导入表重建:Scylla 和 ImportREC 的参数怎么填
OEP 定位成功后,先别急着 dump。要把进程内存按 PE 结构写回文件,还需要修复一个重要部分——输入表。加壳工具会把原始 IAT(导入地址表)重定向到壳自己分配的缓冲区,程序运行时动态解析GetProcAddress填充真实函数地址。如果直接 dump 内存,文件落地后一运行就崩,因为导入表那一块的数据还是壳运行时填的临时地址,而不是 PE 文件里应有的导入描述结构。
修复工具常见做法是 OllyDump(配 OllyDbg)或 Scylla(配 x64dbg)。Scylla 的操作参数更明确,我常用它做演示:
| 参数项 | 填写内容 | 注意事项 |
|---|---|---|
| OEP | 刚才定位到的原始入口地址,RVA 形式 | 如果填错,Scylla 在修复时会找错代码起点 |
| IAT 搜索范围 | 一般填整个镜像大小,例如 0x100000 | 范围太大会搜索变慢,但结果更全 |
| Importer 解析深度 | 默认 Auto 即可 | 遇到加壳过的 DLL 时改为 Depth 2 或 3 |
| 转储模式 | 选“Full dump”(完整转储) | 不要选“Partial”,后者会丢掉部分区段;重定位表缺失时选“Dump headers”先保留头 |
操作顺序是:在 x64dbg 里确认停在 OEP 后,菜单里打开 Scylla,先填写 OEP 地址,再点 “IAT Autosearch”,它会扫描出 IAT 起始 RVA 和大小,点 “Get Imports” 后会列出所有恢复出来的 DLL 和函数。最后点 “Fix Dump” 选择一个刚才用 x64dbg 的dbh命令或 Scylla 自带的 dump 按钮生成的原始转储文件,它会在转储文件基础上直接打补丁生成新文件。这套流程下来,一个 UPX 壳的 exe 从查到脱一般在五分钟以内。加密壳则别指望一次成功,IAT Autosearch 常常扫不全,需要手动在内存窗口里追踪 IAT 字段的赋值位置来补充。
5. 查壳脱壳避坑:六条踩坑记录与排查思路
5.1 入口点地址不是 OEP:dump 出来跑不起来
现象:用工具查出入口点地址,在调试器里跳到那个地址后直接点 dump,生成的文件双击没反应,甚至在加载阶段就报错“不是有效的 Win32 应用程序”。
原因:入口点地址(AddressOfEntryPoint)是 PE 头里记录的启动地址,加壳程序里这个字段被指向了壳的解压代码,而不是原始程序入口。很多人第一次脱壳时直接把这个地址当 OEP 用,dump 出来的整个镜像都还是压缩状态,自然无法运行。
解决:先做 OEP 定位再 dump,定位方法就按我上一章写的 ESP 定律或内存断点。正确 OEP 的特征是落在原始代码区段.text区域内,且附近能看到调用GetModuleHandle、GetCommandLineA等常见启动流程的指令。确认 OEP 后,可以顺便看一眼入口处前几行指令和pushad是否已经出现,以此验证壳的跳转已完成。
5.2 熵值误判:VC 模板代码也能算到 7.0 以上
现象:一个自己用 VS2019 编译的 Release 版小工具,查壳工具报“未知壳,熵值异常偏高”,吓得以为编译产物被感染了。
原因:现代 C++ 模板展开后的代码和异常处理表数据非常规整,但如果文件包含大块常量表特别是加密密钥、预计算数组,熵值能轻松过 7.0。更麻烦的是,有的壳会故意填充大量 0x00 或者重复字节把熵值拉低来伪装成普通文件,只看熵值根本防不住。
解决:熵值只能作为“可疑信号”,不能作为定罪证据。我自己的规则是熵值判断必须搭配区段名、入口指令、特征库三组证据至少两组同时命中才报“加壳”。如果只有熵值异常,先打开区段视图看异常段是.rdata还是.text:.rdata里的高熵多半是资源或常量数据,.text高熵才考虑壳的可能性。遇到填充字节压熵的伪装壳,直接把入口和区段特征作为主导证据。
5.3 输入表全空或者只剩两个函数:静态解不出来,就上动态修复
现象:用 pefile 读输入表,发现要么报错没有输入表,要么只翻到LoadLibrary和GetProcAddress,但程序明明调用了大量系统 API。
原因:壳把原始输入表加密或移除了,PE 头里的数据目录被重定向到壳构建的假导入表上。假导入表的作用是让 Windows 加载器启动时不报错,真正函数地址是壳运行时通过LoadLibrary动态解析的。所以静态解析必然只能看到壳自己留下的两个 API。
解决:切换到动态分析路线。用 x64dbg 里 Scylla 插件做 IAT Autosearch,它会监控内存中跳转到系统 DLL 的指令位置来反推 IAT 的 RVA 和大小。如果 Autosearch 扫不全,就在内存窗口中搜索kernel32.dll、ntdll.dll的模块基址附近跳转指令,找到一段连续的 IMAGE_THUNK_DATA 数据后手动填写 IAT 地址。这个步骤对加密壳是必经之路,别指望静态扫描工具能直接给出答案。
5.4 重定位表被删除:脱壳后的 DLL 一 LoadLibrary 就崩
现象:脱壳修复一个 DLL,转储文件在静态工具里看头头是道,但一旦被 LoadLibrary 加载就报访问违例,而且不是每次都崩,加载地址不同表现还不一样。
原因:壳为了省空间会把.reloc重定位表剥掉,因为壳程序自己加载时会强制把镜像映射到镜像基址,不需要重定位。脱壳后如果没有恢复重定位表,系统把它加载到其他地址时地址修正不到位,代码和数据引用的绝对地址全部指向原始加载地址附近的错误位置。
解决:脱壳前先看区段表是否还有.reloc段;没有的话,修复工具里选择“重建重定位表”或通过 Scylla 的 Reloc 功能基于内存中的现有状态重新生成。这里有个更稳妥的验证动作:脱壳后的 DLL 在被加载时如果只有固定基址能工作,说明重定位表没修好;用LoadLibraryEx加LOAD_LIBRARY_SEARCH_*强制加载到非基址地址做测试,能过这个测试才算真修好了。
5.5 在错误的位置 dump:文件大小巨大但全是空洞
现象:dump 出来的文件比原始文件大出好几倍,用 PE 工具打开发现区段 VirtualSize 全是 0x1000 的整数倍,里面大量数据是 0xCC 或 0x00。
原因:内存转储拿到的区段大小是按内存页对齐后的尺寸,而磁盘文件里区段按 FileAlignment 对齐,通常是 0x200 或 0x1000。dump 时如果直接把 VirtualSize 复制成文件大小,每个区段都带着页对齐补丁,文件当然巨大。更严重的是,如果 dump 时机在壳还没写完所有区段数据时,那些空洞区域会被壳残留的临时数据填满。
解决:dump 的时候要区分 VirtualSize 和 RawSize,转储工具会按区段的 PointerToRawData 关系重新排列数据;如果用的工具没有自动优化,就手工把每个区段按 RawSize 截断,区段头里 SizeOfRawData 字段才是落盘大小。验证方法是把脱壳后的文件用 PE 工具看区段表,检查是否每个区段的 VirtualSize 和 RawSize 比值是否合理,异常膨胀说明 dump 时选错了范围。
5.6 查壳工具报“无壳”但运行明显异常:小心伪装壳
现象:某个软件用常见查壳工具全部扫描一遍都报“无壳”,但用调试器一加载就发现入口点不在.text段,而且代码有大量花指令。
原因:一些定制壳会在 PE 头层面做文章,把入口点 RVA 伪装成指向.text段里的某个位置,实际那段位置存放的是壳的起始跳板代码。更隐蔽的做法是把区段名改成.text,但区段标志位和特征与正常编译结果不同。特征库没收录这类壳,自然查不出来。
解决:把“入口点是否在.text段内”作为必查项。正常编译的程序入口 RVA 必然落在第一个可执行区段内;如果入口点指向.data或文件尾部,直接判定异常。配合区段权限检查:可读可写可执行的区段组合是壳的典型标志,正常程序代码段应该只读可执行。这种伪装壳查到这一步就已经算剥掉伪装了,剩下的是手动分析入口跳板。
6. 脱壳后的验证与 IAT 修复:怎么确认这次脱壳真的成功了
把脱壳文件放进 PE 工具再扫一遍只是第一步,我习惯用一套更严的验证清单。首先看区段表:UPX0UPX1这类壳区段名应该消失,取而代之的是.text.rdata.data,且.text段的 VirtualSize 和文件大小匹配,而不是只剩一个壳的压缩块。其次看入口点:OEP 地址落在.text段内部,用调试器加载后停在入口处能看到正常的 CRT 启动代码,比如sub rsp, 28h配合call到初始化函数,而不是pushad。第三重看输入表:Scylla 修复后导入表中 DLL 数量应该恢复到几十个以上,如果还是只有两个,说明 IAT 修复没做完。第四看熵值:脱壳后.text段的熵值应回落到 6.0 以下,这个回落的幅度是最直观的“壳被剥掉”的证据。
验证代码就一行:
python check_pe.py target_unpacked.exe这个脚本会把前面第二章、第三章的检查项合并到一起,把区段名、入口点、输入表数量和熵值全部打印出来。我每次脱完壳第一件事就是跑它,对比脱壳前后的两组数据。如果发现输入表数量没恢复,用 Scylla 再执行一次 IAT Autosearch,但要注意把搜索范围从默认的 0x1000 扩大到整个镜像区,可以省掉不少手动追踪的功夫。还有一个容易忽略的细节:脱壳文件要放到全新目录里运行测试,别在原来加壳文件旁边跑,某些壳带有文件关联自校验,会误以为还在原目录而触发反调试。
我自己的习惯是脱完壳先不改入口点,保持 OEP 原样跑一遍程序,确认功能正常后再用工具把入口点改为新值。因为有些加壳后的程序内部会校验自己的入口点地址,一改就崩溃,先不动入口点能排除这类干扰。如果运行两分钟后还没有异常且各功能正常,才去做最后一步的入口修正。这套流程走了两年多,帮我避开了绝大多数脱壳后文件只有静态意义、实际一运行就翻车的坑。希望这套方法能让你少走点弯路,也祝你在做自己的查壳脱壳工具时,能在边界判断和异常处理上比大多数工具做得更细。
本文还有配套的精品资源,点击获取