起因

最近项目要搞代码加密,4.0+ 的 ET 非常不适合做加密方案,研究了很久,最后找到如下方案

这篇文章会包含这些天加密的尝试及最终解决方案

PS:我的环境是 MacOSWindows 部分代码可能无法运行,要自己解决

AES 加密 Hotfix.dll + Unity Security 加密

方案介绍

Unity Security 加密是 Unity 新增的 代码加密功能,可以直接在 Player Setting 中打开

  • 通过对 Hotfix.dll.bytes 文件进行热更层加密
    • AES 加密写在 Model 层
  • 通过打开 Security 设置,对非热更层进行加密

这个方案也是我最开始的思路,但是直到我一步步去查看包体中的内容时,发现了这个方案有一个致命的问题

存在的问题

首先我们将打出来的安卓包体重命名为 xxx.rar ,解压之后,我们可以在下面的路径中找到游戏用到的 dll 文件

ET 4.0+ 通过使用 Assembly Definition 来区分 EditorHotfixModelThirdParty,这些 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 文件,注意需要自己生成对应的 keyivsalt

不知道如何生成的可以参考下面的命令

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($"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($"当前使用的是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($"当前使用的是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 效果如下

绕了一大圈终于把这个加密的坑填上了

最后

珍爱生命,远离热更


What doesn’t kill you makes you stronger.