起因
最近项目要搞代码加密,4.0+ 的
ET
非常不适合做加密方案,研究了很久,最后找到如下方案
这篇文章会包含这些天加密的尝试及最终解决方案
PS:我的环境是 MacOS
,Windows
部分代码可能无法运行,要自己解决
AES 加密 Hotfix.dll + Unity Security 加密
方案介绍
Unity Security 加密是 Unity 新增的 代码加密功能,可以直接在 Player Setting 中打开
- 通过对
Hotfix.dll.bytes
文件进行热更层加密- AES 加密写在 Model 层
- 通过打开 Security 设置,对非热更层进行加密
这个方案也是我最开始的思路,但是直到我一步步去查看包体中的内容时,发现了这个方案有一个致命的问题
存在的问题
首先我们将打出来的安卓包体重命名为 xxx.rar
,解压之后,我们可以在下面的路径中找到游戏用到的 dll
文件
ET 4.0+ 通过使用 Assembly Definition
来区分 Editor
、Hotfix
、Model
及 ThirdParty
,这些 dll 在打包的时候,都会统一打包进包体,这样即使我们加密了 Hotfix.dll.bytes
文件,在打包时,工程文件中仍然包含所有的非加密 dll
信息
而且更严重的是,Unity Security 并不会对
Assembly Definition
中的代码进行加密
意味着,你打包时 Hotfix 的代码和非热更层代码中对 Hotfix.dll.bytes
AES 加密的 key 也像明文一样暴露在外面
混淆方案
方案介绍
在出现上面的问题后,我立刻想到了混淆方案,使用 Unity商城中
Obfuscator
插件对代码进行混淆
Obfuscator
中支持对 Assembly Definition
的混淆,最开始让我觉得找到了救世主,可以通过这个插件对 Model
部分的代码进行混淆,保证 AES key
不会轻易被破解者找到,混淆效果如下
这个时候还需要解决
Hotfix
文件不会打入到包体中
Unity
中有一个隐藏文件的功能,通过对文件夹命名,尾部加上 ~
,这样Unity
会自动忽略这个文件夹,如此一来 Hotfix
也完美的避开了会被明文打入包体的命运
当初这里我自己写了一份完整的
Build
,大致意思是,当Build
之前,先检查项目中有没有Hotfix
文件夹,如果有则重命名为Hotfix~
,为了方便Editor Play
,还增加了监听Editor Play
事件,当开启时,检测Hotfix
文件夹是否存在,如果不存在,则重命名
存在的问题
写到这我以为差不多了,打包时,
Hotfix.dll
文件也没有被打进去,Model.dll
也妥妥的被混淆了,代码连我自己都看不懂啥意思
但是!这个混淆会导致 CLR Binding Code
出问题,我们先看一下生成的 Hotfix
绑定代码
ILRuntime
会根据函数名通过反射对代码进行绑定,这个就要命了,我们生成的混淆代码是一堆乱码,这样真机运行的时候会不停的报 Null Error
我尝试了通过手动控制
Obfuscator
混淆时机,然后发现了一个套娃的情况
手动控制混淆时机时,会导致生成的 CLR Binding Code
为混淆后的代码,会出现语法错误导致无法 Buid
我自己的控制顺序如下:
- 先对代码进行混淆
- 对非加密的
Hotfix.dll
生成CLR Bingding Code
- 最后导出包体
所以这个方案也是 Dead End
最终方案
最后我选择采用了
ET 3.0
时期的做法,将Hotfix
层所有代码从Unity
项目中完全剥离出来,并完全弃用Assembly Definition
这样会很不方便,意味着每次都要手动 Build Hotfix
,放弃现在 Hotfix
自动导入到项目并更新的流程 T^T
这样非热更层享受 Unity Security
加密,热更层享受 AES
加密,我们加密用的 key
就会安全的多,破解门槛也会高很多
实现步骤
首先删除所有 Assembly Definition
分离 Hotfix
创建
在 IDE
中创建 Hotfix
项目
我项目的层级目录如下,因为没有使用 ET Server
部分的功能,所以精简了很多内容,创建 Hotfix
项目时可以参考
.
├── Assembly-CSharp-Editor-firstpass.csproj
├── Assembly-CSharp-Editor.csproj
├── Assembly-CSharp-firstpass.csproj
├── Assembly-CSharp.csproj
├── Assets
├── CHANGELOG.md
├── Client.sln
├── Client.sln.DotSettings.user
├── Hotfix
├── Library
├── Logs
├── Packages
├── ProjectSettings
├── ServerData
├── Temp
├── TestData
├── Tools
├── node_modules
├── obj
├── package-lock.json
└── package.json
项目的具体设置请按照下面的提示进行设置
这个时候补全 ET 3.0
时期的 After Build
逻辑,打开 Hotfix.csproj
文件进行编辑,在最后的 </Project>
之前增加如下代码
这段代码的意思是,当项目Build结束时,自动将
dll
文件复制到指定的目录下,CodeForIL
文件夹是方便生成CLR Binding Code
使用的非加密代码
<Target Name="AfterBuild">
<Copy SourceFiles="$(OutDir)$(TargetName).dll" DestinationFiles="$(ProjectDir)/../Assets/Res/CodeForIL/$(TargetName).dll.bytes" />
<Copy SourceFiles="$(OutDir)$(TargetName).pdb" DestinationFiles="$(ProjectDir)/../Assets/Res/CodeForIL/$(TargetName).pdb.bytes" />
</Target>
增加引用
增加引用的部分就不多做介绍了,ET
的一些老文章中有很多比较全的介绍
工具编写
编译
我这里为了保证每次打出来的 Hotfix.dll
文件均为 Release
,写了一个 shell
文件,方便 Unity
调用
不过目录层级需要自己更改,这里的
cd
只适用我的项目
#!/bin/bash
cd ../../Hotfix/
/Library/Frameworks/Mono.framework/Versions/Current/Commands/xbuild /p:Configuration=Release Hotfix.csproj
导入工具
这里我们要弃用之前 Startup.cs
中自动导入 Hotfix.dll.bytes
文件的逻辑,具体实现逻辑不做更多的介绍,仅提供一个参考
这里包含加密逻辑,后面会有提及
public class Startup {
private const string ScriptAssembliesDir = "Assets/Res/CodeForIL/";
private const string CodeDir = "Assets/Res/Code/";
private const string HotfixDll = "Hotfix.dll.bytes";
private const string HotfixPdb = "Hotfix.pdb.bytes";
// static Startup() {
// if(!File.Exists(ScriptAssembliesDir + HotfixDll) || !File.Exists(ScriptAssembliesDir + HotfixPdb)){
// return;
// }
//
// SyncHotfix();
// }
[MenuItem("Tools/Build Hotfix #F3", priority = 103)]
public static void BuildHotfix() { ProcessHelper.Run("/bin/sh", "BuildHotfix.sh", "./Tools/AutoBuild/"); }
[MenuItem("Tools/SyncHotfix #F4", priority = 104)]
public static void SyncHotfix() {
Save(HotfixDll);
Save(HotfixPdb);
// File.Copy(Path.Combine(ScriptAssembliesDir, HotfixDll), Path.Combine(CodeForIL, "Hotfix.dll.bytes"), true);
Log.Info("复制Hotfix.dll, Hotfix.pdb到Res/Code完成");
AssetDatabase.Refresh();
}
private static void Save(string fileName) {
string originPath = Path.Combine(ScriptAssembliesDir, fileName);
string savePath = Path.Combine(CodeDir, fileName);
byte[] bytes;
using(FileStream fs = new FileStream(originPath, FileMode.Open)){
int len = (int) fs.Length;
bytes = new byte[len];
fs.Read(bytes, 0, len);
}
bytes = AESHelper.AESEncrypt(bytes);
if(File.Exists(savePath)){ File.Delete(savePath); }
using(FileStream fs = new FileStream(savePath, FileMode.Create)){
fs.Write(bytes, 0, bytes.Length);
fs.Flush();
}
}
}
加密工具
创建 AESHelper.cs
文件,注意需要自己生成对应的 key
、iv
和 salt
不知道如何生成的可以参考下面的命令
openssl rand -base64 128
public static class AESHelper {
private static string _key = "xxxxxxxxxxxxxxxxxxxxxxxx";
private static string _iv = "xxxxxxxxxxxxxxxxxxxxxxxx";
private static string _salt = "xxxxxxxxxxxxxxxxxxxxxxxx";
public static string AESEncrypt(string encryptString) {
return Convert.ToBase64String(AESEncrypt(Encoding.Default.GetBytes(encryptString)));
}
public static byte[] AESEncrypt(byte[] EncryptByte) {
if(EncryptByte.Length == 0){ throw new Exception("明文不得为空"); }
if(string.IsNullOrEmpty(_key)){ throw new Exception("密钥不得为空"); }
byte[] m_strEncrypt;
byte[] m_btIV = Convert.FromBase64String(_iv);
byte[] m_salt = Convert.FromBase64String(_salt);
Rijndael m_AESProvider = Rijndael.Create();
try{
MemoryStream m_stream = new MemoryStream();
PasswordDeriveBytes pdb = new PasswordDeriveBytes(_key, m_salt);
ICryptoTransform transform = m_AESProvider.CreateEncryptor(pdb.GetBytes(32), m_btIV);
CryptoStream m_csstream = new CryptoStream(m_stream, transform, CryptoStreamMode.Write);
m_csstream.Write(EncryptByte, 0, EncryptByte.Length);
m_csstream.FlushFinalBlock();
m_strEncrypt = m_stream.ToArray();
m_stream.Close();
m_stream.Dispose();
m_csstream.Close();
m_csstream.Dispose();
}
catch(IOException ex){ throw ex; }
catch(CryptographicException ex){ throw ex; }
catch(ArgumentException ex){ throw ex; }
catch(Exception ex){ throw ex; }
finally{ m_AESProvider.Clear(); }
return m_strEncrypt;
}
public static string AESDecrypt(string DecryptString) {
return Convert.ToBase64String(AESDecrypt(Encoding.Default.GetBytes(DecryptString)));
}
public static byte[] AESDecrypt(byte[] DecryptByte) {
if(DecryptByte.Length == 0){ throw new Exception("密文不得为空"); }
if(string.IsNullOrEmpty(_key)){ throw new Exception("密钥不得为空"); }
byte[] m_strDecrypt;
byte[] m_btIV = Convert.FromBase64String(_iv);
byte[] m_salt = Convert.FromBase64String(_salt);
Rijndael m_AESProvider = Rijndael.Create();
try{
MemoryStream m_stream = new MemoryStream();
PasswordDeriveBytes pdb = new PasswordDeriveBytes(_key, m_salt);
ICryptoTransform transform = m_AESProvider.CreateDecryptor(pdb.GetBytes(32), m_btIV);
CryptoStream m_csstream = new CryptoStream(m_stream, transform, CryptoStreamMode.Write);
m_csstream.Write(DecryptByte, 0, DecryptByte.Length);
m_csstream.FlushFinalBlock();
m_strDecrypt = m_stream.ToArray();
m_stream.Close();
m_stream.Dispose();
m_csstream.Close();
m_csstream.Dispose();
}
catch(IOException ex){ throw ex; }
catch(CryptographicException ex){ throw ex; }
catch(ArgumentException ex){ throw ex; }
catch(Exception ex){ throw ex; }
finally{ m_AESProvider.Clear(); }
return m_strDecrypt;
}
}
修改加载流程
在 Hotfix.cs
文件中在加载代码文件时,对代码文件进行解密
这里我们捕获
BadImageFormatException
当有人视图篡改我们热更代码时,给他们弹出一个Nice Try
弹窗
public async ETTask LoadHotfixAssembly() { try{ TextAsset assText = await Addressables.LoadAssetAsync<TextAsset>("Hotfix.dll").Task; TextAsset pdbText = await Addressables.LoadAssetAsync<TextAsset>("Hotfix.pdb").Task; byte[] assBytes = AESHelper.AESDecrypt(assText.bytes); byte[] pdbBytes = AESHelper.AESDecrypt(pdbText.bytes); // byte[] pdbBytes = pdbText.bytes; // byte[] assBytes = assText.bytes; // Game.Scene.GetComponent<ResourcesComponent>().LoadBundle(
quot;code.unity3d");
// GameObject code = (GameObject)Game.Scene.GetComponent<ResourcesComponent>().GetAsset("code.unity3d", "Code");
//
// byte[] assBytes = code.Get<TextAsset>("Hotfix.dll").bytes;
// byte[] pdbBytes = code.Get<TextAsset>("Hotfix.pdb").bytes;#if ILRuntime
Log.Debug(quot;当前使用的是ILRuntime模式");
appDomain = new AppDomain();dllStream = new MemoryStream(assBytes);
pdbStream = new MemoryStream(pdbBytes);
appDomain.LoadAssembly(dllStream, pdbStream, new PdbReaderProvider());start = new ILStaticMethod(
appDomain,
"ETHotfix.Init",
"Start",
0
);
hotfixTypes = appDomain.LoadedTypes.Values.Select(x => x.ReflectionType).ToList();
#else
Log.Debug(quot;当前使用的是Mono模式");
assembly = Assembly.Load(assBytes, pdbBytes);
Type hotfixInit = assembly.GetType("ETHotfix.Init");
start = new MonoStaticMethod(hotfixInit, "Start");hotfixTypes = assembly.GetTypes().ToList();
#endif
}
catch(BadImageFormatException e){
UIUtils.PopupError("Nice Try");
Log.Error(e);
}
catch(Exception e){ Log.Error(e); }
}
修改 CLR Binding
原有的代码读取的是加密过的
dll
文件,最初的CodeForIL
文件夹就是为了CLR Binding
准备的[MenuItem("Tools/ILRuntime/Generate CLR Binding Code by Analysis")] public static void GenerateCLRBindingByAnalysis() { GenerateCLRBinding(); //用新的分析热更dll调用引用来生成绑定代码 ILRuntime.Runtime.Enviorment.AppDomain domain = new ILRuntime.Runtime.Enviorment.AppDomain(); using(FileStream fs = new FileStream("Assets/Res/CodeForIL/Hotfix.dll.bytes", FileMode.Open, FileAccess.Read)){ domain.LoadAssembly(fs); //Crossbind Adapter is needed to generate the correct binding code ILHelper.InitILRuntime(domain); ILRuntime.Runtime.CLRBinding.BindingCodeGenerator.GenerateBindingCode(domain, "Assets/Model/ILBinding"); AssetDatabase.Refresh(); } }
最终效果
因为我们抛弃了
Assembly Definition
所以最终包体内不会看到我们自己的代码文件,所有非热更层的代码全在Assembly-CSharp.dll
中用
ILSpy
打开效果如下
加密后的
Hotfix.dll
效果如下
绕了一大圈终于把这个加密的坑填上了
最后
珍爱生命,远离热更