.NET 程序保护实战系列 05 · 代码虚拟化

05 · 代码虚拟化:把 IL 变成只有你能懂的字节码

目录

  1. [虚拟化 vs 加密:两种方法保护的哲学差异](#虚拟化 vs 加密:两种方法保护的哲学差异)
  2. [架构概览:从 IL 到 VM 字节码](#架构概览:从 IL 到 VM 字节码)
  3. [Token 名称反射:解耦 VM 与模块元数据](#Token 名称反射:解耦 VM 与模块元数据)
  4. 方法选择:哪些方法适合虚拟化
  5. 字节码序列化与加密
  6. [ManifestResource 存储:魔术头扫描定位](#ManifestResource 存储:魔术头扫描定位)
  7. 运行时:栈式解释器逐条执行
  8. 性能考量
  9. 结语

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 使用名称反射绕过元数据依赖:

csharp 复制代码
// 不存储 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 可以精确指定需要虚拟化的方法名:

bash 复制代码
--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(线性同余生成器):

csharp 复制代码
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 等工具的固定资源名称不同,我们使用魔术头扫描

csharp 复制代码
// 运行时 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) 的核心是基于栈的解释器

csharp 复制代码
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 驱动。完整源码和工具请访问项目主页。