Themida 静态去虚拟化全揭秘:通用优化瓦解虚拟机框架,代码恢复 1:1 可执行
引言
在阅读本文前,建议研究以下二进制反混淆的社区研究:
- https://arxiv.org/pdf/1909.01752
- https://github.com/Colton1skees/Dna/pull/8
- https://github.com/JonathanSalwan/VMProtect-devirtualization
- https://github.com/NaC-L/Mergen
- https://www.youtube.com/watch?v=3LtwqJM3Qjg
- https://github.com/backengineering/vmp2
- https://back.engineering/blog/17/05/2021/
- https://www.youtube.com/watch?v=vYAJCfafYTY
- https://www.youtube.com/watch?v=KYQOtGiH9pQ
- https://github.com/r3bb1t/bin_lift
- https://nac-l.github.io/2025/01/25/lifting_0.html
- https://blog.thalium.re/posts/llvm-powered-devirtualization/
- https://github.com/avast/retdec
- https://github.com/ergrelet/themida-unmutate
- https://github.com/lifting-bits/remill
本文展示了对 CodeVirtualizer/Themida 保护代码的去虚拟化过程,此技术几乎适用于所有基于虚拟机的混淆器,稍作修改就能支持不同混淆器。以下是可简化的混淆器列表:
- https://vmpsoft.com/
- https://www.oreans.com/themida.php
- https://github.com/vxlang/vxlang-page
- https://github.com/snowsnowsnows/EagleVM
- https://github.com/dmaivel/covirt
- https://github.com/noahware/binprotect
Themida 架构分析
Themida 的虚拟机架构与 VMProtect 主要区别在于支持嵌套虚拟化,因其虚拟机上下文和虚拟栈存在于二进制文件本身,而非本地栈。本文不深入探讨架构,因其与去虚拟化方法关系不大。重要的特定于虚拟机的组件是虚拟分支和 VMEXIT 行为,将在后续章节介绍。如需全面分析 Themida 架构,可参考此研究。
给明智者的警告
不建议采用将虚拟机处理程序与 x86 指令进行模式匹配的方法。曾有人尝试,但这种方法无法扩展。保护程序供应商对处理程序布局、操作码表或调度逻辑的小更改,可能破坏工具。本文方法减少对特定于虚拟机知识的依赖,能在各种 Themida 版本中发挥作用。研究虚拟机架构有价值,可了解内部结构,为引导符号执行引擎做决策。
绝大多数去虚拟化工作通过通用优化完成,处理控制流,特别是虚拟分支和虚拟化调用时,才需要特定于虚拟机的知识。
引导式符号执行
核心是将本地指令提升为可处理的中间表示(IR),通过优化解决未知分支目标时具体化控制流推动提升。Back Engineering Labs 维护的二进制提升和重新编译引擎 BLARE2 有自定义静态单赋值(SSA)IR,支持 AMD64 和 ARM64,具备完整过程系统、优化器、指令选择器、寄存器分配器和链接器。BLARE2 可将优化后的 IR 降级回本地代码并重新插入二进制文件,生成与原始代码几乎 1:1 的输出。遵循本文技术,用 Triton 或基于 LLVM 的提升器如 Remill 也能实现大部分功能,它们能生成干净的优化 IR,但后端让 LLVM 生成紧凑、行为良好的本地代码并重新插入较难。
提升从所有寄存器和标志符号化开始,指令反汇编并提升,直到无法确定下一个指令指针。情况取决于控制流指令,提升后的 ret 意味着最后存储到 RSP 的值是下一个 IP。地址无法具体化,可能是优化不够深入,或分支有多个实际目标,如虚拟化的 JCC。
具体化栈指针
符号执行开始时,除栈指针赋予具体初始值外,所有寄存器和标志符号化。这是设计选择,非严格要求。保持 RSP 具体,现有加载/存储传播机制可处理栈访问,调整栈指针的算术运算可常量折叠。另一种选择是保持 RSP 符号化,编写专门栈传播逻辑,但在去虚拟化背景下工作量大且无实际收益。
这种方法代价是不支持动态栈分配的函数,如 alloca 或编译器生成的变长数组,因栈偏移量非静态可知。实践中,易被虚拟化的函数中动态分配的栈帧不常见,具体 RSP 的简单性值得这种限制。
优化
简化 Themida 或 VMP 的虚拟化无需详尽的编译器优化,一组小的优化过程运行到收敛,足以瓦解虚拟机框架。以下介绍各优化过程及对去虚拟化的贡献。
这些优化过程相互关联。从内存加载的字节码提升为常量,使围绕它的解密算术运算折叠,产生具体处理程序索引,解决处理程序表查找,暴露下一个处理程序地址为常量。各过程为下一个过程提供输入,虚拟机框架逐渐瓦解。
常量提升与内存建模
内存加载的数据常用于间接跳转计算,VM 字节码是重要例子。提升器遇到从字节码地址加载数据时,需将值提升为常量,供其他优化过程处理。字节码加载提升后,可对解码算术运算常量折叠。处理程序解密逻辑、操作码表索引、VPC 更新数学运算等逐渐折叠,直到只剩具体处理程序地址,用该地址继续提升。
BLARE2 中的加载存储传播逻辑可配置,程序员可指定二进制文件中可安全提升的内存范围,避免 VM 私有常量提升触及用户数据。该过程跟踪存储操作,若对地址 0x5000 存储,随后从 0x5000 加载,会转发存储的 SSA 值,而非从原始映像读取。传播按字节级别建模,可处理重叠存储操作。有两种失败模式需注意:从加载前写入的地址提升会产生错误结果,需存储跟踪;过度提升会破坏原始程序语义,可配置范围策略区分。
常量折叠
表达式操作数为已知常量时,可用结果替换表达式。如 10 + 10 可写成 20,适用于加法、减法等运算。去虚拟化中,此过程需运行到收敛。一次折叠使表达式为常量,可能让之前非常量的表达式变为常量,实现多次折叠。各优化过程相互依赖,使虚拟机框架瓦解。字节码解码算术运算折叠,处理程序表索引具体化,调度逻辑消失。
死存储消除
广泛应用死存储消除不安全,未使用的存储操作可能有实际副作用。这里安全是因为针对的存储操作限于 VM 私有内存。Themida 用自己的部分存储虚拟机上下文、虚拟栈和相关框架,原始程序不会观察到这些内存。提升到 VMEXIT 时,从恢复函数角度,只触及 Themida 部分的存储操作可证明无用,可删除。跳过此过程有代价,VM 处理程序通过上下文和虚拟栈交换状态,不消除存储操作会使 IR 像 VM 解释器。结合死依赖分析过程,可使 IR 更像函数。
指令组合
指令组合通过识别代数恒等式和合并有可知结果的操作简化表达式,目标是将 IR 简化为保留原始语义的最小表达式。
这些恒等式对去虚拟化重要,VM 处理程序有很多噪声,如相互抵消的算术运算、冗余掩码操作和恒等乘法。指令组合运行到收敛会消除这些噪声,简化表达式用于常量折叠和分支折叠。提升后复杂的表达式应用规则后通常简化为常量。
分支折叠
前面优化完成后,标志计算应解析为常量或未定义。依赖常量标志的分支有静态可知的目标,可消除不透明的分支目标。
VMEXIT 行为
提升开始时栈指针具体化,初始 RSP 值已知。提升器遇到 RSP 为 `initRSP - 0x10` 的返回指令时,是 `VMEXIT-CALL`。Themida 和 VMP 都用此模式,调用目标放在 RSP 处,返回地址放在 `RSP + 0x8` 处。此时在 IR 中发出调用操作,原始代码执行间接调用时,调用目标可能符号化,需明确处理。这种模式不符合 CET 规范,因栈上的返回地址不是由 vmenter 存根放置的。
并非每个 VMEXIT 都是调用,与初始 RSP 的偏移量可区分它们。返回到原始函数尾声、不支持的指令退出和类似调用的退出会产生不同的栈指针值,控制流匹配逻辑用这些差异正确分类每个退出。
虚拟化控制流
提升过程需记录每个虚拟指令指针。原因是发现回边时,需将它们识别为循环,而非无限展开。不跟踪 VIP 会使虚拟化循环展开,IR 膨胀。
对于 VMProtect,跟踪 VIP 简单,字节码编码下一个处理程序地址,间接跳转计算中最后一次模块加载的值是当前的 VIP。在 BLARE2 中,从持有跳转或返回目标的 SSA 值反向遍历 IR 有向无环图(DAG),找到最后一次加载得到 VIP。
Themida 处理虚拟化条件分支的方法与 VMP 不同,这是少数需要特定于虚拟机知识的地方。前面内容通用,适用于 VMP 和其他基于 VM 的保护程序,但 VJCC 处理程序是 Themida 特有的结构。
在 Themida 的 VJCC 处理程序中,条件先评估,结果写入 VM 上下文中的 `branch_taken_flag`。处理程序结束设置该标志后,VIP 才前进。这意味着符号执行在 VIP 解析前会遇到分支分叉,需探索两条路径,通过处理程序底部的条件 VPC 更新逻辑跟踪每条路径的正确 VIP,不能用 VMP 的简单启发式方法。必须跟踪 `branch_taken_flag` 直到 VIP 分叉处。
死依赖分析过程
降级前,需知道从虚拟化区域出来后哪些寄存器和标志活跃。方法是收集 VM 退出后立即被本地代码覆盖的寄存器和标志集合。被覆盖的内容从 VM 角度无用,无需计算。
没有此过程,符号表达式会在 IR 中悬空。以 ZF 为例,若符号化且依赖 VM 内部计算,即使下一条本地指令覆盖它,IR 也会保留 ZF 的完整表达式树。
栈指针重写过程
提升开始时栈指针具体化,简化后的 IR 有针对常量栈地址的加载和存储操作。降级前,此过程将这些访问重写为相对于 RSP 的形式。需验证没有栈引用为负数,否则意味着访问到红色区域。
中间表示(IR)降级
有干净的 IR 后,下一步将其降级回原始指令集架构(ISA)。降级包括指令选择、寄存器分配、汇编以及将恢复的代码重新链接到二进制文件中,由 BLARE2 处理。
降级过程关键约束是寄存器压力。若寄存器分配器溢出,去虚拟化后的代码会有自己的栈帧,与原始函数的栈帧并存,导致 IDA、Binary Ninja 等工具误读去虚拟化区域内调用的栈传递参数。目标是生成接近原始代码的可执行输出,能在反汇编器中干净加载。
这也是对基于 LLVM 的去虚拟化框架在该问题上持怀疑态度的原因。让 LLVM 生成紧凑、行为良好的本地代码并重新插入二进制文件是个项目。虽能从提升管道得到可读的 LLVM IR,但要得到干净、可重新插入的本地代码,需与 LLVM 框架斗争。
结果
以下是两张图片:第一张显示虚拟化前 IDA 中的原始函数,第二张显示去虚拟化后的同一函数。
虚拟化之前 IDA 中的原始代码
去虚拟化之后的代码
去虚拟化后的输出与原始代码功能 1:1 对应。后端选择了不同的指令和寄存器,这是寄存器分配器和指令选择器的不同选择结果,但关键是没有寄存器溢出。恢复的代码紧凑干净,无多余栈帧或伪影。
重要的是,去虚拟化后的代码不仅结构相似,而且可执行。恢复的函数可作为本地代码运行,能在反汇编器中干净加载,行为与原始实现相同。
对于有兴趣进一步分析或验证的人,将提供 GitHub 仓库,包含原始二进制文件、虚拟化后的二进制文件和去虚拟化后的二进制文件。
仓库链接:https://github.com/backengineering/themida-devirt
阻止符号执行
为让混淆器击败符号执行,需防止间接跳转目标具体化。一种方法是将分支目标编码为包含不透明值的多变量布尔代数(MBA)表达式,防止常量折叠减少表达式。但现在用此演示中描述的技术可简化用于隐藏分支目标的 MBA 表达式。存在更强大的技术使符号执行不可行,CodeDefender 在其更高级别的保护层级中实现了这些技术,具体细节超出本文范围。
标签
- 混淆
- Themida
相关文章
Theodosius - Jit 链接器、符号映射器和混淆器
- IDontCode
- Windows
现有的软件保护框架通常在较小的编译级别范围内运行。最高级别的混淆通常直接作用于源代码(源到源),次高级别是 LLVM IR(通过优化过程),最常见的第三级别是作用于本地二进制映像(二进制到二进制)。
Ring-1.io 的反混淆与分析
- IDontCode, noahware, Eggsy, AVX
- Windows
作为这项研究的一部分,部分反混淆了 ring-1.io 使用的多个 Themida 保护的二进制文件,包括其 UEFI 引导加载器植入程序。恢复了几个关键函数,以便对植入程序的行为进行静态分析。这项工作揭示了有意设计来抵抗检查的机制,包括虚拟化辅助钩子、执行重定向和内核操作技术。
VMProtect 2 - 第二部分,完整静态分析
- IDontCode
- Windows
本文的目的是详细阐述上一篇题为《VMProtect 2 - 虚拟机架构的详细分析》的文章中披露的工作,并纠正一些错误。此外,本文将主要关注利用上一篇文章中披露的知识创建静态分析工具……
