この記事はLINE Advent Calendar 2016の16記事目です。
こんにちは、LINEエンジニアの愛甲健二です。所属は「セキュリティ室」の「Application Security Team」というところで、主にリリース前のGames/Appsの診断を行っている、いわゆる一般的なセキュリティエンジニアです。今日はUnityに関するセキュリティ視点の入門記事を書きたいと思います。
はじめに
そもそも「Games/Appsのセキュリティ診断って具体的に何をするのか」という話ですが、基本的にはアプリの解析と、通信プロコトルの解析/診断をメインに行います。
アプリの解析にはいくつかのツールを使うのですが、まず.dexを分析するためにJava Decompilerを使います。有名なものにJD-GUIがあります。ただ、デコンパイルに失敗するメソッドがあったりするので、そのときはsmali(AndroidのJavaVM実装)を直接読みます(smali)。smaliはApktoolを使って.apkを展開するとファイルが作成されます。コード分析においてはJava、ARM、そしてIL(Intermediate Language)が読めればほぼ問題ないので、それぞれJD-GUI、IDA、JustDecompile辺りを使います。これらは後ほど改めて解説します。
またゲームサーバとのHTTPS通信も確認したいため商用のBurp Suiteを使います。MITMについてはこの記事では触れませんが、「[Unity/C#]WWW/HttpWebRequestにおける中間者攻撃の危険性を考慮した通信プログラムまとめ」という記事が対策も含め詳細に解説されています。あとは一般のメモリチートツール/自作ツールを使って簡単にcheckしたり、メモリをdumpして内容を確認する、また私はあまり使いませんが、Xposed、Frida、Cydia Substrateを使い、動的に動作を検証することもあります。
ゲームにおけるセキュリティリスクは大きく2つあり、ひとつは「悪意あるユーザーがゲームプレイを(大幅に)簡略化できるもの」もうひとつは「悪意あるユーザーが他ユーザーに被害を与えられるもの」です。
前者は、他プレイヤーに直接的には被害を与えないが、ゲームプレイを大幅に簡略化できるもので、例えば「アイテムやコインを無限に入手できる」「スタミナを一瞬で回復できる」「botによりゲームプレイを自動化し、プレイしなくても強くなれる」といったものが挙げられます。こちらは、アプリを解析されるといずれ実現される、いわゆる対策不可能問題ですが、最悪サーバ側で各ユーザーのプレイログをベースに異常検知(bot/cheat/abusing検知)を行えばアカウントを凍結できます。
後者は「他ユーザーの持つアイテムを自由に売ることができる」「PlayerIDを差し替えることで他ユーザーの認証を突破できる」「他ユーザーの個人情報を(ゲームプロトコルを通して)入手できる」といった例が挙げられます。ゲームの通信は大抵は独自に暗号化されているため、Black-boxによる診断だと、まずアプリの解析を行い、通信プロトコルを分析した上ではじめて調査ができます。当たり前ですが、こちらの方が脆弱性としてはCriticalです。
最近だとほとんどのゲームはUnityもしくはCocos2d-xで作られているため、これらGame Engineの仕組みを理解し、アプリ(ゲーム)を解析し、その上でサーバ側の脆弱性を調査するのがGame Security診断の業務となります。今回はその中でもUnityに関係する部分に限定して解説したいと思います。
Unity
Unityは有名な「Game Engine」のひとつで、最近はAndroid/iOS向けの、いわゆるスマートフォンゲームによく利用されています。マルチプラットフォームに対応しており、Android/iOSはもちろん、その他のスマートフォン、PC、各種ゲーム機用の実行ファイルを出力でき、様々な開発シーンで使われているGame Engineです。今回はAndroid/iOSに限定した(どちらかというとメインはAndroidで、iOSは補足的に説明する感じの)話になりますが、Unityについてセキュリティ視点で書きたいと思います。
またセキュリティ視点なのでプログラミングやゲームの作り方についてはまったく触れません。内容はUnityの仕組みに関する初歩的なもので、主にMono(IL)、IL2CPP辺りに興味がある方を対象としています。
Game Securityはわりとマイナーな技術分野ですが、少しでも興味を持ってもらえたら幸いです。
Mono
Monoは.NET Framework互換環境を提供するオープンソースソフトウェアのひとつです。Unityはこれを用いてマルチプラットフォームを実現しています(mono-project)。Unityアプリは、IL(Intermediate Language)と呼ばれる中間言語を実行ファイル内に持ち、実行時に機械語へ変換、実行します。ILにはCIL(Common Intermediate Language)、MSIL(Microsoft Intermediate Language)がありますが、この辺はあまり本筋と関係ないため説明は省きます(Common Language Infrastructure (CLI) Partitions I to VI)。
ゲーム開発者が書いたコードは中間言語であるILとして実行ファイル内に保持されているため、まずはそれを確認します。
UnityのAsset Storeから、Survival ShooterというUnityを学ぶためのチュートリアルゲーム「Survival Shooter tutorial」をUnityへImportします。これをAndroid向けにビルドしてapkファイルを作成します。apkファイルを展開し、\assets\bin\Data\Managed以下を見ると、Assembly-CSharp.dllファイルがあります(iOSだとNightmares.app\Data\Managed以下にあります)。この中にゲームのコードが入っています。
ゲーム本体となる*.dllは.NETのDLLファイルであるため、.NET用のdecompilerでILに戻せます。decompilerには有名なもので、ILSpy、JustDecompileがあります。ILを編集するためにReflexilも必要です。JustDecompileならば、起動後Plugins -> Plugins Managerを選択し、Assembly EditorをダウンロードすればPluginとして組み込まれます。
Assembly-CSharp.dllをJustDecompileで開き、ReflexilでPlayerHealthのTakeDamageメソッドの先頭Instructionをretに変更します。
.method public hidebysig instance void TakeDamage ( int32 amount ) cil managed { IL_0000: ldarg.0 # 変更: ldarg.0 -> ret IL_0001: ldc.i4.1 IL_0002: stfld bool CompleteProject.PlayerHealth::damaged IL_0007: ldarg.0 IL_0008: dup }
これによりTakeDamageメソッドは何も実行せずにcall元に処理を返します。TakeDamageはPlayerのダメージを計算、反映する処理なので、変更したAssembly-CSharp.dllをapkの中に戻してre-pack/re-signすれば、Playerがダメージを受けない状態でゲームを続けられます。
IL2CPP
IL2CPPはその名の通り、ILをCPPに変換するツール(オプション)です。UnityのBuild SettingsからPlayer Settings -> Other Settings -> Configuration -> Scripting Backendで設定できます。IL2CPPに関する詳細はAN INTRODUCTION TO IL2CPP INTERNALSを参照してください。
IL2CPPでビルドしていた場合*.dllはなく、代わりにlibil2cpp.so内にゲームコードが存在します(iOSの場合はMach-O実行ファイルに含まれます)。この場合プロセッサに対応した機械語になるため、解析には有料の逆アセンブラであるIDAがよく使われます。
IL2CPPを使った場合、メソッド名、参照文字列は\assets\bin\Data\Managed\Metadata\global-metadata.dat内に置かれており、libil2cpp.soから動的に読み込まれます。そのためIDAでsoファイルを開いただけではメソッド名、参照文字列は見えません。よって構造体、オフセットをベースにこれらを復元する(名前付け)するIDA Script(unity_metadata_loader)を使います(Demo)。unity_metadata_loaderはオープンソースなので、詳細が知りたい方はコードを読んでみてください。作者の資料も公開されています(Mobile Game Security)。
Androidの場合はlibil2cpp.soを、iOSの場合はMach-O実行ファイルをIDAで開いて上記Scriptを実行します。
さきほどと同様にSurvival Shooter tutorialのコードをそのまま使い、IL2CPPを使ったときと使わないときの差を調べます。以下がTakeDamageメソッドを、IL2CPPを「使用しない or 使用した」ときのコードです。全部載せると長いため、(TakeDamageメソッドの)this.playerAudio.Play()以下を載せています。上がILでJustDecompileを、下がARMでIDAを使っています。
// Assembly-CSharp.dll (JustDecompile) .method public hidebysig instance void TakeDamage ( int32 amount ) cil managed { (省略) IL_0027: ldarg.0 IL_0028: ldfld class [UnityEngine]UnityEngine.AudioSource CompleteProject.PlayerHealth ::playerAudio IL_002d: callvirt instance void [UnityEngine]UnityEngine.AudioSource::Play() IL_0032: ldarg.0 IL_0033: ldfld int32 CompleteProject.PlayerHealth::currentHealth IL_0038: ldc.i4.0 IL_0039: bgt IL_004f # 条件分岐:生存ならIL_004fへ IL_003e: ldarg.0 IL_003f: ldfld bool CompleteProject.PlayerHealth::isDead IL_0044: brtrue IL_004f # 条件分岐:生存ならIL_004fへ IL_0049: ldarg.0 IL_004a: call instance void CompleteProject.PlayerHealth::Death() IL_004f: ret
// libil2cpp.so (IDA + unity_metadata_loader) .text:000ABCB0 PlayerHealth$TakeDamage (省略) .text:000ABD24 MOV R1, #0 .text:000ABD28 BL AudioSource$Play_20505 .text:000ABD2C LDR R0, [R4,#0x10] # R0 = this.currentHealth .text:000ABD30 CMP R0, #0 .text:000ABD34 BGT loc_ABD44 # 条件分岐:生存ならloc_ABD44へ .text:000ABD38 LDRB R0, [R4,#0x44] # R0 = this.isDead .text:000ABD3C CMP R0, #0 .text:000ABD40 BEQ loc_ABD4C # 条件分岐:Deathならloc_ABD4Cへ .text:000ABD44 loc_ABD44 # 生存判定 .text:000ABD44 VPOP {D8} .text:000ABD48 LDMFD SP!, {R4-R7,R11,PC} .text:000ABD4C loc_ABD4C # Death判定 .text:000ABD4C MOV R0, R4 .text:000ABD50 VPOP {D8} .text:000ABD54 LDMFD SP!, {R4-R7,R11,LR} .text:000ABD58 B PlayerHealth$Death
000ABD2CはR0へthis.currentHealthを読み込む処理、000ABD38はthis.isDeadを読み込む処理です。000ABD4CがDeath判定、000ABD44が生存判定なので、currentHealthとisDeadの結果によってこのいずれかにジャンプしています。IL同様に、このARM命令を任意に変更すれば好きな処理に改変できます。
ついでにそれぞれのデコンパイル結果も見てみましょう。JustDecompileとHex-Rays Decompilerを使っています。
// Assembly-CSharp.dll (JustDecompile) public void TakeDamage(int amount) { (省略) this.playerAudio.Play(); if (this.currentHealth <= 0 && !this.isDead) this.Death(); }
// libil2cpp.so (IDA + unity_metadata_loader) int __fastcall PlayerHealth__TakeDamage(int a1, int a2) { (省略) AudioSource__Play_70868(); result = *(_DWORD *)(v2 + 16); if ( result <= 0 ) { result = *(unsigned __int8 *)(v2 + 68); if ( !*(_BYTE *)(v2 + 68) ) JUMPOUT(&PlayerHealth__Death); } return result; }
ILはほとんど完璧にソースコードに戻っています。ARMの方はというと、ソースコードと呼ぶには少し可読性が悪いですが、十分に処理を追えます。2つの条件を満たしたときにDeathに飛ぶことが分かります。
コードだけだと色気がないので、IDAの画面キャプチャも載せておきます。
スマートフォンゲームに限らず、あらゆるゲームはバイナリを解析(リバースエンジニアリング)されれば独自のクライアント、いわゆるbotが作られ、自動でゲームをplayするユーザーが増えます。人気のゲームであればあるほどbotも作られやすいので、人気ゲームほど様々なセキュリティ対策を実装している傾向にあります。
暗号化/難読化
Unityで普通にビルドすると上記で示したようなコードになるのですが、これをさらに解析されづらくするために対策ツールを利用することがあります。いわゆるPackerと呼ばれるもので近年だと有料のものも多く、使用すると実行コードに対して暗号化/難読化を行います。.dexファイルのみを対象としているもの、Unityアプリにも対応しているものなど、種類は多種多様です。
Unityにおいては.Net/PE用のPacker(.Net/PE Packer)も一部使用できますが、.Net/PE PackerはWindowsのシステムに依存する実装に変換する場合も多く、Unityの*.dllにそのまま適用しても動かないことがほとんどです。有名なものに.Net Reactor, Appfuscator, ConfuserEx, Babelがあります。
試しにAppfuscatorを利用した例を見てみましょう。以下はGameOverManagerのUpdateメソッドです。
private void Update() { if (this.playerHealth.currentHealth <= 0) this.anim.SetTrigger("GameOver"); }
C#のコードは2行です。これをILにすると10行程度の命令になります。
.method private hidebysig instance void Update () cil managed { IL_0000: ldarg.0 IL_0001: ldfld class CompleteProject.PlayerHealth CompleteProject.GameOverManager::playerHealth IL_0006: ldfld int32 CompleteProject.PlayerHealth::currentHealth IL_000b: ldc.i4.0 IL_000c: bgt IL_0021 # 条件分岐:currentHealthが1以上ならIL_0021へ IL_0011: ldarg.0 IL_0012: ldfld class [UnityEngine]UnityEngine.Animator CompleteProject.GameOverManager::anim IL_0017: ldstr "GameOver" IL_001c: callvirt instance void [UnityEngine] UnityEngine.Animator::SetTrigger(string) IL_0021: ret }
これをAppfuscatorでPacking(難読化)すると以下のようになります。ちなみにJustDecompileではC#への変換(デコンパイル)に失敗しますので、ILのみ載せます。
.method private hidebysig instance void Update () cil managed { .locals init ( [0] int32 V_0, [1] int32 V_1 ) IL_0000: ldarg.0 IL_0001: ldloc.0 IL_0002: ldc.i4.s 34 IL_0004: add IL_0005: stloc.0 IL_0006: ldfld class CompleteProject.PlayerHealth CompleteProject.GameOverManager::a IL_000b: ldfld int32 CompleteProject.PlayerHealth::b IL_0010: ldc.i4.0 IL_0011: bgt.s IL_007a # 条件分岐:b(=currentHealth)が1以上ならIL_007aへ IL_0013: ldarg.0 IL_0014: ldfld class [UnityEngine]UnityEngine.Animator CompleteProject.GameOverManager::b IL_0019: ldloc.0 IL_001a: ldc.i4.s 81 IL_001c: add IL_001d: stloc.0 IL_001e: sizeof [mscorlib]System.Double IL_0024: ldc.i4 8977 IL_0029: add IL_002a: sizeof [mscorlib]System.Byte IL_0030: ldc.i4 12007 IL_0035: add IL_0036: ldsfld int32 ScoreManager::a IL_003b: stloc.1 IL_003c: ldloc.1 IL_003d: ldc.i4 9935 IL_0042: sub IL_0043: ldloc.1 IL_0044: ldc.i4.s 15 IL_0046: mul IL_0047: ldloc.1 IL_0048: ldc.i4.s 17 IL_004a: mul IL_004b: add IL_004c: neg IL_004d: ldc.i4.5 IL_004e: shr.un IL_004f: beq.s IL_0053 IL_0051: br.s IL_0061 IL_0053: ldsfld class [mscorlib]System.Type[] [mscorlib] System.Type::EmptyTypes IL_0058: ldlen IL_0059: ldc.i4 1758338841 IL_005e: add IL_005f: br.s IL_006a IL_0061: sizeof [mscorlib]System.UInt32 IL_0067: ldc.i4.s 113 IL_0069: add IL_006a: nop IL_006b: call string '<Module>'::c(int32, int32, int32) # "GameOver"文字列の生成 IL_0070: ldloc.0 IL_0071: ldc.i4.s 81 IL_0073: sub IL_0074: stloc.0 IL_0075: callvirt instance void [UnityEngine] UnityEngine.Animator::SetTrigger(string) IL_007a: ret }
難読化の目的は、まったく同じ結果となる処理を無駄に長く、冗長にすることで可読性を下げ、解析を困難にする点にあります。またメソッド名や文字列を消し、一目見ただけでは何の処理か分からないようにします。上記のコードだとadd/sub/mulといった命令(無駄な処理)が散見されるのが確認できます。
別の手法も紹介しましょう。
UnityはゲームのコードをDLL(IL)として保持することでマルチプラットフォームを実現しています。そして*.dllは(apk内の)lib/[arch]/libmono.soからロードされており、このモジュールのソースコードはUnity-TechnologiesのGitHubにあります(Unity-Technologies)。
このlibmono.soは、自前でビルドすることも可能です。
$ ./external/buildscripts/build_runtime_android.sh
soファイルはbuilds/embedruntimes/android/[arch]/以下に作成されます。libmono.soに復号(デコード)処理を追加すれば*.dllを暗号化(エンコード)でき、実際に似たようなことを行っているPackerもあります。
たとえばmono/metadata/image.cのmono_image_open_from_data_with_name関数に以下のコードを追加し、Assembly-CSharp.dllをxorした状態で保持させます。すると実行時にxorデコードされてメモリに展開されます。ただし、メモリ上にはそのままの(平文の)Assembly-CSharp.dllが展開されているため、プロセスのメモリをdumpすると*.dllが取得できます。
// mono/metadata/image.c MonoImage * mono_image_open_from_data_with_name ( char *data, guint32 data_len, gboolean need_copy, MonoImageOpenStatus *status, gboolean refonly, const char *name) { MonoCLIImageInfo *iinfo; MonoImage *image; char *datac; // -- 追加 if (name != NULL && strstr(name, "Assembly-CSharp.dll")) { data[0] ^= 0xFF; } // -- if (!data || !data_len) { if (status) *status = MONO_IMAGE_IMAGE_INVALID; return NULL; } ....
上記の例ではxorにしていますが、この箇所に(例えばAESの)復号処理を入れておき、その上で*.dllを暗号化しておけば、DLLファイル単体からではゲームの処理を分析できなくなります。
libmono.soの各関数はlibunity.soからcallされます。libunity.soは実質的なUnityのメインモジュールであり、Unityに関する処理はほぼこの中に存在します。libunity.soは、まずJNI_Onload(モジュールがロードされたときに実行される関数)で自身のいくつかの関数(NativeXXXX)をJava側のUnityPlayerクラスの(Native)メソッドにリンクさせ、あるタイミングでlibmono.soを使ってゲーム本体となる*.dllをロード、実行します。もしIL2CPPを使っていた場合はmono.soはなく、代わりにlibil2cpp.soがロードの対象になります。
Unityの起動からコアの処理にくるまでの流れはプラットフォームごとに異なりますが、Androidの場合はだいたい以下の流れになります。
- AndroidManifest.xmlのmain activityよりUnityPlayerActivityが起動
- UnityPlayerActivityのonCreateメソッド実行
- onCreateメソッド内でnew UnityPlayer実行
- UnityPlayerのStatic initializer内でlibmain.soをロード
- libmain.soのJNI_OnloadでNativeLoaderのload、unloadメソッドとlibmain.so内の関数をリンク
- UnityPlayerのConstructor実行
- Constructor内でNativeLoaderのloadメソッド(=libmain.so内の関数)を実行
- libmain.soの関数内でlibmono.so, libunity.soをロード
- libunity.soのJNI_OnloadでNativeXXX関数をUnityPlayerの各メソッドとリンク
- 以降UnityPlayerとlibunity.soがゲームの処理を行う
AndroidManifest.xmlから、最初にmain activityとして定義されているUnityPlayerActivityクラスが起動します。このクラスはonCreateでUnityPlayerクラスを作ります。UnityPlayerはStatic initializerでlibmain.soをロードします。libmain.soはJNI_Onloadで自身の2つのNative関数(便宜上f1, f2とする)をJava側にあるNativeLoaderクラスのload, unloadメソッドにそれぞれリンクさせます。
そして、UnityPlayerのコンストラクタでNativeLoaderのloadメソッド(=libmain.so内のNative関数f1)が実行され、libunity.so, libmono.soをそれぞれロードします。ちなみにf2関数ではアンロードの処理が走ります(メソッド名から明確ですが)。そしてlibunity.soはJNI_OnloadでNativeXXXX関数をUnityPlayerクラスのメソッドとリンクさせます。これ以降はlibunity.soがゲーム本体のコードを解釈、実行する処理となります。
Androidではある程度役割ごとにモジュールが分離していますが、iOSでは1つのファイルにすべて含まれます。IL2CPPを使っていた場合は、さらにゲーム本体のコードもUnityコアの処理といっしょに1つのファイルに入ります。
Packerのほとんどは、このようなUnityの起動処理の途中に任意のコードを追加する仕様になっています。守りたいものはゲームコードであるため、それがなるべく隠されるように難読化/暗号化コードが追加されます。ただし、メモリ上にはどうしてもオリジナルの*.dll、*.soファイルが存在してしまうため、メモリをdumpされることに関しては別の対策が必要になります。
さいごに
PlayStation 4やWii Uといった家庭用ゲーム機とは異なり、PCやAndroid/iOSに代表されるスマートフォンで動作するゲームは、動作環境がオープンなプラットフォームであるため、ゲームに対するReversingも容易です。実行時のプロセスの保護においても、プロセスがデバッガからAttachされないよう監視する別プロセスを生成する方法や、メモリ上でも暗号化されたまま保持しておき、必要になったときだけ一時的に復号するといったことが考えられます。しかし、いずれにしても100%の対策は存在せず、逆にゲームの安定性を阻害する要因になることもあります。
個人的な意見ではありますが、「はじめに」でも触れたように、時間さえかければどんな対策もいつかは破られてしまいます。人気ゲームであればなおさらです。なので、必要以上にアプリ側のセキュリティ対策を行うよりも、仮に解析されたとしても、普通にゲームを楽しんでくださっている一般ユーザーには迷惑がかからないような設計にする方が重要じゃないかなとも思います。極端な話、解析されたとしてもbot/cheat/abusingであるならばサーバ側で異常を検知できます。一般のユーザーに迷惑をかけないようにすることがまず第一で、その上で不正ユーザーの対策をできる限り行うというのが、セキュリティ対策のあるべき形かなと思います。
さて、今回はUnityで作られたゲームはどのようなツールを用いて、どういった手法で解析されるのかを簡単に解説しました。なぜこのような記事を書いたのかというと、以前開発者の方から、意外とこういった視点でUnityを調査したことはない、開発者にはない視点でとても面白かった、という感想をもらったことがあったため、それを思い出して(あとちょうどLINE Advent Calendar 2016の企画に声をかけてもらったため)書かせていただきました。Game Securityという点ではまだまだ書き足りないことが多々あるのですが、それはまたの機会にとさせていただきます。
本記事では、Unityで作られたゲームコードが実行ファイル内ではどのように含まれているのか、どうやって解析されるのか、どのような対策が考えられるのかについてセキュリティエンジニアの視点で紹介しました。
この記事がゲーム開発/運営に関わる方に少しでも参考になれば幸いです。
※クリスマスには少し早いですが、本記事をもってLINE Advent Calendar 2016は終了となります。読んでいただいた皆様、ありがとうございました!