
Devirtualizes Tencent VM .tvm0 sections: lifts VM handlers to guest-level listings and LLVM IR, recovers CFG and dispatch, and patches recovered code back into the PE.
关联 Zenodo 记录:10.5281/zenodo.23474767。
⚠️ Disclaimer: This project is intended strictly for authorized security research, interoperability analysis, and education. It does not include or provide any bypass functionality for commercial anti-cheat systems. Users are solely responsible for ensuring they have the legal right to analyze the target binaries.
面向 Tencent VM 的虚拟化还原工具。从 .tvm0 段恢复虚拟化代码,提取每个 VM handler 对 guest 寄存器和内存做了什么,并能把还原出的函数写回二进制,作为一个普通的只读可执行段。
它是工具:给它一个模块,拿回结构和代码。
.tvm0 段 -> VM 语义 -> guest 级 listing -> LLVM IR -> 打过补丁的 PE
某个 VM root 的还原后 listing,guest 寄存器组在跨块绑定后的形式。

python -m venv .venv
source .venv/Scripts/activate # Windows: .venv\Scripts\Activate.ps1
pip install -r requirements.txt
Python 3.13、capstone。后端另需 llvmlite==0.50.0,Windows 上还需 MSVC link.exe/cl.exe。z3-solver 可选 —— 没有它时路径条件化仍在,只跳过有界 dispatch 证明。
把 .env.example 复制为 .env,填上本机路径。.env 已 gitignore;每个配置项也都有命令行参数,文件只是提供默认值。
| 键 | 含义 |
|---|---|
TVM_MODULE | 分析目标,供不带位置参数的 probe 使用 |
TVM_LISTINGS | 后端读取的 listing 目录 |
TVM_OUT | 所有输出的根目录(默认 out/) |
TVM_STEPS | 每块步数上限,触顶即截断(默认 900000) |
TVM_BLOCKS | 每 root 块预算(默认 512) |
TVM_PAYLOAD_OPT | payload codegen 级别 0–3 |
TVM_PAYLOAD_MEM | sealed | absolute guest 内存模式 |
TVM_MSVC_BIN | 存放 link.exe/cl.exe 的目录,免去依赖 PATH |
优先级:命令行参数 → .env → 环境变量 → 内置默认值。
引擎命令在 engine/ 下执行;后端命令在仓库根目录执行。
cd engine
python -m probe.scan_tvm0 /path/to/dir # 哪些文件带 .tvm0,以及它的 root
python -m probe.trampoline_forms input.exe # 入口跳板长什么样
python -m probe.mnemonic_census input.exe # VM 里包含哪些 x86
python -m core.devirt input.exe --out ../out/listings
每个函数产出一个 0x<root>.txt listing 和一个 0x<root>.guest-state.json。选项:
| 选项 | 默认 | 含义 |
|---|---|---|
--out=DIR | stdout | 写出 listing 与 guest state |
--root=0x… | 全部发现的 | 只处理一个 VM 入口 |
--show[=N] | 关 | 打印前 N 行 |
--steps=N | 900000 | 每块步数上限,触顶即截断 |
--blocks=N | 512 | 每 root 块预算 |
--caller-entry=0x… --call-site=0x… | 关 | 对入口做 caller 特化 |
每个 listing 的头部会写明这次运行是否被截断;截断的 listing 是下界,不是完整函数。
python -m probe.cfg_run input.exe 0x14015fc2e # 走一遍,打印 CFG
python -m probe.guest_effect input.exe 0x14015fc2e # handler 对 guest 寄存器的效果
python -m probe.ctx_map input.exe 0x14015fc2e # 哪个 context slot 存哪个 guest 寄存器
python -m probe.why_unresolved input.exe 0x14015fc2e # 某个 dispatch 为什么没解出
python -m probe.block_exits input.exe 0x14015fc2e # 某个块为什么没有后继
python -m probe.stop_debug input.exe 0x14015fc2e # 求值为什么停下
python -m probe.unresolved_detail input.exe 0x14015fc2e # 逐块细节
追到具体某个值或某次写入:
python -m probe.trace_window input.exe 0x14015fc2e 0x14015fc40 0x14015fc90
python -m probe.reg_history input.exe 0x14015fc2e rax
python -m probe.who_wrote input.exe 0x14015fc2e 0x18
python -m probe.flags_at input.exe 0x14015fc2e 0x14015fc60
python -m probe.stack_at input.exe 0x14015fc2e
.\write-devirt.ps1 -Module 'C:\path\to\SGuardSvc64.exe'
依次跑 payload → 注入 → 加载 → 差分,遇到第一个失败就停下。选项:-Listings、-Out、-Opt 0..3、-Mem sealed|absolute、-SkipDifferential、-Force。
也可以逐个阶段单独跑:
cd llvm
python payload.py --listings=../out/hard-cases-listings-final --out=../out/write-devirt --opt=0 --mem=sealed
python peinject.py --host=input.exe --payload=../out/write-devirt --out=../out/write-devirt/input.neko.exe
python miniload.py ../out/write-devirt/input.neko.exe
python diff_payload.py --payload=../out/write-devirt --patched=../out/write-devirt/input.neko.exe
真正有意义的是 diff_payload.py:它运行打过补丁的镜像里的代码,与解释器在同一 listing 上逐字节比对。miniload.py 只证明镜像能加载、入口能跑。
ACE 用一个跳板替换原生函数,跳入 VM dispatcher。dispatcher 执行的是原生 x86 handler,但被保护函数的体系结构状态存放在 VM context 中。
flowchart TB
subgraph TOP[ ]
direction LR
subgraph TEXT[.text]
ENTRY["<b>函数入口</b><br/>跳转到 VM entry"]
end
subgraph VM[.tvm0 段]
direction LR
CORE["<b>共享 dispatcher 与 handlers</b><br/>执行 x86 - 解析下一个 handler"]
CTX["<b>Guest context</b><br/>GPRs - RFLAGS - VIP"]
CORE <--> CTX
end
end
subgraph NATIVE[VM 之外的原生执行]
direction LR
BOXED["<b>Boxed 指令</b><br/>执行原始 x86"]
CALL["<b>Guest 调用</b><br/>调用原生目标"]
EXIT["<b>Guest 返回或尾调用</b><br/>恢复原生执行"]
BOXED ~~~ CALL ~~~ EXIT
end
TEXT --> VM
VM <--> NATIVE
style TOP fill:transparent,stroke:transparent
部分操作在 VM 之外执行。Boxed 指令在 VM 恢复它所需的寄存器后执行原始 x86 编码,再把效果写回 guest context。Guest 调用调用一个原生目标,返回时把返回值与 Windows x64 ABI 的 clobber 体现在 guest 状态里。Guest 返回与尾调用则完全离开 dispatcher。
context 包含全部十六个 guest 通用寄存器(含 guest RSP),以及打包的 guest 标志位。它的布局随保护版本变化,因此是通过观察 VM 的保存/恢复行为定位的,而不是假设固定地址 —— probe.ctx_map 报告在 store 处实测的映射,且每个输出的 listing 都会给 context 访问标注 slot 偏移。
条件控制流通过 dispatch 算术实现:handler 取出一个 guest 标志或谓词,用它选出下一个虚拟程序计数器,再算出下一个 handler 的地址。还原器符号化地求值这一过程,并在所选路径依赖未解析 guest 值时分裂求值状态。一个块的标识是 (handler, vip) 这一对,而不只是 handler。
整个还原建立在一条规则上:按机器实际做了什么来识别值,而不是按分析给它的名字。 跨块寄存器传播在 listing 中以注解形式携带,绝不作为证据 —— 把 listing 里每个寄存器版本号任意置换后,解析出的结构必须不变。
write-devirt.ps1 一条命令:payload -> 打过补丁的 PE -> 差分
.env.example 配置模板;复制为 .env
envfile.py .env 解析;bootenv.py 供 llvm/ 加载,engine/envboot.py 供 probe 加载
engine/core/ PE 解析、表达式 arena、x86-64 lifting、机器模型、
guest context、CFG 恢复、guest IR、guest-state 程序
engine/probe/ 28 个 probe —— 每个回答一个关于目标的问题
engine/tests/ 30 个套件,全部基于合成 fixture 或 Python 独立算术对照
llvm/ listing 文法、IR -> LLVM IR、解释器、差分、
payload 构建、PE/PDB 编辑、注入、加载
llvm/runtime/ payload 调用的代码:密封内存、入口桩、__chkstk
out/ 工具输出(已 gitignore)
guest context 的布局是通过测量得出的,不是假设的:probe.ctx_map 报告在 store 处实测的 slot 与寄存器对应关系。一个块的标识是 (handler, vip) 这一对,而不只是 handler。
关于输出不声称什么:listing 是 VM 做过什么的记录,不是反编译结果。未解析的 dispatch 会被如实报告为未解析,而不是猜一个。截断的运行会在文件头标记。--caller-entry 产出的是以那个调用者为前提的结果,payload 拒绝把它提升为通用还原。