Unity製アプリにおいてアセットを暗号化する手法
はじめに
株式会社QualiArtsでUnityエンジニアをしている渡部です。 2021年6月リリースの「IDOLY PRIDE」(以降、アイプラ)の開発に携わり、主にUnityでのゲーム開発やビルド環境の整備を担当しています。
Unity製アプリの内部構造は非常にわかりやすく、ネット上にはAssetStudio等のアセット解析ツールも存在し、少し検索すればプログラミング知識が無くても容易にアプリ内のアセットを覗き見ることができます。
本記事では、こうした既製ツールを用いたカジュアルハック対して、アプリに内蔵されるアセットを保護する方法を紹介します。
サンプルアプリ解説
次のような画面を持つアプリを作 り、このアプリに2つのTextAssetと、2つのSpriteAtlasを組み込んでビルドします。
アセットはそれぞれ、
解析されてもいいTextAssetとSpriteAtlasを plainTextAsset
plainAtlas
、
解析されたくないTextAssetとSpriteAtlasを himituTextAsset
himituAtlas
、
と名付けました。
SpriteAtlasの見た目は次の通りです。
TextAssetの内容はそれぞれ次の通りで、この内容をサンプルアプリ中央のTextに反映しています。
plainTextAsset.txt
これは普通のTextAssetです。
himituTextAsset.customtxt
これは秘密のTextAssetです!
AssetStudioによるアセット解析の結果
まずは上記アプリをビルドしてAssetStudioでアセットを表示してみた結果を示します。
その後、TextAssetの暗号化方法と、SpriteAtlasの暗号化方法について説明します。
TextAsset
AssetStudioでは2つのTextAssetをそれぞれ、次のように表示されました。
plainTextAssetの中身はテキストファイルの中身が表示されているのに対して、 himituTextAssetの中身はそのままでは内容がわからないようにできました。 方法については、TextAssetの暗号化方法のセクションで説明します。
SpriteAtlas
AssetStudioではplainAtlasのSpriteAtlasの中身がそのまま表示されました。 一方でhimituAtlasは、表示以前にその存在すら認識されていませんでした。
また、plainAtlasの中に登録されているplainSpriteが個別で表示できるのに対して、himituAtlasの中に登録されている「秘密のAtlas」という文字が書き込んであるSpriteはPreview不能という扱いになりました。
TextAssetとSpriteAtlasの暗号化が可能なことが確認できました。
ここから実装方法について説明します。
TextAssetの暗号化方法
TextAssetの暗号化処理をどのタイミングで行うか考えると、いくつかの候補が挙げられます。 簡単に思いつく範囲では、
- Unityにインポートする前のテキストファイルの段階で暗号化する
- UnityにインポートされてTextAssetになるタイミングで暗号化する
- Unityでのアプリビルドのタイミングで暗号化する
などがあると思われます。 1の「テキストファイルの段階で暗号化する」はワークフロー的に煩雑になりそうな上に、Unityプロジェクト内で差分管理を行っていた場合に差分の把握が難しくなる、などの重めのデメリットが発生しそうです。
したがって、今回は2の「TextAssetになるタイミングで暗号化する」を採用してみます。なお3についてはSpriteAtlasの暗号化で採用してみます。
今回、秘密にしたいTextAssetの名前を himituTextAsset.customtxt
のように特殊な拡張子(.customtxt)
にしました。これはScriptedImporterという機能を使うために、Unityが標準で対応している.txtとは違う拡張子にする必要があったためです。
実装例を以下に示します。ScriptedImporter自体はシンプルなAPIで、ScriptedImporter属性で拡張子を指定し、OnImportAssetメソッド内で何らかのassetを生成するだけです。
// 平文のtextファイルから暗号化済みのTextAssetを生成する
[ScriptedImporter(1, "customtxt")] // .customtxtな拡張子のアセットを対象にする
public class CustomTextAssetImporter : ScriptedImporter {
public override void OnImportAsset(AssetImportContext ctx) {
byte[] bytes = File.ReadAllBytes(ctx.assetPath);
// bytesから何らかの暗号化処理を加えてTextAssetを生成する
TextAsset textAsset = CustomTextAssetReader.CreateTextAsset(bytes);
// ImportContextにMainObjectとして暗号化済みTextAssetを登録する
ctx.AddObjectToAsset("MainObject", textAsset);
ctx.SetMainObject(textAsset);
}
}
// TextAssetを暗号化したり復号化したりする
public static class CustomTextAssetReader {
// byte配列に何らかの暗号化・復号化を施す
private static void Xor(byte[] bytes) {
for (var i = 0; i < bytes.Length; i++) bytes[i] = (byte) (bytes[i] ^ 100);
}
// byte配列から暗号化済みTextAssetを生成する
public static TextAsset CreateTextAsset(byte[] bytes) {
Xor(bytes);
return new TextAsset(Convert.ToBase64String(bytes));
}
// 暗号化済みTextAsset を復号してstringを読み出す
public static string ReadString(TextAsset textAsset)
=> Encoding.UTF8.GetString(ReadBytes(textAsset));
private static byte[] ReadBytes(TextAsset textAsset) {
var bytes = Convert.FromBase64String(textAsset.text);
Xor(bytes);
return bytes;
}
}
上記のようなScriptedImporterを経由することで、ファイル自体は平文なのに、Unity内では暗号化済みのTextAssetとして扱う、といったことが可能になります。
次の画像は、 himituTextAsset.customtxt
のインスペクターであり、Unityからの見かけ上の文字列を変えることができています。
string txt = CustomTextAssetReader.ReadString(himituTextAsset)
のように、専用の読み出しメソッドを用意しておけば、TextAssetのカジュアルハック対策をしつつ、取り回しも難しくなりすぎないという、程々のところに落ち着くと思います。
SpriteAtlasの暗号化方法
SpriteAtlasをアプリピルドのタイミングで暗号化する方法を説明します。 ただし、ここで説明する方法はSpriteAtlasがResourcesフォルダ内に入れてある場合は対応できていません。 Resources内のSpriteAtlasをアセット解析ツールから保護したい場合は、別の方法を考える必要がある点に注意してください。
SpriteAtlasには Include in Build
設定があります。
これをオフにすることで、アプリビルド時にSpriteAtlasが同梱されなくなります。これを利用し、アプリビルド時にSpriteAtlasをAssetBundle化&暗号化&StreamingAssets化することで簡単には画像を抜き出せなくする方法を紹介します。
SpriteAtlasのAssetBundle化 & 暗号化 & StreamingAssets化
次の3つの手順でSpriteAtlasをAssetBundle化&暗号化します。
- IncludeInBuildがfalseなSpriteAtlas検索しAssetBundleビルド対象にする
- StreamingAssetsの下にAssetBundleを生成する
- 生成されたAssetBundleを暗号化する
これらの手順を実装するとしたら以下の例のようになると思います。
// ビルドボタンを押した時にSpriteAtlasをAssetBundle化する
public static void BuildAtlasAssets() {
// 1. IncludeInBuildがfalseなSpriteAtlas検索しAssetBundleビルド対象にする
var builds = AssetDatabase
.FindAssets("t:SpriteAtlas")
.Select(AssetDatabase.GUIDToAssetPath)
.Select(AssetDatabase.LoadAssetAtPath<SpriteAtlas>)
.Where(x =>
{
// IncludeInBuildがfalseなSpriteAtlasに絞り込む
// もしくは全てを対象にしていいのなら x.SetIncludeInBuild(false) で強制的にIncludeInBuildをfalseにするのもあり
using var so = new SerializedObject(x);
return !so.FindProperty("m_EditorData.bindAsDefault").boolValue;
})
// AssetBundle情報を構築する
.Select(x => new AssetBundleBuild
{
addressableNames = new[] {x.name},
assetNames = new[] {AssetDatabase.GetAssetPath(x)},
assetBundleName = x.name.ToLower(),
assetBundleVariant = null,
}).ToArray();
// 2. StreamingAssetsの下にAssetBundleを生成する
var outputDir = Path.Combine(Application.streamingAssetsPath, "sa");
if (Directory.Exists(outputDir) == false) Directory.CreateDirectory(outputDir);
BuildPipeline.BuildAssetBundles(outputDir, builds,
BuildAssetBundleOptions.ForceRebuildAssetBundle, EditorUserBuildSettings.activeBuildTarget);
// 3. 生成されたAssetBundleを 暗号化する
foreach (var build in builds)
{
var path = Path.Combine(outputDir, build.assetBundleName);
var bytes = File.ReadAllBytes(path);
// byteデータに何らかの暗号化を施す
AtlasStream.Xor(bytes, 0, bytes.Length);
File.WriteAllBytes(path, bytes);
}
}
上記のように手順や実装自体は難しくはありませんが、これをどのタイミングで呼び出すかで困るかもしれません。
IPreprocessBuildWithReport.OnPreprocessBuild
で実行するのが安直に思いつきますが、 IPreprocessBuildWithReport
では BuildPipeline.BuildAssetBundles
を呼び出せません。 そこで、Unityの標準APIの中で IPreprocessBuildWithReport
以外にビルド直前のタイミングをフックする方法として、 BuildPlayerWindow.RegisterBuildPlayerHandler
が候補に上がります。 以下に示す通り、これは InitializeOnLoad
のタイミングで差し込みたい処理を登録しておくだけというお手軽なAPIです。
ただしこれは、staticなActionを上書きする実装のため、プロジェクト全体を通して一つだけしか有効になりません。 RegisterBuildPlayerHandler
の奪い合いにならないように注意する必要があります。
[InitializeOnLoadMethod]
public static void InitializeOnLoad() {
BuildPlayerWindow.RegisterBuildPlayerHandler(options =>
{
// ビルドボタンが押されたらSpriteAtlasをAssetBundle化して
BuildAtlasAssets();
// 次にアプリビルドを実行する
BuildPlayerWindow.DefaultBuildMethods.BuildPlayer(options);
});
}
暗号化SpriteAtlasのAssetBundleの読み込み
Include in Build
をオフにしたSpriteAtlasは SpriteAtlasManager.atlasRequested
というactionにSpriteAtlasをロードする処理を登録する必要があります。 以下のようにRuntimeInitializeOnLoadMethod
で登録する方法などが考えられます。
また、StreamingAssets内のSpriteAtlasのAssetBundleは暗号化されているので、ロード時には復号する必要があります。
AssetBundleをロードするAPIの中にはStreamを引数にとるものがあります。これを利用し、 AtlasStream
の例のような復号処理を内蔵するStreamを実装することで、暗号化済みのAssetBundleをロードする ことができます。
[RuntimeInitializeOnLoadMethod]
public static void InitializeOnLoad() {
if (Application.isEditor) {
// Editorの時はAssetDatabaseからSpriteAtlasを読み込む
SpriteAtlasManager.atlasRequested += LoadFromAssetDatabase;
}
else {
// ビルド済みアプリの場合はStreamingAssetsからSpriteAtlasを読み込む
SpriteAtlasManager.atlasRequested += LoadFromStreamingAssets;
}
}
private static void LoadFromAssetDatabase(string atlasName, System.Action<SpriteAtlas> register)
{
#if UNITY_EDITOR
SpriteAtlas spriteAtlas = "AssetDatabaseとかを使ってSpriteAtlasをロードする処理"
register.Invoke(spriteAtlas);
#endif
}
// StreamingAssets内のAssetBundleファイルからSpriteAtlasを読み込む処理
private static void LoadFromStreamingAssets(string atlasName, System.Action<SpriteAtlas> register) {
// atlas名からAssetBundleファイルのパスを取得する
var path = Path.Combine(Application.streamingAssetsPath, atlasName.ToLower());
using var fs = File.OpenRead(path);
// 暗号化済みのFileStreamを復号するAtlasStreamで復号化する
using var stream = new AtlasStream(fs);
// 独自のStreamでもAssetBundle.LoadFromStreamを動かすことができる
var assetBundle = AssetBundle.LoadFromStream(stream);
var spriteAtlas = assetBundle.LoadAsset<SpriteAtlas>(atlasName);
assetBundle.Unload(false);
register.Invoke(spriteAtlas);
}
// FileStreamなどを復号化するstream
public class AtlasStream : Stream {
private readonly Stream _stream;
public AtlasStream(Stream stream) => _stream = stream;
// ...
public override int Read(byte[] buffer, int offset, int count) {
// 暗号化済みのstreamを読み出して復号化する
var readCount = _stream.Read(buffer, offset, count);
Xor(buffer, offset, readCount);
return readCount;
}
public override void Write(byte[] buffer, int offset, int count) {
// bufferを暗号化してstreamに書き込む
Xor(buffer, offset, count);
_stream.Write(buffer, offset, count);
}
public static void Xor(byte[] buffer, int offset, int count) {
// 何らかの暗号化・復号化処理
for (var i = offset; i < count; i++) buffer[i] = (byte) (buffer[i] ^ 100);
}
}
おまけ:C# DLL内のメソッド名を難読化する方法
ここまでアセットを暗号化する方法を説明しました。 しかし、C#の実装というもの はIl2CppDumperやILSpy,dnSpyといった逆コンパイルツールも豊富で、それほど苦労せず内部を調べることができます。 例として、ビルド済みアプリのAssembly-CSharpをdnSpyで逆コンパイルした結果を貼ります。 クラス名やメソッド名(さらにはその中の処理も)がそのまま復元されています。
ここでは初歩的な難読化の方法として、メソッド名の難読化をビルド時に行う方法を紹介します。
Mono.Cecilのインストール
C#のソースコードでは無く、コンパ イルされたDLLを編集する方針にするのでMono.Cecilを使います。 Mono.CecilはUnity Package Managerで導入することができます。 Package
Managerの Add package from git url...
を選択し git@github.com:needle-mirror/com.unity.nuget.mono-cecil.git
を入力してAddします。
これだけでEditorスクリプトでMono.Cecilを利用できるようになります。
IPostBuildPlayerScriptDLLsでビルド時にDLLを編集する
アプリビルド時、C#スクリプトのコンパイル後に IPostBuildPlayerScriptDLLs.OnPostBuildPlayerScriptDLLs
が発火します。 この時引数の BuildReport.files.path
にアプリビルド用にコンパイルされたDLLのファイルパスが渡ってきます。このファイルパスのDLLに対して任意の編集を行うことで、最終的にアプリに組み込まれるDLL(もしくはDLLから作られるcpp)に変更を与えることができます。
以下の EditDLLCallback
では、 CustomAtlasLoader
と CustomTextAssetReader
のメソッド名を HimituMethod_XXXXXXXXXXXX
のように書き換えています。
/// <summary>
/// アプリビルド時にDLLを編集するcallback
/// </summary>
public class EditDLLCallback : IPostBuildPlayerScriptDLLs {
public int callbackOrder => 0;
public void OnPostBuildPlayerScriptDLLs(BuildReport report) {
var dlls = report.files
.Where(x => x.role == "ManagedLibrary")
.Select(x => (x.path, asm: AssemblyDefinition.ReadAssembly(x.path))).ToArray();
// 秘密にしたいメソッドをもつクラス名
var editTargetClassNames = new List<string> {
"CustomAtlasLoader",
"CustomTextAssetReader",
};
var renameMethods = dlls.SelectMany(x => x.asm.Modules)
.SelectMany(x => x.Types)
.Where(x => editTargetClassNames.Contains(x.FullName))
.SelectMany(x => x.Methods)
// RuntimeInitializeOnLoadMethodなどの属性がついているメソッドの名前を変えると動かなくなる
.Where(x => x.CustomAttributes.All(c => c.AttributeType.Namespace != "UnityEngine"))
.Select(x => x.FullName)
// メソッド名を適当に変える
.ToDictionary(x => x, x => $"HimituMethod_{Mathf.Abs(x.GetHashCode())}");
var newDllPathList = new List<(string originalDllPath, string editedDllPath)>();
foreach (var (path, asm) in dlls) {
var editedDllPath = RenameMethods(asm, renameMethods);
if (editedDllPath != null) newDllPathList.Add((path, editedDllPath));
}
foreach (var asm in dlls.Select(x => x.asm)) asm.Dispose();
foreach (var (originalDllPath, editedDllPath) in newDllPathList) {
File.Delete(originalDllPath);
File.Move(editedDllPath, originalDllPath);
}
}
// dll内のメソッド定義と、メソッド参照の名前を書き換える
private static string RenameMethods(AssemblyDefinition asm,
IReadOnlyDictionary<string, string> renameMethods) {
var isDirty = false;
// メソッド参照一覧を取得する
var methodReferences = asm.Modules
.SelectMany(x => x.Types)
.SelectMany(x => x.Methods)
.Select(x => x.Body)
.Where(x => x != null)
.SelectMany(x => x.Instructions)
.Where(i => i.Operand is MethodReference)
.Select(i => ((MethodReference) i.Operand).GetElementMethod())
// 書き換え対象のメソッドを参照しているところだけ
.Where(x => renameMethods.ContainsKey(x.FullName));
// メソッド参照の書き換え
foreach (var methodReference in methodReferences) {
methodReference.Name = renameMethods[methodReference.FullName];
isDirty = true;
}
// メソッド定義一覧を取得する
var methodDefinitions = asm.Modules
.SelectMany(x => x.Types)
.SelectMany(x => x.Methods)
// 書き換え対象のメソッドを定義しているところだけ
.Where(x => renameMethods.ContainsKey(x.FullName));
// メソット定義の書き換え
foreach (var methodDefinition in methodDefinitions) {
methodDefinition.Name = renameMethods[methodDefinition.FullName];
isDirty = true;
}
if (isDirty == false) return null;
var tempPath = Path.GetTempFileName();
// 名前をつけてファイルを書き出し
asm.Name = new AssemblyNameDefinition(asm.Name.Name, asm.Name.Version);
asm.Write(tempPath);
return tempPath;
}
}
各処理について説明します。 以下のコードはUnityが生成した BuildReport
から、CecilのAssemblyDefinition
を読み込む処理です。
今回はManagedLibrary全てを対象にしましたが、場合によってはもっと絞り込んだ方がいいでしょう。
public void OnPostBuildPlayerScriptDLLs(BuildReport report) {
var dlls = report.files
.Where(x => x.role == "ManagedLibrary")
.Select(x => (x.path, asm: AssemblyDefinition.ReadAssembly(x.path))).ToArray();
RenameMethods
の中ではたくさんのLinqが書かれていますが、実際にDLLを書き換えているコードは少しだけで、 MethodReference.Name
と MethodDefinition.Name
に新しい名前を指定して、最後に asm.Write
でDLLをファイルに書き出しているところだけです。
private static string RenameMethods(AssemblyDefinition asm,
IReadOnlyDictionary<string, string> renameMethods) {
...
// メソッド参照の書き換え
foreach (var methodReference in methodReferences) {
methodReference.Name = renameMethods[methodReference.FullName];
}
...
// メソット定義の書き換え
foreach (var methodDefinition in methodDefinitions) {
methodDefinition.Name = renameMethods[methodDefinition.FullName];
}
...
// 名前をつけてファイルを書き出し
asm.Name = new AssemblyNameDefinition(asm.Name.Name, asm.Name.Version);
asm.Write(tempPath);
}
これを行って、ビルドされたアプリを逆コンパイルしてみた結果は次のようになりました。 名前が意図通りに変更されていることがわかります。
ここではメソッド名の書き換えだけでしたが、クラス名やフィールド名を書き換えることもできます。 ただ、全てをランダムに書き換えたりすると、Unity固有のUpdateなども書き換わり、うまく動かなくなることも考えられます。 名前を隠すことにどれだけ意味があるのかと考えると・・・それほど意味は無いようにも思いますので、趣味の域を出ない気がします。
おわりに
AssetStudio等のアセット解析ツールによるカジュアルハックからTextAssetとSpriteAtlasを保護する方法を紹介しました。しかし、暗号化・復号化処理自体を逆コンパイルされたりして解読されてしまうこともあります。 結局のところ、アプリを公開した時点でアプリ内のアセットも全て解読されてしまうという前提を持った方がいいことは変わりませんので、過信は禁物です。