简介:一套面向Windows平台逆向分析人员的脱壳与调试辅助工具集,专攻Themida及WinLicense 1.8至2.x版本加壳程序的识别、特征提取与脱壳流程验证。压缩包共含292个文件,整体大小约2.23兆字节,内容以inc、vm、h、cpp、pas等源代码与模板文件为主,涵盖安全引擎动态库、多语言开发接口、虚拟机皮肤文件、自定义虚拟机模板、示例工程、宏定义检查模块、帮助文档及多语言界面。其中海豚、鲨鱼、美洲豹、鱼类系列虚拟机皮肤与自定义模板,可辅助分析人员搭建针对不同混淆程度的脱壳环境;C、Delphi、Go、PowerBasic、PureBasic等多语言接口则方便在各类编程环境下进行二次开发与调试集成。宏定义检查模块与保护状态检测功能有助于快速确认程序是否受Themida保护及保护强度。这套工具集既可作为理解虚拟机保护机制的现成学习素材,也能作为日常逆向调试的辅助工具箱,目前已有90人学习。
1. 为什么 Themida/WinLicense 1.8–2.x 需要一套专用脱壳与调试辅助工具集
当目标程序用了 Themida 或 WinLicense 且版本落在 1.8 到 2.x 之间,常规的通用脱壳流程往往会陷入死循环:你看到了入口点,dump 出来的却是一堆无法运行的碎片;你追上了原始 API,反调试又把你推进一个看似正常但实际错误的执行分支。这个版本段的壳把反调试、VM 虚拟化和导入表隐藏三件事揉进了同一个启动流程,靠记忆里的几条命令无法稳定解锁。专用脱壳与调试辅助工具集就是把这三件事对应的模块和参数固化下来,围绕 1.8–2.x 这个具体版本区间做适配,让一次脱壳从玄学变成可重复的调试工序。这篇文章默认读者已经有调试基础,不是来读壳的原理科普,而是带着具体样本找解法:恶意样本分析、商业软件兼容性排查、老旧产品维护,都适用。我会从保护机制讲起,再把 OEP 定位、内存转储、IAT 修复和避坑逐条拆开。
2. 先拆保护再谈工具集:1.8–2.x 的运行时层次与选型
2.1 反调试、VM 和伪 IAT:工具集必须同时处理的三个层面
Themida/WinLicense 的启动过程分三段。第一段是从壳入口开始的解密循环,它会把自身解压,并把原代码的区块逐个解密;这个阶段里进程内存里的 PE 头、区段表、导入表都不是最终形态。第二段是反调试检查,它分布在解密循环的每一层,并不集中在一处,所以你不能等检查完再偷偷摸摸,而是要从附加那一刻起就隐藏痕迹。第三段是 VM 接管,原程序里被抽出来的函数被换成虚拟机字节码,由解释器在循环里读取并执行。
真正影响脱壳决策的是这三段并不是严格递进的。壳可以一边解密一边调用GetProcAddress,一边检查调试器,一边又切换 VM 上下文。所以调试器里看到的“暂停位置”往往不是干净节点,而是多个运行阶段交错的快照。正因为这样,工具集不能只准备一个按钮,而是要针对每一个阶段准备独立的断点、日志和寄存器记录,再根据这些记录判断当前处于哪一段。很多自动脱壳脚本在这里翻车,是因为它假设壳的运行顺序是线性的:先解密,再反调试,最后跳 OEP。实际不是。
IAT 层面的问题更隐蔽。Themida/WinLicense 在启动早期不会把原始导入表解密出来,而是用一段自己的跳转代码按需解析。当你 dump 出内存时,那些看起来像 API 地址的值可能来自壳自建的中转区,也可能来自临时缓冲,修复工具自动扫到的“导入函数”并不一定对应原始模块。所以工具集必须有一项“等到 IAT 稳定后再 dump”的约束。稳定标志通常是GetProcAddress连续返回之后,且地址高位落在系统模块范围内,同时原始代码段已经变成可读的指令序列。
另外还需要处理反调试的“非致命”特性。壳检测到调试器后不一定退出,而是选择走一个正常业务分支但结果错误的分支,这种情况下你很难判断是不是自己操作错了。工具集里要有一个状态记录模块,把每次断点触发时的线程 ID、异常次数、模块加载顺序全部落到日志,等脱壳失败时对照日志,才能看出壳在哪一步转移了执行方向。
2.2 1.8 与 2.x 的差异决定了脚本不能一套走天下
标题把版本写成 1.8–2.x 是有原因的。1.8 时代,壳对原代码块的处理更接近“压缩加混淆”,大量原始指令还是能在内存里找到,只是被穿插了垃圾字节。此时用内存断点跑一遍,很容易在某个VirtualProtect返回后看到完整的push ebp。2.x 以后,壳开始把关键函数真正推进 VM 指令集,原始入口附近的代码也变成一段跳板,你找到的push ebp可能只是壳伪造的诱饵。
版本差异还体现在导入表的结构上。1.8 样本的导入表往往是“壳先解析了一遍,再原样恢复”;2.x 更常见的是“导入地址不是直接解析出的函数地址,而是先放到一个中转区,再在运行时跳转”。修复后的程序表面上能运行,但跨模块调用深度不同,容易在后续初始化中触发异常。工具集里如果只写死某一套 IAT 修复规则,在不同样本上翻车只是时间问题。
从工具集选型角度看,这两个版本区间在初始调试策略上的取舍也不一样。下面这个表是我平时分版本时参考的行为概括,不一定覆盖所有加壳选项,但足够用来决定脚本方向。
| 观察项 | 1.8 更常见 | 2.x 更常见 |
|---|---|---|
| 原始代码状态 | 变形指令穿插垃圾字节 | 关键函数被 VM 抽取 |
| OEP 附近 | 可见的编译器序言 | 中转跳板加伪造序言 |
| 导入表 | 壳一次性恢复 | 多次按需解析 |
| 反调试异常 | 少量 int3 陷阱 | 大量异常分发干扰 |
| 环境检测 | 时间差为主 | CPU 指令、系统模块校验为主 |
这套差异说明“vmp脱壳”那类通用自动化脚本不能直接套到 Themida/WinLicense 上来。VMP 的虚拟化入口往往有固定模式,而 Themida/WinLicense 每次加壳生成的入口模式都带随机性,工具集里必须有“根据现场匹配入口”的步骤,而不是硬编码字节模板。这也是为什么我会把工具集设计成由手工跑通流程后再固化成脚本,而不是一开始就追求一键。
2.3 一套切入角度:四个模块和一次完整路线
我一般会把工具集拆成四个模块。反反调试模块负责启动阶段:关闭软件断点、开启硬件断点、隐藏调试器在 PEB 里的标志、过滤壳的父进程检测。OEP 定位模块用条件断点和日志记录 API 调用序列,通过调用顺序推断原始入口。内存转储模块在确定 OEP 后按页读取目标内存,并保留 PE 头里重定位表和 TLS 回调信息。IAT 修复模块最后处理 dump 文件,把调试器里解析出的真实地址写回。
一次完整路线是:附加进程 → 启动反反调试 → 不打断壳解密 → 等到 OEP 信号 → 记录寄存器和栈 → dump 完整镜像 → 修复 IAT → 另存新 exe → 再开一个新调试会话验证。每次失败都要能回溯到某一步的时机或参数,所以模块之间必须共享一份日志,把断点命中的时间戳、地址、调用栈全部写下来。
这里有一个容易被忽略的顺序问题:反反调试模块的配置必须早于壳第一次读取 PEB。如果你附加后壳已经读过了,再回头去改BeingDebugged字段,改动没有意义,因为壳已经把读取结果放到了一个局部变量里。有的工具集为了省事,把所有反反调试选项放在“附加完成”事件里,这在 Themida/WinLicense 上不够安全,正确位置应该在“创建进程”事件里,也就是系统断点触发之前。
3. 搭建辅助工具集:调试器选项、脚本化定位和常见参数
3.1 进调试器先改五个选项,否则后面都是白做
以常见调试器环境为例,我用的是“Windows 用户态调试器 + 脚本插件”这套组合。默认配置下你直接附加 Themida 加壳进程,第一步就会输:调试器停在系统断点,而壳在启动早期检测到这个“人为停顿”,随后走进错误分支。所以第一件事是打开“跳过系统断点”或“无痛附加”类选项。这个选项不仅能减少干扰,更重要的是避免让壳在启动早期看到调试器的介入。
第二个要改的是 PEB 调试标志伪装。壳会读取 PEB 的BeingDebugged和ProcessHeap字段,单改一个字段不够,两个都要改成“未调试”状态。第三个关键选项是使用硬件断点而不是软件断点。软件断点会在目标位置写入0xCC字节,壳只要对代码段做完整性校验就会抓到你;硬件断点不修改代码内容,对检测来说更隐蔽。第四个是关闭调试器的“自动处理异常”,改成“全部交给程序处理”。只有这样才能让异常分发型的反调试走过去,否则壳故意触发一次int3异常,调试器自动处理掉,壳就知道有人在接管。第五个是打开完整日志记录,把异常编码、模块加载事件、线程启动事件全部写到文件,后面定位 OEP 时这些会成为分析依据。
下面这段配置是逻辑示意,不是某个调试器特有的标准格式,但你可以在自己环境里对照成同义选项。
settings: skip_system_bp: true hide_peb: true auto_handle_exceptions: false use_hardware_breakpoints: true log_exceptions: true重点在于理解每一个开关为什么这样设。skip_system_bp如果设成 false,首次断点会停在系统 DLL 里,壳通过NtQueryInformationProcess能感知到调试端口;hide_peb要同时处理 32 位和 64 位进程的不同偏移,只处理当前位数会漏掉 WoW64 分支;auto_handle_exceptions如果设成 true,调试器会把壳用来扰乱异常处理的int3吞掉,导致壳检测不到自己的异常,从而判断存在外部调试器。这五个选项属于“不设置就先不用往下做”的基础项。
3.2 一段辅助定位 OEP 的脚本和它的参数
查找 OEP 不能靠单个字节模式。Themida/WinLicense 1.8–2.x 的随机化很强,但有一个相对稳定的特征:原始代码段在解密后会出现编译器序言,通常是push ebp; mov ebp, esp,而且该地址与当前进程模块的某个可执行段一致。下面这段脚本示意在调试器封装对象里扫描模块内存,寻找候选入口,并不绑定某个具体调试器 API。
# 在已加载模块范围内扫描 OEP 候选 def find_oep_candidate(dbg, image_base, image_size): for seg in dbg.enum_memory_regions(image_base, image_size): # 只处理已提交且可读的段,过滤 guard 页和未映射区 if not seg["writable"] or not seg["readable"] or seg["size"] < 0x200: continue data = dbg.read_memory(seg["base"], seg["size"]) # 编译器序言:push ebp; mov ebp, esp marker = data.find(b"\x55\x8B\xEC") if marker == -1: continue candidate = seg["base"] + marker # 如果候选地址跨到了另一个段,很可能是壳生成的中转段 if dbg.section_of(candidate) != dbg.section_of(seg["base"]): continue dbg.log(f"OEP candidate: 0x{candidate:X}") return candidate return None参数上,image_base是壳程序加载后的实际模块基址,不要用 PE 头里的ImageBase,因为 ASLR 后实际地址会偏移;image_size用 PE 头里的SizeOfImage,但循环里按段遍历,不能直接整块读,否则会遇到不可读页。writable条件是为了过滤只读区段,但 2.x 下原始代码段可能被标记为W+X,所以你还得允许同时可写且可执行的页面,否则会漏掉真正的原始代码段。section_of函数用于判断虚拟地址属于哪个区段,如果candidate在另一个区段,说明它是壳跳板而不是原程序入口。
这段脚本只适合生成候选 OEP,不能替代人工确认。壳可能故意在某段里放一个一模一样的push ebp来误导扫描。我通常会在脚本扫描结果上再叠加一个条件:候选地址后 32 字节内出现call [api>之类的间接调用,或者候选地址距离某个由VirtualAlloc分配的段起点很近。只有同时满足多个特征时,才把暂停点设为该地址。
3.3 dump 模块的最小脚本和几个关键参数
一旦你确认了 OEP,就要在退出调试器之前把内存完整抓出来。dump 不能直接读整片镜像,因为进程内存里有空洞,直接读会触发异常。更稳妥的做法是按页枚举,只读已提交、可访问的页面,对于空页补零。
# 按页读取,避免空洞页导致 dump 文件出现大量空隙 def dump_safe(dbg, image_base, image_size, out_path): with open(out_path, "wb") as f: for rva in range(0, image_size, 0x1000): addr = image_base + rva page_bits = dbg.query_page(addr) if not page_bits["committed"] or not page_bits["readable"]: f.write(b"\x00" * 0x1000) continue f.write(dbg.read_memory(addr, 0x1000))这里有一个常被忽略的参数:rva步长取0x1000并不总是正确。某些系统上的大页面分配单位是0x200000,如果你按0x1000拆,已提交区域末尾的单元格会因查询结果不可读而补零,dump 出来的文件就会缺一块。更稳的写法是先通过VirtualQueryEx拿到每个内存区块的页面大小,再按真实分配单位读取。另一个参数是out_path,不要把 dump 文件放到和被调试程序同目录的写保护路径下,建议放到独立工作目录,因为壳可能监视自己的文件目录变化。
dump 写完后,第一件事不是拿去修 IAT,而是用 PE 解析工具重新打开,确认区段数量、区段属性和原 PE 头里的信息能对上。对不上的话,说明 dump 时机或内存读取顺序有问题,早点重来比修复阶段再回头省时间。
4. 从入口点到修复点:Themida/WinLicense 1.8–2.x 的一次标准脱壳流程
4.1 三个能当 OEP 的可靠信号
在 1.8–2.x 这段版本里,判断 OEP 有三个信号比单看汇编更可靠。第一个是跨段跳转后停在编译器序言上,并且该序言所在段的第一个字节不是壳的密文,而是一眼能看出的机器码。第二个是栈结构恢复:当程序走到原始入口时,栈顶附近会出现一个返回地址,该地址指向壳的启动段,同时ESP对齐到 16 字节边界,这是编译器调用main前的标准状态。第三个是 API 调用序列:你会在 OEP 附近看到连续调用GetModuleHandleA、GetCommandLineA、GetStartupInfoA之类的系统 API,这是 CRT 初始化堆栈的典型序列。
这三个信号要组合使用。只看到编译器序言,可能是壳伪造;只看到 API 调用,也可能发生在壳解密阶段。我的习惯是先暂停在候选地址,把寄存器和栈内容写进日志,再用硬件执行断点重新跑一遍,观察壳是否在这个地址附近解密出一个完整的可执行块。如果每次跑到这里,下一条指令都是相同的,并且栈上已经出现系统 API 地址,再认定 OEP 不迟。
还有一类被很多人忽略的信号来自 TLS 回调。原程序如果注册了 TLS 回调,这些回调会在主入口之前执行。Themida/WinLicense 会接管这个时机,把回调地址替换成壳的代码。如果你在 dump 文件里看不到原始 TLS 回调,说明壳的启动流程还没走完,当前点并不是真正可以脱离的 OEP。
4.2 内存转储的正确时机和不该做的事
很多人会在壳自解密完成后立刻 dump,结果发现自己 dump 出来的模块没有导入表,或者入口点根本不在预期地址。原因在于壳的“解密完成”并不等于进程完全准备好,IAT 修复往往还要等到 CRT 初始化完成后才有效。更合适的时点是第一次 API 调用序列开始之后,并且你已经确认当前停留在原代码段而不是壳段。
这个阶段不该做的事也很明确:不要在 VM 解释器循环内 dump;不要在只读段里切下一个断点;不要在 dump 前手动修改任何代码字节。手动修改代码字节会让壳的校验跳进另一个错误分支,切段断点也会触发时间检测。如果一定要在壳内部停留,尽量用硬件执行断点,而不是修改页属性。
如果需要把 dump 结果做成一个可重新加载的文件,还要考虑区段映射关系。原 PE 头里的SizeOfImage通常比实际镜像要大,dump 脚本要按内存页补齐,否则加载到调试器后区段边界会错位。常见做法是把每个区的 VirtualAddress、VirtualSize、Characteristics 按顺序写入日志,再在 dump 文件里按这些值重建区段头。这样得到的文件不一定能直接运行,但至少能被调试器正确加载,方便后续修复比对。
4.3 导入表修复:必调参数和推荐取值
导入表修复通常是 dump 之后工作量最大的环节。以常见的导入表修复工具为例,你要先指定模块基址,再把“IAT 搜索范围”设置成整个镜像,而不是默认的0x1000或0x10000。Themida 的 IAT 可能在镜像末段出现,默认的小范围会让工具误判“没有导入表”。具体参数可参考下表。
| 参数 | 推荐配置 | 说明 |
|---|---|---|
| 模块基址 | 实际加载基址 | 用调试器记录的地址,不用 PE 头里的 ImageBase |
| IAT 搜索起点 | OEP 附近 RVA | 如果工具扫出异常,改从 0x0 开始全镜像搜索 |
| IAT 搜索大小 | SizeOfImage | 太小会漏掉隐藏在尾部的中转表 |
| 深度 | 2 或 3 | Themida 有时会多套一层间接跳转,深度不够会修错地址 |
| 无效项处理 | 手动处理 | 自动忽略容易跳过真实但非常规的接入点 |
| 重建重定位 | 勾选 | WinLicense 会对重定位表做压缩,dump 后必须重建 |
这里的“深度”指的是工具在解析间接调用时最多穿透几层jmp。对 1.8 样本,深度 2 通常够用;对 2.x 样本,我常遇到需要设 3 的。设大了会误把数据区识别成代码,所以修补后要在调试器里逐个点开导入函数,看反汇编前几条指令是否合理。如果出现大量导入项指向同一模块内部地址,多半是起始地址或深度不对,需要重新调整参数,而不是手工删除,因为手工删很容易把真实导入也删掉。
4.4 WinLicense 授权检查在脱壳流程里的位置
如果加壳的是 WinLicense,而不是 Themida,流程里要单独加一步:让授权验证先通过。WinLicense 会在原程序启动前读取许可证,把许可证状态放到一个全局变量里。如果你在授权检查之前就 dump,那么修复后的程序即使能启动,也会立刻返回一个授权错误,错误码还不是通用值,而是壳自定义的十六进制值。处理办法是先运行到主界面或主逻辑,确认授权状态正常,再回头 dump 内存。这样 dump 里的授权标志就是“已通过”的状态。
但要注意,这不等于绕过授权。它只是为了能在分析环境里保留一个可运行状态,方便继续调试后续逻辑。工具集里如果针对 WinLicense 做二次开发,最好记录授权变量在内存里的位置和访问它的指令序列,这样在后续多轮 dump 中可以直接定位授权状态。
5. 避坑与排查:Themida/WinLicense 1.8–2.x 脱壳和调试辅助最常踩的五个坑
5.1 调试器一附加,壳就走进“看起来正常”的错误路径
现象:附加进程后没有弹窗,没有明显崩溃,调试器里一切都正常,但继续运行后目标进程开始创建大量无用线程,或者功能逻辑明显不对。原因:壳的反调试检测把“存在调试器”映射到了一个正常业务流程分支,而不是直接报错。解决:不要寄希望于事后看日志,要在第一次暂停之前就把反反调试参数全部打开,包括隐藏 PEB、忽略异常、使用硬件断点。如果已经走到错误分支,保存快照重来,附加后的第一次暂停最好设在工作区之外。
5.2 dump 后入口点有效,但第一条指令就跑飞
现象:用工具把 dump 文件加载进调试器,入口点确实是你记下的那个push ebp,但按 F8 单步两三次后,跳进了一个0x00000000或0xFFFFFFFF地址。原因:dump 的时机太早,壳还没有把原始入口地址写进当前进程的上下文;调试器里看到的“入口点”只是某次内存断点暂停处的地址。解决:从 dump 文件中读出的.text段内容要和调试器里同一虚拟地址处的内容逐字节比较,不一致就是 dump 时机错了。应当在壳已经准备好完整 IAT、原始代码完整解密后再 dump。
5.3 导入表修复后全是杂项,目标无法运行
现象:自动搜索后列出了几百个导入,但大量导入指向一个没有模块名的地址,或者指向了同一模块内部。原因:IAT 搜索的起点或范围不对。Themida/WinLicense 的导入表不是标准顺序,自动搜索误把壳自建的那张中转表当成了导入表。解决:把“IAT 自动搜索起始地址”改成之前记录到的原始代码段 RVA,并设置足够大的范围;然后逐个点击导入项,凡是指向系统模块但不带模块名的,应手动删除。这里最容易踩的坑是不看导入项所在页,直接把整页标记为有效。
5.4 换一台机器,同样的工具集跑不出同样的结果
现象:在开发机上能顺利脱壳,换到干净的虚拟机或另一台物理机上,要么壳报错,要么 dump 出来的文件不完整。原因:Themida/WinLicense 的指令集可能依赖 CPU 特性,比如cpuid指令,不同 CPU 返回的特性位不同,壳会据此改变行为;虚拟机的时钟和中断行为也容易被时间检测利用。解决:工具集里要记录 CPU 型号、系统版本、调试器版本,并在日志头部输出这些字段;如果反调试检测与虚拟机相关,就在物理机运行一次确认当前流程是基于指令集还是基于时间。固定环境变量后,再去调脚本参数。
5.5 dump 后程序能跑,但全局变量、线程回调全部丢失
现象:修复后的 exe 能启动到主窗口,但程序内部的全局变量是默认值,或者某些线程一启动就崩溃。原因:壳启动期间调用了 TLS 回调和重定位,原始 TLS 目录并没有被重建,线程初始化函数指向的是旧地址。解决:dump 时要把 PE 头里的TlsCallbacks和.reloc段原样保存,并在修复阶段把这两部分从原始 PE 复制到新镜像。很多自动修复工具不处理 TLS 目录,需要手工补。这个环节也最容易让人误判,因为“程序能跑”会掩盖问题,直到运行在特定数据上才崩溃。
6. 给工具集加一个回归基线:用 API 调用轨迹验证脱壳可信度
脱壳做到“能跑”远远不够。你还需要确认脱壳后的程序行为与原版一致,尤其是 API 调用顺序和参数。我自己的习惯是给工具集加一个回归基线,把原程序和脱壳后的程序分别放进同样的调试会话,记录前 30 秒里的关键 API 调用序列,再对齐比较。
# 记录原程序和脱壳程序各 30 秒的 API 调用轨迹 trace_start --target original.exe --output baseline.log trace_start --target dump_restored.exe --output restored.log # 过滤掉动态基址和句柄这类噪声字段后对比 trace_diff --left baseline.log --right restored.log --ignore handle,address,time这是示意命令,具体实现取决于你调试器里可用的跟踪组件。关键是差异率怎么算:两段日志里CreateFileW、RegOpenKeyExW、GetProcAddress出现的次数应基本一致;不一致不一定说明脱壳失败,可能是启动顺序里的壳逻辑残留。我会把出现次数差异低于 5% 的项当作噪声,高于 10% 的项标红,逐条去看调用参数。如果某个CreateFileW的路径变了,说明 dump 文件里的字符串表或时间戳影响到了原逻辑。
这个验证方法也能反过来排查前面章节里的反调试问题。当脱壳后的程序在无调试条件下运行正常、在调试条件下却行为异常,你可以用轨迹对比找出第一个产生差异的 API 调用点,那个位置往往就是残留的反调试分支。它比单纯看硬盘上有没有跑出 dump 文件更可靠。
以前我交脱壳结果时,只说“能打开”;后来在一份样本上翻车,就是因为没检查 TLS 回调,程序在特定线程场景里崩溃。从那以后,我的工具集最后一步都不是“保存 dump 文件”,而是跑一次轨迹回归。希望帮到你。
本文还有配套的精品资源,点击获取