Qiita初投稿ですが非常にニッチな記事を書かせていただきます。
よろしくお願いします。
筆者の開発経験
- C# 10年以上
- Unity 7年以上
Unityの最適化と暗号化と難読化を独学で7年かけて試行錯誤してきた筆者が考える暗号化・難読化テクニックをご紹介いたします。
最適化は以下の記事で紹介しています。
想定している読者
- AssetBundleの画像や3Dモデルを第三者に抽出されたくない人
- ビルド資産に含まれるAssembly-CSharp.dllなどのスクリプトを解析されたくない人
- 普通の暗号化や難読化の手法では満足できない人
(ここまでする必要ある?というレベルのディープなテクニックを本記事ではご紹介いたします) - 難読化テクニックだけ知りたいという方すみません、難読化に役立つ知識が暗号化側でも頻出しているので、お時間のあるときに上から順にお読みください…。
結論
先に本記事の結論を述べさせていただきますと、どんなに強固な暗号化を施しても復号できる人は復号できますし、難読化にも限界があります。
本記事でご紹介させていただくテクニックも所詮は解析や抽出の難易度をあげているだけにすぎず、資産を100%完全に守りきることを目的としていません。
解析手法をググって上位にヒットするような有名どころの手法やツールを使ってくる攻撃者を弾ければ御の字とします。機械語が読めちゃう変態さんの魔の手は防げません。
その点はご承知おきください…。
1. 暗号化
Unityの資産を守るための一番スタンダードな手段といえば暗号化でしょう。
本章ではまず一般的な暗号化をご紹介した後、筆者なりに極めた暗号化をご紹介いたします。
ただし暗号化はついでです。暗号化は突破されなければ強いですが突破されたら終わりです。
なので筆者としては暗号化よりも難読化が本命ですが、その前座として暗号化の知識も必要なのでご一読いただければと思います。
1.1. 一般的な暗号化
まずは一般的な暗号化手法です。
ここでは主にAssetBundleの暗号化について考えます。
恐らく最も攻撃の標的となるのがAssetBundleです。画像データや3Dモデルデータに音声データなどが詰まっているAssetBundleは攻撃者にとって宝の山です。ソシャゲでは大抵AssetBundleが使われており、最近のソシャゲのAssetBundleは大体暗号化されています。
やはりオーソドックスな暗号化アルゴリズムといえばAESでしょう。
UnityでビルドしたAssetBundleを暗号化します。
private RijndaelManaged CreateAes(string key, string salt)
{
RijndaelManaged aes = new RijndaelManaged();
using (Rfc2898DeriveBytes rfc2898DeriveBytes = new Rfc2898DeriveBytes(key, Encoding.UTF8.GetBytes(salt), 1000))
{
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.KeySize = 128;
aes.BlockSize = 128;
aes.Key = rfc2898DeriveBytes.GetBytes(aes.KeySize / 8);
aes.IV = rfc2898DeriveBytes.GetBytes(aes.BlockSize / 8);
return aes;
}
}
private byte[] Encrypt(byte[] plainTextBytes, string key, string salt)
{
using (RijndaelManaged aes = this.CreateAes(key, salt))
{
using (MemoryStream memoryStream = new MemoryStream())
using (ICryptoTransform transform = aes.CreateEncryptor())
using (CryptoStream cryptoStream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Write))
{
using (BinaryWriter binaryWriter = new BinaryWriter(cryptoStream))
{
binaryWriter.Write(plainTextBytes);
}
return memoryStream.ToArray();
}
}
}
byte[] encrypted = this.Encrypt(File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, "hogefile")), "hogehogekey", "hogesalt");
File.WriteAllBytes(Path.Combine(Application.streamingAssetsPath, "hogefile_enc"), encrypted);
暗号化を行うと以下のようになります。
左が暗号化前で、右が暗号化後です。
これで復号化するまでAssetBundleとして使うことができなくなりました。
この暗号化済みAssetBundleをUnityで読み込む際に復号化処理を挟みます。
private byte[] Decrypt(byte[] cipherTextBytes, string key, string salt)
{
using (RijndaelManaged aes = this.CreateAes(key, salt))
using (MemoryStream memoryStream = new MemoryStream(cipherTextBytes))
using (ICryptoTransform transform = aes.CreateDecryptor())
using (CryptoStream cryptoStream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Read))
using (BinaryReader binaryReader = new BinaryReader(cryptoStream))
{
byte[] array = new byte[cipherTextBytes.Length];
int newSize = binaryReader.Read(array, 0, array.Length);
Array.Resize(ref array, newSize);
return array;
}
}
byte[] decrypted = this.Decrypt(File.ReadAllBytes(Path.Combine(Application.streamingAssetsPath, "hogefile")), "hogehogekey", "hogesalt");
AssetBundleCreateRequest request = AssetBundle.LoadFromMemoryAsync(decrypted);
while (request.isDone == false) await UniTask.Yield();
筆者が最初にAssetBundleの暗号化をやり始めたころのソースコードが大体こんな感じです。
つまりダメダメです。問題だらけです。
1.2. AssetBundle.LoadFromMemory ダメ絶対
暗号化する前にまず考えなければいけないのが、AssetBundleの読み込み方です。
AssetBundleをUnityで読み込む方法は、主に次の3つの選択肢があります。
AssetBundle.LoadFromFile
ファイルパスを直接指定してAssetBundleを読み込みます。パフォーマンスは最速かつ省メモリですが、復号化などの中間処理を挿入できません。
AssetBundle.LoadFromMemory
予めメモリに読み込んだAssetBundleのバイナリデータを読み込みます。前述のAES暗号化ではこちらを使っています。復号化のような複雑な処理を噛ませることができますが、通常の倍以上のメモリを消費するため使いたくありません。
AssetBundle.LoadFromStream
AssetBundleのバイナリデータを読み込めるStreamを介してAssetBundleを読み込みます。AssetBundleはネイティブ空間に直接読み込まれるためメモリに優しいですが、シーク可能なStreamである必要があります。
それぞれの読み込み方のメモリ管理の違い
前述の説明文でもパフォーマンスについて軽く触れていますが、図で表すと以下のような違いがあります。
AssetBundleから読み込んだアセットはネイティブメモリに載りますが、LoadFromMemoryの引数に渡すバイト配列はマネージドメモリに載ってしまいます。
このバイト配列はAssetBundleを解放するまでメモリ上に残り続けます。つまり、アセットのデータがネイティブメモリとマネージドメモリの両方に載った状態が続くことになるため通常の倍以上のメモリを使うことになります。
AssetBundle.LoadFromStreamを使おう
ということで(LoadFromMemoryはパフォーマンスが最悪なので論外)(LoadFromFileは復号化ロジックを使えない)、LoadFromStream一択なのでこれを使います。
ただしLoadFromStreamは前述の通りシーク可能なStreamである必要があります。
これがどういうことかというと、C#標準搭載の暗号化StreamであるCryptoStreamはシーク不可能のため使えません。
なのでシーク可能なStreamを自前で実装する必要があります。これに関しては先駆者のSeekableAesStreamが参考になると思います。
これを使えばメモリを労わりつつ復号化しながらAssetBundleが読み込めるので万事解決です。以上、お疲れさまでした。
1.3. 一般的な暗号化では満足できない理由
それでは上のロジックを取り入れて完成したゲームのAssembly-CSharp.dllを逆コンパイルしてみましょう。
有名なリバースエンジニアリングソフトはILSpyですね。
はい。復号化のロジックを全部復元できました。パスワードの文字列もがっつり復元されています。
このようにビルド資産のdllファイルに含まれるソースコードや文字列のような値などは簡単に復元できます。
1.3.1. IL2CPPなら解析されづらいけど…
勿論逆コンパイルをある程度防ぐ手もあります。従来はMonoビルドと呼ばれるものが主流で、これはC#のソースコードをILと呼ばれる中間言語にコンパイルします。
詳細は割愛しますが、中間言語の状態だと簡単に逆コンパイルができてソースコードを容易く復元できてしまいます。
一方でIL2CPPビルドを選択すれば、C#のソースコードはC++を介してネイティブコードに変換されます。こうなると逆コンパイルは完全にはできなくなります。
完全にはできませんが、部分的には復元できます。
Il2CppDumperを使えばリテラル(文字列や数値など)は全てダンプできますし、フィールド一覧やメソッド一覧も抽出できます。もう少し頑張れば処理の流れもある程度は追えるようになるらしいですが、あれはほぼ機械語を読むようなものなので相当な手練れでないとロジックの解析は難しいでしょう。
1.3.2. IL2CPPの解析対策
ここまでで見えてきたIL2CPPの問題は、
- 文字列や数値などのリテラルは抜かれる
- フィールド一覧やメソッド一覧も抽出される
- 滅茶苦茶頑張られると処理の流れも追われる
です。
暗号化済みAssetBundleを100%守り切るためには3つとも阻止しなくてはいけません。
ですが最初に述べた通り本記事では100%は諦めています。長年かけて暗号化手法を試行錯誤してきましたが、結局はイタチごっこで解析技術も日進月歩なので解析のプロからは守り切れません。
なのでせめてIl2CppDumperを用いた解析攻撃は守り切りたいところです。
1.4. ディープな暗号化
1.4.1. リテラルぶっこ抜き対策
まず解決すべき問題は
- 文字列や数値などのリテラルは抜かれる
です。
リテラルがぶっこ抜かれてしまう理屈ですが、Monoビルドのdllを逆コンパイルしたものを見てみると、dllに含まれる文字列の一覧が思いっきりありました。
Monoビルドではdllファイルのメタデータから文字列一覧の抽出が可能なようです。
ではIL2CPPビルドではどうでしょう。
IL2CPPビルドした資産をIl2CppDumperにかけてみました。
E:.
│ dump.cs
│ il2cpp.h
│ script.json
│ stringliteral.json
│
└─DummyDll
Assembly-CSharp.dll
Il2CppDummyDll.dll
Mono.Security.dll
mscorlib.dll
System.Configuration.dll
System.Core.dll
System.dll
System.Xml.dll
UniTask.Addressables.dll
UniTask.dll
UniTask.DOTween.dll
UniTask.Linq.dll
UniTask.TextMeshPro.dll
Unity.Burst.dll
……
これらのファイルが出力されました。
この中でstringliteral.jsonがメタデータから抽出された文字列一覧に相当するので、復号化キーを探してみるとやはり含まれていました。
いかにも復号化キーっぽいものは目視でも特定できてしまいそうですし、そうでなくとも気力のある攻撃者なら総当たりで復号化キーを特定できそうです。
なので直接的な対策手法は、復号化に必要なパスワードをこのメタデータに含めないことになります。
それがC#のソースコード上にどう表れるかというと、まず文字列のパスワードは当然使えません。
Unityアセットストアの暗号化ツールの中には文字列をバイト配列の状態で保持しておき必要な時だけ文字列に復元するモノもありますが、結局は文字列になった瞬間を狙われてダンプされてお終いです。
他にも色々選択肢はあるかもしれませんが、結局のところバイト配列を終始パスワードとして使うという結論に至りました。
また、ソースコード内にバイト配列の変数を忍ばせておくとやはりダンプされる心配があるため、パスワード代わりのダミーファイルを用意しておきます。
private void CreateDummyFile()
{
System.Random rand = new System.Random();
byte[] dummyBin = new byte[29403];
for (int i = 0; i < dummyBin.Length; i++)
{
dummyBin[i] = (byte)rand.Next(0, 255);
}
using (FileStream fs = new FileStream(@"C:\dummy.unity3d", FileMode.Create, FileAccess.Write))
{
fs.Write(dummyBin, 0, dummyBin.Length);
}
}
ダミーファイルのポイントは、暗号化済みAssetBundleに紛れ込ませる前提で作ることです。
- ファイルサイズを無個性かつ他のAssetBundleと近い値にする(上の例では29403)
- ファイルの内容は暗号化済みAssetBundleと区別がつかないものにする(AES暗号化させるならダミーファイルの中身はひたすら(byte)rand.Next(0, 255)でよい)
ダミーファイルを絡めて暗号化・復号化を行うSeekableAesStreamのロジックがこちらです。(一部抜粋)
void Cipher(byte[] buffer, int offset, int count, long streamPos, byte[] dummyData, bool isRead)
{
var dummyDataSize = dummyData.Length;
var blockSizeInByte = _aes.BlockSize / 8;
var blockNumber = (streamPos / blockSizeInByte) + 1;
var keyPos = streamPos % blockSizeInByte;
var outBuffer = new byte[blockSizeInByte];
var nonce = new byte[blockSizeInByte];
var init = false;
for (var i = offset; i < count; i++)
{
if (!init || (keyPos % blockSizeInByte) == 0)
{
BitConverter.GetBytes(blockNumber).CopyTo(nonce, 0);
_encryptor.TransformBlock(nonce, 0, nonce.Length, outBuffer, 0);
if (init) keyPos = 0;
init = true;
blockNumber++;
}
if (isRead) buffer[i] ^= dummyData[(int)(streamPos + i) % dummyDataSize];
buffer[i] ^= outBuffer[keyPos];
if (!isRead) buffer[i] ^= dummyData[(int)(streamPos + i) % dummyDataSize];
keyPos++;
}
}
暗号化するときはisReadをfalse、復号化時はtrueにします。
ダミーファイルから取得したダミーデータはdummyDataに渡します。
このロジックでは、暗号化時は AES暗号化 → ダミーファイルとのXOR の順で行い、復号化時は ダミーファイルとのXOR → AES暗号化 を行っています。
(この例だと厳密にはAES暗号化を行ってるわけではありませんが、一例の紹介ということで簡略化します)
dummyDataもやはりダンプされる危険性があるので、復号化する瞬間だけdummyDataに値を格納しておくのがよいでしょう。
1.4.2. フィールド・メソッド一覧ぶっこ抜き対策
次に解決すべき問題は
- フィールド一覧やメソッド一覧も抽出される
です。
前項で紹介したIl2CppDumperの出力ファイルの中にAssembly-CSharp.dllがありました。これをILSpyで見てみます。
メソッドの中身は復元されていませんが、メソッドの名前や引数などは見事に復元されてしまっています。
フィールドも名前や型が復元されているのが分かります。
これを抽出されるのを100%防ぐことを諦めた本記事では、フィールド名やメソッド名を難読化することで攻撃者の解析の難度を上げます。詳細は後述の難読化の項まで読み進めていただければと思います。
1.4.3. ロジックの解析対策
最後の課題は、
- 滅茶苦茶頑張られると処理の流れも追われる
ですが、こちらはどうしようもありません。
ここまで辿り着いた猛者には太刀打ちできないので、せめて後述する難読化手法で攻撃者の精神力を削る方向で頑張りましょう。
…と諦める前に、まだできる暗号化手法がもう1つあります。
それはglobal-metadata.datの暗号化です。
IL2CPPビルドで吐き出されるglobal-metadata.datとdllファイルは深く紐づいており、どちらか片方が欠けていると 読める機械語とやらへの変換もできません。
dllはどうしようもないですが、global-metadata.datはまだ救う余地があるのでそれについて軽く触れておきます。
IL2CPPビルドのglobal-metadata.datの暗号化はMfuscatorというアセットがやっています。
結構お値段が張りますがよく半額セールをやっている印象です。
実際に使ってみました。左がMfuscatorなしで、右がMfuscatorありです。
global-metadata.datがなにやら書き換えられています。
これで暗号化されたのでしょうか。
ん?
なんだか…読める部分がありますね…
恐らく復号化ロジックが独特で攻撃者を惑わせるためなのでしょうが、このアセットは肝心の暗号化復号化ロジックがブラックボックスなので不安が残ります。
(このアセットの暗号化復号化ロジックも入手できる高額なプランがあるようですが流石に手が届かず…)
調べてみるとMfuscatorとは別口で、global-metadata.datの暗号化を実現しているサンプルがありました。
どうやらUnityエディタ本体のフォルダ配下に IL2CPPビルドに挿入されるcppのソースコード群があり、そこに手を加えてglobal-metadata.datの復号化を実装しているようです。
これで綺麗にランダムなバイナリ値と化したglobal-metadata.datが出来上がりました。
global-metadata.datを守り切れれば、Il2CppDumperは弾くことができそうです。
ちなみにAndroidには復号化されたglobal-metadata.datをメモリからダンプするアプリが存在するため、Androidに対してこの対策は実質無効です。(その理屈だとPCはもっとダメなのでは…?)
1.4.4. 暗号化のまとめ
-
暗号化は普通にAES暗号化すればよくない?
→MonoビルドはILSpyで簡単に逆コンパイルできる!パスワードもすぐ特定される! -
IL2CPPビルドなら逆コンパイルは防げるんじゃない?
→メタデータからリテラルは抜かれるし機械語を読める変態さんには敵わない! -
せめて有名なツールのIl2CppDumperからの攻撃は防ぎたい!
→復号化パスワードは文字列を使わない
→Mfuscatorなどでglobal-metadata.datを暗号化する -
でもやっぱりIl2CppDumperを使われてしまった時の対策もしたい!
→ダミーファイルなど複数のファクターを混ぜることで実質的な難読化をする
→後述する本物の難読化をソースコードに施す
色々紹介できたようであまり質がなかったような気がします…申し訳ない…
ですが暗号化はあくまで前座…
難読化で挽回します!
2. 難読化
暗号化だけでも割と量があったように思いますが、ここからが本題です。
なぜなら暗号化は突破されるかされないかの2択なので、突破されたら全てが無になってしまいますが、しっかり難読化すれば復元されてしまう可能性は限りなく低いからです。
2.1. 一般的な難読化
まずは一般的な難読化手法です。
そもそも難読化というのは暗号化に比べて誰もがやっている一般的な解析対策というわけではないように思います(偏見)。
筆者がこのような偏見を持ってるので一般的な難読化というのも正直よく分かりませんが、こういう時はやはりアセットストアの有名どころを当たるのがベターでしょう。
難読化アセットの王道といえばこの2つのどちらかでしょう。
筆者は両方試したのですが、個人的には後者のほうが使い勝手が良かったので後者を例にご紹介します。
早速難読化を施してみました。
上が難読化前で、下が難読化後です。
メソッド名や引数が難読化されているのが分かります。
メソッド内のローカル引数が難読化されてないように見えますが、通常ビルド資産の中にローカル変数名は含まれません。
デバッグ用のビルドには「Assembly-CSharp.pdb」というファイルが含まれており、このファイルがローカル変数名などの情報を持っています。
このファイルを誤って一緒に公開してしまうケースは基本的に考えられないので、ローカル変数名の難読化は不要でしょう。
また、StartやUpdateなどUnity固有のメソッド名は流石に難読化できません。名前を変えると機能しなくなるのでこれは仕方ないでしょう。
2.2. 一般的な難読化では満足できない理由
さて、それではフィールド名はどうでしょうか。
全然難読化されていません。
(NotObfuscatedCauseなど難読化アセット固有の属性は、ReleaseビルドやMasterビルドでは削除されます)
また前項の画像でお気づきの方もいらっしゃるかもしれませんが、Sampleというクラス名も難読化されていませんでした。
これは難読化アセットの不備などではなく、Unityのシリアライズの性質上避けられない問題です。
UnityのSceneやPrefabは、ソースコードのpublicなフィールドやSerializedField属性が付けられたprivate・protectedなフィールドの情報をフィールド名と紐づけて保存しています。
このためソースコードのシリアライズ可能なフィールド名を難読化してしまうと、SceneやPrefabが保持しているフィールド名と整合性が取れなくなりエラーを引き起こします。
先ほどクラス名が難読化されていませんでしたが、AssetBundleはMonoBehaviourを継承したクラス(=コンポーネント)も名前で紐づけて保持しているため、クラス名を難読化するとAssetBundleを読み込んだ際にコンポーネントが見つからずにエラーとなってしまいます。
そのため、これらの難読化アセットだけでは完全な難読化が不可能ということになります。
そうなるとどのような問題が発生するでしょう。
ざっくりした例を挙げれば、「assetBundleDecryptPassword」という名前のシリアライズされたフィールドがあると、実際にそのフィールドに値が入っていなくともそこを足掛かりにパスワードを特定されそうです。
もう少し現実味のある例を挙げると、「parallelLoadCount」という名前のフィールドを見つけられると、「並列読み込み数か…これはAssetBundleを並列で同時に読み込める数のことを指してるんじゃないか?」と攻撃者に推測され、復号化ロジック解析の足掛かりとされてしまいます。
セキュアな状態というのは1か所でもボロを出せば破られると考えるべきだと筆者は思うので、ここまできたらシリアライズ可能なフィールド名やクラス名も含め全て難読化すべきです。(極論)
2.3. ディープな難読化
ここまでがチュートリアルでここからが本番です。
前項の話をまとめると、本記事での難読化の最終目標は以下のようになります。
- メソッド名を"全て"難読化する
-
プロパティ名を"全て"難読化する
→internal、private、protected、public全て難読化アセットで対応可能なので本記事では割愛します
→ただし例外的にasync系のメソッドは本記事で難読化します(詳細は後述) -
フィールド名を"全て"難読化する
→internal、シリアライズされないprivate・protectedは難読化アセットで対応可能
→シリアライズされたprivate・protected、publicは難読化アセットが使えないので本記事で難読化します -
クラス名を"全て"難読化する
→シリアライズされたpublicは難読化アセットが使えないので本記事で難読化します - 列挙型の名前と値を"全て"難読化する
- namespaceは難読化しない
2.3.1. 難読化の方針を決める
シリアライズされたフィールドやクラスを難読化アセットで対応できないのは、Unity内部でフィールド名と値が紐づけられて管理されていることが理由です。
であればフィールド名と値を紐づける前の段階から難読化を施すしかありません。
かといって、ソースコードの時点から常に難読化されたフィールド名やクラス名のまま管理するのは避けたいところです。
なのでUnityのプロジェクトを難読化前プロジェクトと難読化後プロジェクトの2つに分けます。
普段は難読化前プロジェクトで開発を行い、リリース用ビルドを作る時に難読化後プロジェクトへの変換を行いビルドします。(→難読化ツールの自作)
難読化後プロジェクトでは変数の値の確認など細かなデバッグ作業は行わない前提となります。
それを実現できるように、難読化前後ではフィールド名やクラス名の難読化以外は本当に何もしていないから大丈夫という信頼をおける環境の構築も必須となります。
また、難読化後の文字列ですがaaやab、jY7t3PxZやouU3H1qPなどもよろしくありません。
100%完璧にこういった文字列へ難読化できるのであれば問題ありませんが、Unity標準のクラスや外部アセットなどの難読化されてない文字が割り込んでくると却って悪目立ちしてしまうので悪手です。
どのような文字列に難読化すべきかも考えなければいけません。
2.3.2. 難読化ツールの作成
筆者は前項の要件を実現するためにツールを自作しました。
ツールは以下のような仕様にして、同時にUnityプロジェクトには複数の制約が発生しました。
- Assets/Scriptsフォルダ配下のcsファイルに対して難読化処理を行う
-
事前に単語リストをtxtファイル形式で作成しておき、単語リストからランダムに2~3個取り出して変数名っぽくしたものを難読化後のクラス名・フィールド名とする
→例:「AbsoluteUndoPink」「UtilitySolidPartial」など -
スクリプトファイルのどこかで定義されているフィールドのうち、接頭辞が「m_」から始まるフィールド名のみ難読化対象とする
→「m_hogeFuga1」は難読化されるが「hogeFuga2」「_hogeFuga3」などは難読化されない
→外部のアセットが「m_hogeHoge1」というフィールド名を使っていたとしてもそれは難読化しない - スクリプトファイルのどこかで定義されているクラスは一律で難読化の対象とする
-
難読化後フィールドにはFormerlySerializedAs属性を付与する
→これを使えばPrefabやSceneの難読化前フィールド名と紐づけられるが、当然リリースビルドに残すわけにはいかないので難読化後プロジェクトでFormerlySerializedAsを削除する処理を別途行う -
列挙型の値も難読化する
→列挙型の値は「E_HOGE」など接頭辞に「E_」を付け、これを難読化の対象とする
→マスターデータのファイルに列挙型の値「E_HOGE」を含めた場合、これに対しても難読化処理を施すことで整合性を担保する -
スクリプトファイルに含まれるローカル変数の接頭辞には必ず「l_」を付け、これも難読化対象とする
→フィールドとローカル変数の混同を防ぐためでもあり、人為的ミスによりローカル変数だと思い込んでいたフィールドの接頭辞に「m_」を付け忘れてうっかり難読化されなかった、という事故を防ぐ -
async系のメソッドを難読化する
→難読化アセットは何故かasync系のメソッドを難読化してくれないので自作ツールで対応する
→戻り値がUniTask系、メソッド名の末尾が「Async」、メソッド名が「Start」以外を難読化対象とする -
ついでにstatic変数・const変数も難読化する
→難読化アセットの設定によってはpublicだと難読化されないことがある
→「Start」「Update」などUnityの予約語との重複を防ぐため、接頭辞が「S_」の変数のみ難読化対象とする -
特定の属性が付与されたクラス名やフィールド名などは難読化対象から除外する
→本ツールで難読化してしまうとAssetBundle内のフィールド名と紐づかなくなるため除外設定も必要
→除外したクラス名やフィールド名の難読化は次項で行う -
csファイル名は難読化後のクラス名に準じたものかランダムなクラス名に変更する
→ビルド資産のdata.unity3dを解析すると、csファイル名一覧を取得できてしまうため
(他にも難読化にかこつけて処理の最適化をするための変換処理などを自作ツールでは行っていたりします)
接頭辞系の制約は、難読化対象であることを明示する目的に加え、csファイルから難読化対象のクラスやフィールドを難読化ツールが検索する際に正規表現によるゴリ押し検索をしていることが理由として挙げられます。
上記の説明文の中にも度々ありますが、マスターデータのような外部ファイルの加工処理や難読化後プロジェクトで実行させるスクリプトなども別途用意する必要があり、割と手間がかかっています。
2.3.3. 難読化ソースジェネレータの作成
前項のツールで難読化してしまうとAssetBundleとの紐づけが壊れてしまうため、難読化を意図的に除外したフィールド名などがあります。
これは難読化前プロジェクトで難読化せざるを得ません。
ですがやはり難読化された状態のコンポーネント名やフィールド名で管理したくはありません。
そこでSourceGeneratorの力をお借りしようと思います。
SourceGeneratorについてはこちらの記事で詳しく紹介されています。
難読化ソースジェネレータの仕様は以下の通りです。
-
「RoslynObfuscateComponent」
「RoslynObfuscateField」
の2つの属性を定義する
→RoslynObfuscateComponentが付与されたコンポーネントをSourceGeneratorの処理対象とする
→RoslynObfuscateFieldが付与されたフィールドを本ソースジェネレータによる難読化の対象とする -
RoslynObfuscateComponent付きコンポーネントを継承した新しいコンポーネントをSourceGeneratorで生成して、さらに難読化対象フィールド名を難読化したフィールドを定義する
→RoslynObfuscateField属性のフィールドはシリアライズせず前項の難読化ツールで難読化させる
→継承先コンポーネントの難読化済みフィールドをシリアライズして、前項の難読化ツールの難読化対象外とする -
Assets/Scriptsフォルダ配下に空の難読化後クラスファイルを生成する
→AssetBundleのコンポーネントはGUIDでも紐づいているためファイルの実体が必要 -
難読化前フィールドと難読化後フィールドの値の同期をとる
→ゲーム開始時に値を保持しているのは難読化後フィールドなので、難読化前フィールドに値を格納する
→以降は難読化前フィールドの値のみ使うためリリースビルドではこれ以降何もする必要がないが、エディタではLateUpdateなどで難読化前フィールドと難読化後フィールドの同期をとり続ける必要がある -
難読化後アセットのフィールド名がエディタのインスペクタで正しく表示されるようにする
→何もしないと難読化後のフィールド名がインスペクタに表示されてしまう
→開発初期はエディタ拡張を実装してゴリ押していたが、今ではOdin Inspectorの導入によりグッと楽になった
これらの要件を踏まえて実装するとこのようになります。
[RoslynObfuscateComponent(RoslynObfuscateInstanceType.E_MonoBehaviour)]
public class SampleComponent : RoslynMonoBehaviour
{
[RoslynObfuscateField]
protected string m_test1 = null;
[RoslynObfuscateField]
protected int m_test2 = 4;
[RoslynObfuscateField]
protected List<float> m_test3TaroJiro = new System.Collections.Generic.List<float>();
}
[OPS.Obfuscator.Attribute.DoNotRename]
public partial class HlSslAbstraction : global::Hoge.SampleComponent
{
[SerializeField][Sirenix.OdinInspector.LabelText("Test1")][UnityEngine.Serialization.FormerlySerializedAs("m_test1")] // original name: m_test1
private System.String rangesIron = default(System.String);
[SerializeField][Sirenix.OdinInspector.LabelText("Test2")][UnityEngine.Serialization.FormerlySerializedAs("m_test2")] // original name: m_test2
private System.Int32 immutableDamp = 4;
[SerializeField][Sirenix.OdinInspector.LabelText("Test3 Taro Jiro")][UnityEngine.Serialization.FormerlySerializedAs("m_test3TaroJiro")] // original name: m_test3TaroJiro
private System.Collections.Generic.List<System.Single> coefficientCollideSenders = new System.Collections.Generic.List<float>();
// 難読化前後のコンポーネントで値の同期をとるAwake関数やLateUpdate関数は省略
}
いい感じになりました。
実際にGameObjectにアタッチするコンポーネントは難読化後コンポーネントのほうですが、ソースコード内で実際に値や処理をあれこれするのは難読化前コンポーネントです。
難読化前コンポーネントをアタッチさせたら自動で難読化後コンポーネントに差し替えるロジックも仕込んでおけば、開発中は難読化後コンポーネントを意識する必要が一切なくなります。
2.3.4. ディープな難読化をやってみた
それではここまででご紹介した難読化手法を一通り取り入れたゲームを難読化後プロジェクトに変換してからビルドしてみます。
シリアライズされた変数もstaticな変数もローカル変数も、しっかり難読化されています。
難読化ソースジェネレータのほうはどうでしょうか。
左がソースジェネレータ難読化前のコンポーネントで、右がソースジェネレータで生成された難読化後コンポーネントです。
ソースジェネレータ難読化前コンポーネントは前項の難読化ツールにより難読化され、ソースジェネレータ難読化後コンポーネントは開発環境の段階で既に難読化されたものがそのままビルド資産にも引き継がれています。
結果として、どちらも綺麗に難読化されました。ついでにenumの値も難読化されてます。難読化した痕跡も残してないので、万が一難読化漏れがあったとしてもどれが難読化漏れなのかぱっと見では分かりづらいです。
画像では見せきれてないかもしれませんが、最初に挙げた難読化の最終目標は現状一通り満たせています。
難読化ツールと難読化ソースジェネレータのおかげで、変数名の接頭辞がこうでなきゃいけないなど多少の制約は生まれましたが、開発中はほぼこれまで通りでありながら難読化後はがっつり難読化されるようになりました。
2.3.5. 難読化のまとめ
-
難読化アセットで難読化すればよくない?
→シリアライズされたフィールドやコンポーネント名は難読化されない!
難読化漏れがあるとそこを足掛かりに悪さをされるかもしれない! -
難読化前プロジェクトと難読化後プロジェクトに分ける
→AssetBundleにアタッチされたコンポーネントはこれじゃ救えない! -
難読化ソースジェネレータを作る
→AssetBundleのコンポーネントも含めて難読化完了!
3. 総括
暗号化は突破されるかされないかの2択なので、突破されてしまえば無意味になってしまいます。
ですが更にソースコードの難読化を施しておくことで、復号化ロジックを解析する手間を増やして解析者の心を折れる確率が高まります。
難読化によって、間接的に暗号化の強度を上げることにも繋がるということです。
ちなみに暗号化や難読化のデメリットとしてパフォーマンスの低下が挙げられますが、筆者の場合はかなり最適化にこだわったうえでAssetBundleの読み込み速度にこれだけの差が出てしまいました。
左が非暗号化アセットの読み込み時間、右が暗号化アセットの読み込み時間です。
左-非暗号化アセット:計2.25秒
右-暗号化アセット:計9.05秒
この辺りはAssetBundleを特定のタイミングで纏めて読み込む、AssetBundleを並列で読み込むなどテクニックでカバーして、なんとかプレイに支障が出ない程度に抑え込めています。
2024/09/19 追記
なんか前測った時より復号化に時間かかってんな…と思って再検証してみたら、ICryptoTransform.TransformBlockがめっちゃ時間かかってました。
Unity外の暗号化ツールでICryptoTransform.TransformBlockする時は並列で実行できたのですが、UnityのAssetBundle.LoadFromStreamを介したStream.Readは直列で実行されるらしく、結果としてTransformBlockも直列だったので時間が異様にかかっていたというオチでした。
なのでうまいこと誤魔化して、堅牢な暗号化を維持しつつ読み込み時間も短縮できました。
暗号化アセット読み込み(改良後):計4.67秒
改良前でもIL2CPPすればかなり速かったので、改良後はIL2CPPによって更に爆速になりました。
ここまでの労力をかけて資産を死守したいという方はそうそういないと思いますが、そんな一握りの方のお役に立てれば幸いです。