アセットバンドル機能を応用することで、アセットに対して独自の暗号化を施したり、C# Script 内にアセットを埋め込むなどの方法で、大切な画像や効果音などを保護しましょう。
目次
- まえがき (対象読者など)
- 事前準備 (使用ソフトなど)
- 事前準備 (使用素材、プロジェクト環境など)
- アセットバンドルの暗号化方法
- アセットバンドルの復号化方法
- もう 1 つの保護方法 : アセットを C# Script に埋め込む
- C# Script の逆コンパイル・逆アセンブルにも要注意!
- おわりに
まえがき (対象読者など)
Unity でアセットを暗号化して保護するための手段の 1 つとして、アセットバンドル機能を利用する方法が存在します。
しかし、本記事執筆時点では Google 検索上位の記事は中上級者向けを想定したものが多いため、入門者向けには敷居が高い方法となっています。
ただし、初心者の方でも簡単に実装できるシンプルな暗号でも、AssetStudio 等の素材抜き出しツール (下記) から身を守ることはできます。
AssetStudio は、インターネット上で容易に入手でき、かつ素人でも扱える無償ツールなので、暗号化していなければ上記のように容易に素材を抜き取られてしまいます。
ゲームの性質にもよりますが、重要な素材 (例えば、主要キャラの立ち絵等) だけは抜き取られたくないと思う方も居るはずです。
例えば、本記事で紹介する「実際に動かせる」C# Script 例に含まれている簡単な暗号化処理だけでも、上記のように AssetStudio から身を守ることができます。
暗号化は、突き詰めればきりのない分野ですが、「アセットを抜き取ろうとする素人から身を守る」程度のレベルであれば、ほんのちょっとした工夫だけでも防御できます。
本記事は、「勝手に抜き取られたくないアセットがあるけど、どうやって守れば良いか分からない or 難しい」と思っている Unity 開発者向けの、アセット暗号化入門記事です。
事前準備 (使用ソフトなど)
本記事では、下記のリンク先のフリーソフトを動作確認用に使用します。
いずれもインストーラー無しで単独実行可能です。
- AssetStudio
- 「まえがき」でも説明した、Unity のアセットを抜き取ることができるツールです。
- 基本的には、最も大きいバージョン番号の zip ファイルのものを使えば OK です。
- Stirling
- ファイルの中身を 16 進データ列として覗くことができるバイナリエディターです。
- 他のバイナリエディターを使っている方は、上記以外のものでも構いません。
- ILSpy
- Unity を始めとする C# 製プログラム (exe, dll 等) の逆コンパイルツールです。
- 端的には、ビルド後のゲームから、ビルド前の C# Script を復元するツールです。
- 「ILSpy_binaries_」で始まっているものが、インストーラー不要の exe 版です。
事前準備 (使用素材、プロジェクト環境など)
別記事「Unity アセットバンドル (AssetBundle) 実例付き入門」と同じ素材、プロジェクト環境を使用します。
つまり、今回もぴぽや倉庫様の画像素材を例として使用します。
- アセットバンドルについて「知らない」「理解できていない」という方は、まずは上記の入門記事にて、サンプルの C# Script だけでも動かしてみて、ざっくりと理解してから本記事の内容に挑戦することをおすすめします。
アセットバンドルの暗号化方法
説明は後で行いますので、Editor.cs 側に以下のコードを丸ごと貼り付けて、Assets メニュー → !!!!!!!!!! Build !!!!!!!!!! で実行してみましょう。
アセットバンドルの生成 & 暗号化スクリプト (Editor.cs)
using System.IO; using UnityEngine; using UnityEditor; public class Editor { [MenuItem("Assets/!!!!!!!!!! Build !!!!!!!!!!")] static void Build() { // ■ アセットバンドルの生成 // (bundle22222) BuildPipeline.BuildAssetBundles("Assets/StreamingAssets", new AssetBundleBuild[] { new AssetBundleBuild() { assetBundleName = "bundle22222", assetNames = new[] { "Assets/pipo_sprites.png", }, }, }, BuildAssetBundleOptions.None, BuildTarget.StandaloneWindows64); // ■ 生成したアセットバンドルの暗号化 // (bundle22222 → bundle22222enc) byte[] assetBundleData = File.ReadAllBytes( Application.streamingAssetsPath + @"\bundle22222"); for (int i = 0; i < assetBundleData.Length; i++) { assetBundleData[i] ^= 0xAA; } File.WriteAllBytes( Application.streamingAssetsPath + @"\bundle22222enc", assetBundleData); } }
暗号化前後のファイルの中身の確認
ソースコードを理解する前に、まずは実行結果を確認してみましょう。
StreamingAssets フォルダの中に、bundle22222 と bundle22222enc が生成されていることを確認します。(他にも色々生成されていますが、アセットバンドルの記事でも説明したとおり無視できます)
それぞれ、事前準備で紹介した Stirling 等のバイナリエディターで中身を覗いてみましょう。
bundle22222 (暗号化前) のバイナリデータ
UnityFS や、CAB-xxxx、使用している Unity のバージョン番号等、Unity 関連のファイルであることが一目瞭然です。
bundle22222enc (暗号化後) のバイナリデータ
若干規則性は残っていますが、パッと見では何のファイルか分からなくなっています。
暗号化前後のファイルを AssetStudio に読み込ませる
それでは、次に、上記両方のファイルを AssetStudio にかけてみて、元のアセットが抜き出せてしまうかどうかを見てみましょう。
bundle22222 (暗号化前) の AssetStudio 抽出結果
AssetList タブを開くと、見事に抜かれてしまっています。
bundle22222enc (暗号化後) の AssetStudio 抽出結果
抽出に失敗しました。暗号化の効果が出ていることが分かります。
暗号化処理の説明
コメントも付けてあるとおり、アセットバンドルを実際に暗号化しているのは下記の部分です。
(ReadAllBytes と WriteAllBytes はファイルの読み書き部分なので説明は割愛)
// ■ 生成したアセットバンドルの暗号化 // (bundle22222 → bundle22222enc) byte[] assetBundleData = File.ReadAllBytes( Application.streamingAssetsPath + @"\bundle22222"); for (int i = 0; i < assetBundleData.Length; i++) { assetBundleData[i] ^= 0xAA; } File.WriteAllBytes( Application.streamingAssetsPath + @"\bundle22222enc", assetBundleData);
for 文をもちいて、アセットバンドルのファイルの中身全体に 0xAA という 16 進数を XOR しています。
これが、コンピューターの世界で使われる暗号化の基礎でもある XOR 暗号です。
XOR 暗号とは
XOR 暗号は、ビットデータ (=0, 1 のみで構成される 2 進数のデータ) 毎の XOR 演算による暗号化方式です。
XOR 演算とは、0 を XOR した場合はそのままで、1 を XOR した場合は反転させるという処理のことを指します。
今回の事例で暗号化の「鍵」として使用した 16 進数の「AA」は、2 進数だと「10101010」 になるため、入力されたビットデータの偶数番目 (※ 先頭のビットを 0 番目と仮定) のみを反転させる、という処理になります。
ファイル先頭の UnityFS の部分は、上記画像のように、黄色マーカーになっている 1 に相当する部分だけが反転するため、水色マーカーのように入力データの反転処理 (平文が 0 なら 1 に、平文が 1 なら 0 に) が行われます。
Q. 暗号化の「鍵」は AA じゃなければいけないの?
結論から言うと、00 以外であれば、XOR 暗号としては機能します。
(00 だと 2 進数で 00000000 になるため、XOR しても「平文のまま」となります)
ただし、2 進数にしたときに 0 と 1 がバランスよく含まれていた方が、「反転する/しない」が複雑にかみ合わさるため、55 (01010101) や AA (10101010) のようにバランスよく含まれるパターンが比較的好まれます。
また、以下の例のように全ビットを反転させる FF (11111111) もよく使われます。
Q. どうやって復号するの?
同じ鍵 (例えば AA なら、再び AA を使う) でもう一度 XOR するだけです。
反転したものを、もう一度反転させれば元に戻るからです。
- 実際に、次に提示する復号化側の C# Script でも、まったく同じ for 文を使います。
暗号化処理において XOR 演算が頻繁にもちいられる理由の 1 つが、ここ (=暗号化・復号化の処理を共通化できる) にあります。
Q. XOR 演算による暗号化の強度は?
残念ながら、さほど強くはないです。暗号に関する知識がちょっとでもある人だったらすぐに解読してきます。
しかし、プログラミングなどのスキルが無く、AssetStudio などのツールに頼らないと抽出できない程度のスキルしか無い「素人」からアセットを守るという前提であれば、十分強力な部類には入るでしょう。
よほどの大型ゲームタイトルでもない限り、「ネット上で出回っている主要な抽出ツールから防護できれば十分」と割り切っておくのも 1 つの考えです。
Q. より強度の高い暗号化方式は?
本記事では詳細は割愛しますが、例えば以下のような事例であれば比較的簡単に実現できます。
- 「最長LFSR」を利用して 2 進数の疑似乱数列を生成し、その乱数列を XOR する
- ビットシフトと XOR 演算だけで実現できる比較的簡単で、そこそこ強力な暗号化方法です。一昔前の携帯電話で使われていましたが、現代暗号に比べると容易に解読できてしまうため、現在では実用レベルの暗号化では使われていません。(それでも単純な XOR 暗号よりはずっと強力なので、ちょっとした暗号化にはよく使われます)
- 単純な XOR 暗号と同じく、復号化も同一の乱数パターンを XOR するだけです。
- C# (.NET) の標準ライブラリとして実装されている AES を使用
- 現代で現役の暗号化方式の 1 つで、C# (.NET) から呼び出す方法があります。標準機能を呼び出すだけ…ですが、そこそこ行数のある C# コードを書く必要があります。Google 検索の上位に出てくる Unity アセット暗号化関連の記事でいくつか言及されていましたので、気になる方は試してみても良いかもしれません。
補足:2 進数と 16 進数について
普段我々が日常生活で使用している数字は、10 進数と呼ばれるもので、0~9 の 10 種類の数字を使って 1 桁を表しています。
これに対して、2 進数は「0~1」の 2 種類、16 進数は「0~9, A~F」の 16 種類の数字を使って 1 桁を表します。
2 進数や 16 進数を簡単に手計算するためのテクニック
慣れない方には難しいかもしれませんが、コンピューターで 2 進数や 16 進数を扱うときは、以下の 0~15 までの 16 通りの表だけを暗記しておけば、電卓などに頼らなくても素早く換算できるようになります。(4 行毎に区切ると規則的になっているので覚えやすいです)
- おそらく、情報系の大学や専門学校を出ている方は授業等で習っているはずです。
- 情報処理技術者試験 (基本情報技術者など) でもほぼ必須のテクニックです。
ポイントは以下です。
- 1 桁の 16 進数は、4 桁 (= 4 bit) の 2 進数と相互変換できること。
- 複数桁の 16 進数は、それぞれの桁で独立に 2 進数と相互変換できること。
- 例:16 進の 5F は、5 = 0101, F = 1111 なので、5F = 0101 1111
- 1 バイトは、2 桁の 16 進数 (= 8 桁の 2 進数) で表現できること。
これが、バイナリエディター等でバイト列を表記する際に、16 進数が多用される理由です。
コンピューターの内部は 2 進数 (0, 1) で動いていますが、2 進数のままだと桁数が膨大になってしまうため、16 進表記することで、画面表示時の桁数を 1/4 に圧縮している、と言い換えることもできます。(例:2 進の 11111111 は 16 進で FF なので 8 桁から 2 桁に減る)
アセットバンドルの復号化方法
先ほどの説明のとおり、暗号化と復号化の処理は同一なので、読み込むことも簡単にできます。
ただし、あらかじめファイルの内容を ReadAllBytes() で読み出して「復号」を済ませてから、LoadFromFile() の代わりに LoadFromMemory() に渡す必要があります。
- LoadFromMemory() は、入力がファイルの代わりにメモリ (= byte 型の配列) になるだけで、出力仕様は同じなので、以降の処理は LoadFromFile() の場合と同じです。
アセットバンドルの復号化 & 読込スクリプト (NewBehaviourScript.cs)
実際にアセットを読み込んで使用する側 (=ゲーム本体側) のスクリプトなので、貼り付け先は NewBehaviourScript.cs です。
using System.IO; using UnityEngine; public class NewBehaviourScript : MonoBehaviour { AssetBundle _bundle22222; void Start() { // ■ 暗号化アセットバンドルの読込・復号 byte[] assetBundleData = File.ReadAllBytes( Application.streamingAssetsPath + @"\bundle22222enc"); for (int i = 0; i < assetBundleData.Length; i++) { assetBundleData[i] ^= 0xAA; } // ■ メモリ上で復号したアセットバンドルの読出 _bundle22222 = AssetBundle.LoadFromMemory(assetBundleData); Texture2D texture = _bundle22222.LoadAsset<Texture2D>( "Assets/pipo_sprites.png"); Sprite sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 1); SpriteRenderer render = new GameObject().AddComponent<SpriteRenderer>(); render.sprite = sprite; Camera.main.orthographicSize = texture.height / 2; } void OnApplicationQuit() { _bundle22222.Unload(true); } }
動作確認
以下のように、アセットバンドルに格納した画像が表示されれば OK です。
もう 1 つの保護方法 : アセットを C# Script に埋め込む
アセットを保護するための手段は、何も「暗号化」だけとは限りません。
例えば、C# Script としてゲーム本体に埋め込んでしまうのも、容易にアセットを抜き取られないようにするために有力な手段の 1 つです。
アセットバンドルの C# 化スクリプト (Editor.cs)
アセットバンドル生成側のコードなので、貼り付け先は Editor.cs です。
using System.IO; using UnityEngine; using UnityEditor; public class Editor { [MenuItem("Assets/!!!!!!!!!! Build !!!!!!!!!!")] static void Build() { // ■ アセットバンドルの生成 // (bundle22222) BuildPipeline.BuildAssetBundles("Assets/StreamingAssets", new AssetBundleBuild[] { new AssetBundleBuild() { assetBundleName = "bundle22222", assetNames = new[] { "Assets/pipo_sprites.png", }, }, }, BuildAssetBundleOptions.None, BuildTarget.StandaloneWindows64); // ■ 生成したアセットバンドルの C# Script 化 // (bundle22222 → Bundle22222scr.cs) byte[] assetBundleData = File.ReadAllBytes( Application.streamingAssetsPath + @"\bundle22222"); using (StreamWriter sw = new StreamWriter( Application.dataPath + @"\Bundle22222scr.cs")) { sw.Write("class Bundle22222scr { "); sw.Write("public byte[] AssetBundleData = new byte[] { "); for (int i = 0; i < assetBundleData.Length; i++) { sw.Write(assetBundleData[i]); sw.Write(','); } sw.Write(" }; }"); } } }
簡単な説明
以下の部分で、実際にアセットバンドルを C# Script として出力しています。
using (StreamWriter sw = new StreamWriter( Application.dataPath + @"\Bundle22222scr.cs")) { sw.Write("class Bundle22222scr { "); sw.Write("public byte[] AssetBundleData = new byte[] { "); for (int i = 0; i < assetBundleData.Length; i++) { sw.Write(assetBundleData[i]); sw.Write(','); } sw.Write(" }; }"); }
上記のコードをAssets メニュー → !!!!!!!!!! Build !!!!!!!!!! で実行すると、以下のような C# Script が出力されるはずです。
つまり、アセットバンドルのファイルの中身を (C# の文法に違反しないように) byte 型配列変数として出力し、その外側を class 定義で囲んであげれば良いのです。
C# 化したアセットバンドルの読込スクリプト (NewBehaviourScript.cs)
実際にアセットを読み込んで使用する側 (=ゲーム本体側) のスクリプトなので、貼り付け先は NewBehaviourScript.cs です。
using UnityEngine; public class NewBehaviourScript : MonoBehaviour { AssetBundle _bundle22222; void Start() { // ■ C# Script 化したアセットバンドルの読出 _bundle22222 = AssetBundle.LoadFromMemory( new Bundle22222scr().AssetBundleData); Texture2D texture = _bundle22222.LoadAsset<Texture2D>( "Assets/pipo_sprites.png"); Sprite sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 1); SpriteRenderer render = new GameObject().AddComponent<SpriteRenderer>(); render.sprite = sprite; Camera.main.orthographicSize = texture.height / 2; } void OnApplicationQuit() { _bundle22222.Unload(true); } }
簡単な説明
既に C# Script になっているので、Unity を一旦最小化した後、もう一度アクティブにするだけで Unity 側で勝手に認識してくれます。
プログラム上では、先ほどの配列変数を、そのまま LoadFromMemory() に渡しているだけです。
// ■ C# Script 化したアセットバンドルの読出 _bundle22222 = AssetBundle.LoadFromMemory( new Bundle22222scr().AssetBundleData);
動作確認
C# Script 化したアセットバンドルに格納した画像が表示されれば OK です。
C# Script 化した場合に埋め込まれる場所
C# Script にアセットバンドルを埋め込んだ場合、ゲーム本体側の C# Script と同様、通常は Assembly-CSharp.dll というファイルに埋め込まれます。
このファイルは、ゲームをリリース用にビルドしたとき、「(プロジェクト名)_Data → Managed」フォルダの中に生成されます。
気になる方は、実際にビルドを行い、バイナリエディターで中身を覗いてみましょう。
「UnityFS」で文字列検索すると、Assembly-CSharp.dll の中 (上記の例ではアドレス B50 から) に、平文のままのアセットバンドルが埋め込まれていることが分かります。
Assembly-CSharp.dll を AssetStudio に読み込ませる
平文のままだと、AssetStudio にバレるのではないか? …と心配になるかもしれません。
しかし、以下のように、AssetStudio にはバレませんでした。
つまり、たとえ暗号化を行わなくても、C# Script としてゲーム本体に埋め込む方法は、アセットを保護するうえで十分強力な手段になりうることを意味します。
もちろん、「埋め込み」と同時に「暗号化」も併用した方がより安全になることに変わりはありません。
C# Script の逆コンパイル・逆アセンブルにも要注意!
Unity に限らず、C# で作成したソフトウェア全般には、逆コンパイル・逆アセンブルに弱いという弱点があります。
つまり、先ほどの Assembly-CSharp.dll から、元のソースコードを容易に復元できてしまうことを意味します。
ILSpy で Assembly-CSharp.dll を逆コンパイルする
事前準備で紹介した ILSpy というツールをもちいることで、AssetStudio とほぼ同じような使い勝手で C# Script を復元できます。
Unity 側で C# Script をビルドするときに、コメントが削られたり、処理の最適化が行われるため、完全には一致しませんが、雰囲気的にはほぼ同じものが取り出せます。
こちらは、先ほど C# Script として埋め込んだアセットバンドルの配列です。ここまで見られてしまえば、もはや何をやっているのかが筒抜けです。
いくらアセットバンドルに AES のような強力な暗号化処理を施していたとしても、逆コンパイルの前では無力 (AES の復号化処理を覗き見されて「鍵」を抜き取られてしまう) です。
特に変数名やメソッド名は復元できてしまうので、分かりやすい変数名やメソッド名が含まれている時点でアウトです。
逆コンパイル・逆アセンブルへの対策方法
残念ながら、C# Script の逆コンパイルに対しては、100% 有効な対策は存在しません。
その代わり、一般的に広く知られている方法として、難読化があります。
難読化とは
難読化とは、例えば C# Script の中にある変数名やメソッド名などを、ランダムな文字列などに置き換えてしまうことで、逆コンパイルされても読みづらい (=解析しづらい) ソースコードにすることです。
この難読化は、自動的に行ってくれるツールも存在しますし、あるいは重要なところ (人に勝手に解析されたくないところ) だけ自分自身の手で複雑怪奇なソースコードにして (自分以外の) 誰にも読めなくしてしまうという手段で行うこともできます。
適当なところで割り切るのも有効
他にも、一部のコードを C# ではなく C/C++ で書いて呼び出したり、復号処理を行うソースコードが格納されている dll (アセンブリ) だけを暗号化するなど、様々な対策が考えられますが、逆コンパイル・逆アセンブルの知識が豊富な「玄人」に対して 100% 有効な対策はほぼ無いと言えるでしょう。
しかし、C# のソースコードを読むことのできるゲームのプレイヤーは、ほんの一握りだと言えます。なぜなら、ゲームのプレイヤー層には、ゲーム開発者だけではなく一般人も含まれているからです。
なので、アセットバンドルの復号化や読み込み処理に関わるようなところだけ、最低限の難読化対策 (コメントが無ければ読めないように、あえて複雑にしておく等) をしておく、というのも 1 つの選択肢として有効だと言えるでしょう。
おわりに
本記事では、アセットの保護について、入門レベルの暗号化・埋め込み方法を実例とともに紹介・解説しました。
本記事の内容が難しかったと感じた方でも、最低限、本記事の C# Script を動かすことができたのであれば、それをもとに読者の開発するゲームに応用することは十分可能でしょう。
いずれにしても、繰り返し述べてきたとおり、アセットの暗号化に関しては完璧と言えるような対策はほぼありませんので、本記事の内容よりも高度な暗号化やその他保護策を講じるにしても、どこかで割り切ることが必要になります。
むしろ、各ゲーム作者が様々な「個性的対策」を講じれば、解析する側も一筋縄ではゲームからアセットをぶっこ抜くことができなくなりますので、お粗末な方法でも良いので各作者独自の暗号化・埋め込み等を施すことが、様々なゲームのアセットを保護することに繋がっていくのではないか…と筆者は考えます。
本記事を読まれたゲーム作者の方が、自己の資産である、ゲームの「画像」や「効果音」などを守るために役に立てていただけたら幸いです。