长文预警!!!
UWP 程序有 .NET Native 可以将程序集编译为本机代码,逆向的难度会大很多;而基于 .NET Framework 和 .NET Core 的程序却没有 .NET Native 的支持。虽然有 Ngen.exe 可以编译为本机代码,但那只是在用户计算机上编译完后放入了缓存中,而不是在开发者端编译。
于是有很多款混淆工具来帮助混淆基于 .NET 的程序集,使其稍微难以逆向。本文介绍 Smart Assembly 各项混淆参数的作用以及其实际对程序集的影响。
本文不会讲 SmartAssembly 的用法,因为你只需打开它就能明白其基本的使用。
感兴趣可以先下载:.NET Obfuscator, Error Reporting, DLL Merging - SmartAssembly。
准备
我们先需要准备程序集来进行混淆试验。这里,我使用 Whitman 来试验。它在 GitHub 上开源,并且有两个程序集可以试验它们之间的相互影响。
额外想吐槽一下,SmartAssembly 的公司 Red Gate 一定不喜欢这款软件,因为界面做成下面这样竟然还长期不更新:
而且,如果要成功编译,还得用上同为 Red Gate 家出品的 SQL Server,如果不装,软件到处弹窗报错。只是报告错误而已,干嘛还要开发者装一个那么重量级的 SQL Server 啊!详见:Why is SQL Server required — Redgate forums。
SmartAssembly
SmartAssembly 本质上是保护应用程序不被逆向或恶意篡改。目前我使用的版本是 6,它提供了对 .NET Framework 程序的多种保护方式:
- 强签名 Strong Name Signing
- 强签名可以确保程序之间的依赖关系是严格确定的,如果对其中的一个依赖进行篡改,将导致无法加载正确的程序集。
- 微软提供了强签名工具,所以可以无需使用 SmartAssembly 的:
- 自动错误上报 Automated Error Reporting
- SmartAssembly 会自动向 exe 程序注入异常捕获与上报的逻辑。
- 功能使用率上报 Feature Usage Reporting
- SmartAssembly 会修改每个方法,记录这些方法的调用次数并上报。
- 依赖合并 Dependencies Merging
- SmartAssembly 会将程序集中你勾选的的依赖与此程序集合并成一个整的程序集。
- 依赖嵌入 Dependencies Embedding
- SmartAssembly 会将依赖以加密并压缩的方式嵌入到程序集中,运行时进行解压缩与解密。
- 其实这只是方便了部署(一个 exe 就能发给别人),并不能真正保护程序集,因为实际运行时还是解压并解密出来了。
- 裁剪 Pruning
- SmartAssembly 会将没有用到的字段、属性、方法、事件等删除。它声称删除了这些就能让程序逆向后代码更难读懂。
- 名称混淆 Obfuscation
- 修改类型、字段、属性、方法等的名称。
- 流程混淆 Control Flow Obfuscation
- 修改方法内的执行逻辑,使其执行错综复杂。
- 动态代理 References Dynamic Proxy
- SmartAssembly 会将方法的调用转到动态代理上。
- 资源压缩加密 Resources Compression and Encryption
- SmartAssembly 会将资源以加密并压缩的方式嵌入到程序集中,运行时进行解压缩与解密。
- 字符串压缩加密 Strings Encoding
- SmartAssembly 会将字符串都进行加密,运行时自动对其进行解密。
- 防止 MSIL Disassembler 对其进行反编译 MSIL Disassembler Protection
- 在程序集中加一个 Attribute,这样 MSIL Disassembler 就不会反编译这个程序集。
- 密封类
- 如果 SmartAssembly 发现一个类可以被密封,就会把它密封,这样能获得一点点性能提升。
- 生成调试信息 Generate Debugging Information
- 可以生成混淆后的 pdb 文件
以上所有 SmartAssembly 对程序集的修改中,我标为 粗体 的是真的在做混淆,而标为 斜体 的是一些辅助功能。
后面我只会说明其混淆功能。
裁剪 Pruning
我故意在 Whitman.Core 中写了一个没有被用到的 internal
类 UnusedClass
,如果我们开启了裁剪,那么这个类将消失。
▲ 没用到的类将消失
特别注意,如果标记了 InternalsVisibleTo
,尤其注意不要不小心被误删了。
名称混淆 Obfuscation
类/方法名与字段名的混淆
名称混淆中,类名和方法名的混淆有三个不同级别:
- 等级 1 是使用 ASCII 字符集
- 等级 2 是使用不可见的 Unicode 字符集
- 等级 3 是使用高级重命名算法的不可见的 Unicode 字符集
需要注意:对于部分程序集,类与方法名(NameMangling)的等级只能选为 3,否则混淆程序会无法完成编译。
字段名的混淆有三个不同级别:
- 等级 1 是源码中字段名称和混淆后字段名称一一对应
- 等级 2 是在一个类中的不同字段使用不同名称即可(这不废话吗,不过 SmartAssembly 应该是为了强调与等级 1 和等级 3 的不同,必须写一个描述)
- 等级 3 是允许不同类中的字段使用相同的名字(这样能够更加让人难以理解)
需要注意:对于部分程序集,字段名(FieldsNameMangling)的等级只能选为 2 或 3,否则混淆程序会无法完成编译。
实际试验中,以上各种组合经常会出现无法编译的情况。
下面是 Whitman 中 RandomIdentifier
类中的部分字段在混淆后的效果:
// Token: 0x04000001 RID: 1
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private int \u0001;
// Token: 0x04000002 RID: 2
private readonly Random \u0001 = new Random();
// Token: 0x04000003 RID: 3
private static readonly Dictionary<int, int> \u0001 = new Dictionary<int, int>();
这部分的原始代码可以在 冷算法:自动生成代码标识符(类名、方法名、变量名) 找到。
如果你需要在混淆时使用名称混淆,你只需要在以上两者的组合中找到一个能够编译通过的组合即可,不需要特别在意等级 1~3 的区别,因为实际上都做了混淆,1~3 的差异对逆向来说难度差异非常小的。
需要 特别小心如果有 InternalsVisibleTo
或者依据名称的反射调用,这种混淆下极有可能挂掉!!!请充分测试你的软件,切记!!!
转移方法 ChangeMethodParent
如果开启了 ChangeMethodParent,那么混淆可能会将一个类中的方法转移到另一个类中,这使得逆向时对类型含义的解读更加匪夷所思。
排除特定的命名空间
如果你的程序集中确实存在需要被按照名称反射调用的类型,或者有 internal
的类/方法需要被友元程序集调用,请排除这些命名空间。
流程混淆 Control Flow Obfuscation
列举我在 Whitman.Core 中的方法:
public string Generate(bool pascal)
{
var builder = new StringBuilder();
var wordCount = WordCount <= 0 ? 4 - (int) Math.Sqrt(_random.Next(0, 9)) : WordCount;
for (var i = 0; i < wordCount; i++)
{
var syllableCount = 4 - (int) Math.Sqrt(_random.Next(0, 16));
syllableCount = SyllableMapping[syllableCount];
for (var j = 0; j < syllableCount; j++)
{
var consonant = Consonants[_random.Next(Consonants.Count)];
var vowel = Vowels[_random.Next(Vowels.Count)];
if ((pascal || i != 0) && j == 0)
{
consonant = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(consonant);
}
builder.Append(consonant);
builder.Append(vowel);
}
}
return builder.ToString();
}
▲ 这个方法可以在 冷算法:自动生成代码标识符(类名、方法名、变量名) 找到。
流程混淆修改方法内部的实现。为了了解各种不同的流程混淆级别对代码的影响,我为每一个混淆级别都进行反编译查看。
▲ 没有混淆
0 级流程混淆
▲ 0 级流程混淆
1 级流程混淆
▲ 1 级流程混淆
可以发现 0 和 1 其实完全一样。又被 SmartAssembly 耍了。
2 级流程混淆
2 级流程混淆代码很长,所以我没有贴图:
// Token: 0x06000004 RID: 4 RVA: 0x00002070 File Offset: 0x00000270
public string Generate(bool pascal)
{
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2;
if (-1 !=