.NET 程序保护实战系列 05 · 代码虚拟化
05 · 代码虚拟化:把 IL 变成只有你能懂的字节码
目录
- 虚拟化 vs 加密:两种方法保护的哲学差异
- 架构概览:从 IL 到 VM 字节码
- Token 名称反射:解耦 VM 与模块元数据
- 方法选择:哪些方法适合虚拟化
- 字节码序列化与加密
- ManifestResource 存储:魔术头扫描定位
- 运行时:栈式解释器逐条执行
- 性能考量
- 结语
1. 虚拟化 vs 加密:两种方法保护的差异
方法体加密在第 06 篇详细讲解。这里先做对比:
| 方法体加密 | 代码虚拟化 | |
|---|---|---|
| 原理 | 加密 IL → 运行时 DynamicMethod 解密执行 | 转换 IL 为 VM 字节码 → 运行时解释器执行 |
| 性能 | 首次调用慢(解密+DynamicMethod 创建),后续正常 | 每次调用都通过解释器,约 50-100x 慢 |
| 安全性 | 解密后在内存中恢复原始 IL | 原始 IL 永远不会出现在内存中 |
| 适用 | 大部分方法 | 核心算法(几 KB 的代码) |
2. 架构概览:从 IL 到 VM 字节码
原始方法:
IL: ldc.i4.1, ldc.i4.2, add, stloc.0, ret
虚拟化过程:
1. 提取方法 IL 字节
2. 提取 local 签名
3. 提取异常处理器
4. 为每个 token 构建实体信息(名称反射)
5. 序列化为 VM 字节码
6. LCG 加密(魔术头 0x564D4243 = "VMBC")
7. 替换方法体为 stub:
→ newobj object[]
→ ldarg (参数压入数组)
→ ldtoken
→ call VirtualMachine.Invoke(token, args)
→ ret
8. 添加加密数据为 ManifestResource
3. Token 名称反射:解耦 VM 与模块元数据
IL 中的 call 0x0A000001(MethodDef token)在保护后模块中已不存在(方法体已删除)。VM 使用名称反射绕过元数据依赖:
// 不存储 token 数字
// 而是存储实体信息字符串:
"System.Math::Abs(double)"
"System.Console::WriteLine(string)"
"MyNamespace.MyClass::.ctor()"
"MyNamespace.MyClass::MyField"
// 运行时通过反射解析:
Type.GetType("MyNamespace.MyClass, MyAssembly")
Type.GetMethod("MyMethod", BindingFlags.Instance | ...)
关键设计:对每种 token 类型(Method / Field / Type / MemberRef / MethodSpec)生成不同的实体信息格式,确保解析精确。
4. 方法选择:哪些方法适合虚拟化
自动跳过不适合虚拟化的方法:
| 条件 | 原因 |
|---|---|
含 endfinally / fault |
异常处理终结指令不支持 |
含 ldftn / ldvirtftn |
函数指针无法通过解释器传递 |
| 泛型方法 | 类型参数在运行时才确定 |
<Module> 类型方法 |
全局初始化依赖 |
| 抽象方法 / P/Invoke | 无方法体 |
| 指令数 < 5 | 成本大于收益 |
通过 --method-names 可以精确指定需要虚拟化的方法名:
--method-mode Virtualize --method-names "Encrypt;Decrypt;SignData"
5. 字节码序列化与加密
序列化格式:
[4 bytes] Magic: 0x564D4243
[4 bytes] TokenCount
[foreach method]:
[4 bytes] MethodToken
[4 bytes] LocalSigLength
[N bytes] LocalSig
[4 bytes] ILCodeLength
[N bytes] ILCode
[4 bytes] EHCount
[foreach EH]:
[4 bytes] Flags
[4 bytes] TryOffset / TryLength
[4 bytes] HandlerOffset / HandlerLength
[4 bytes] CatchTypeToken (or 0)
加密使用 LCG(线性同余生成器):
uint state = (uint)Environment.TickCount;
for (int i = 0; i < data.Length; i++)
{
state = state * 214013 + 2531011;
data[i] ^= (byte)(state >> 16);
}
密钥嵌入在 VirtualMachine.Initialize() 方法中(通过 Mutation 占位符替换)。
6. ManifestResource 存储:魔术头扫描定位
与 TMD/Modi 等工具的固定资源名称不同,我们使用魔术头扫描:
// 运行时 Initialize() 方法:
foreach (var res in module.GetManifestResourceNames())
{
using var stream = module.GetManifestResourceStream(res);
var header = new byte[4];
stream.Read(header, 0, 4);
if (header[0] == 0x56 && header[1] == 0x4D && header[2] == 0x42 && header[3] == 0x43)
{
// 找到虚拟化数据!
ProcessData(stream);
break;
}
}
不依赖资源名称 = 攻击者无法通过名称判断哪些资源是加密的方法体。
7. 运行时:栈式解释器逐条执行
VirtualMachine.Invoke(uint token, object[] args) 的核心是基于栈的解释器:
object[] stack = new object[maxStack];
int sp = 0; // 栈指针
// 加载局部变量和参数
for (int i = 0; i < args.Length; i++)
stack[sp++] = args[i];
while (true)
{
byte op = code[ip++];
switch (op)
{
case 0x00: /* nop */ break;
case 0x01: /* break */ break;
case 0x16: /* ldc.i4.0 */ stack[sp++] = (int)0; break;
case 0x17: /* ldc.i4.1 */ stack[sp++] = (int)1; break;
// ... 200+ 条指令 ...
case 0x6F: /* callvirt */ HandleCallVirt(); break;
}
}
每次方法调用都要经过解释器,性能显著下降——但安全性大幅提升。
8. 性能考量
| 指标 | 原生 IL | 虚拟化 |
|---|---|---|
| 单次调用 | ~10ns | ~500-1000ns |
| 内存 | 正常 | +2KB(VM 元数据) |
| 适用方法长度 | 任意 | 建议 < 500 指令 |
建议只虚拟化核心算法(如加密/解密、许可证验证、序列化逻辑),不要虚拟化 UI 事件处理器。
9. 结语
代码虚拟化提供了最高级别的保护——原始 IL 从不在内存中以明文出现。虽然性能有损耗,但对于关键的商业秘密算法,这是值得投入的。
下一篇将讲解方法体加密——另一种方法保护策略,在安全性和性能之间取得了更好的平衡。
本文由 TWSoft.AssemblyProtector 驱动。完整源码和工具请访问项目主页。

浙公网安备 33010602011771号