チートには高度な技術が必要なのか ~Unityアプリ改変の例
すでにビルドされてバイナリファイルになっているプログラムを改変する、ネットワークを流れる通信内容やコンピューター内のメモリの値を書き換える、といったチートで使われる攻撃方法は何やら難しい技術のようにも見えます。たしかに、チートを行う攻撃者が高い技術力を持っていると思われる事案もあり、ゲーム側が備える高度な保護処理が突破されてしまうような場合もあります。しかしながら実際に多いのは簡単に行えてしまうチートであり、簡単だからこそ誰にでもチートができてしまい被害が大きくなるというのがよくあるケースです。
チートが高度な技術のように見えるのは、脆弱性情報データベースで公開されるような脆弱性を突いた攻撃と同じようなものに見えるからではないかと思われます。バッファーオーバーフローを突く攻撃として謎のバイト列をサーバーに送りつけると任意のコードを実行できてしまうといった話は、どういう仕組みでそうなってしまうのかの理解も難しいですし、仕組みが理解できても「どうしてそんな攻撃を思いつけるのだろうか」と思われるものばかりです。チートの仕組みは一見するとこの種の脆弱性を突いた高度な攻撃に似ていて、メモリを改変する話はバッファーオーバーフローを突いてのメモリの上書きに、通信を改変する話はサーバーに送りつける謎のバイト列に、プログラムを改変する話は脆弱性を突いての任意コードの実行に、それぞれ似ているようにも見えます。実際には両者はまったく異なるもので、チートの多くは「まあ、そうやって攻撃するだろうね」と思われるような内容です。
ここでは、この「チートはそう難しいことではない」という感覚を疑似体験するべく、Unityを使って作成したアプリのプログラムを改変してみることにします。文章量の都合等もあるので極度に簡略化したモデルにはなっていますが、プログラムを改変してチートを行うのにそれほど高度な技術が必要なわけではないことは感じられるかと思います。
アプリの内容
以下のようなC#スクリプトを作成し、適当なゲームオブジェクトのコンポーネントにすることで実行させるだけのアプリです。今回は、これをAndroidアプリにビルドしたものに対してプログラム改変を行ってみます。
using UnityEngine;
public class MinimalGame : MonoBehaviour
{
void Start() {
Debug.Log("score = " + Random.Range(0, 10));
}
}
アプリを起動すると「score = N」のようなログ出力が行われる様子がadb logcatで確認できます。ここでNは0から9までのいずれかの値を取る乱数です。この「score =」として出力される値を、99のような異常に大きな値にするというプログラム改変を行うことにします。
.dllファイルの逆アセンブル
UnityでAndroidアプリを作成した場合、C#で書いたプログラム内容は.apkファイル内の「assets/bin/Data/Managed/Assembly-CSharp.dll」というファイルに含まれるようになります。これはバイナリファイルですが、.NET Frameworkに含まれるIldasm.exeやMonoに含まれるmonodis(1)といったツールによって逆アセンブルすることで、以下のようなテキストファイルに変換できます。
.assembly extern UnityEngine
{
.ver 0:0:0:0
}
.assembly extern mscorlib
{
.ver 2:0:5:0
.publickeytoken = (7C EC 85 D7 BE A7 79 8E ) // |.....y.
}
.assembly 'Assembly-CSharp'
{
.custom instance void class [mscorlib]System.Runtime.CompilerServices.RuntimeCompatibilityAttribute::'.ctor'() = (
01 00 01 00 54 02 16 57 72 61 70 4E 6F 6E 45 78 // ....T..WrapNonEx
63 65 70 74 69 6F 6E 54 68 72 6F 77 73 01 ) // ceptionThrows.
.hash algorithm 0x00008004
.ver 0:0:0:0
}
.module 'Assembly-CSharp.dll' // GUID = {A17528F0-B33E-437D-9112-75403AFC9DF9}
.class public auto ansi beforefieldinit MinimalGame
extends [UnityEngine]UnityEngine.MonoBehaviour
{
// method line 1
.method public hidebysig specialname rtspecialname
instance default void '.ctor' () cil managed
{
// Method begins at RVA 0x20ec
// Code size 7 (0x7)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void class [UnityEngine]UnityEngine.MonoBehaviour::'.ctor'()
IL_0006: ret
} // end of method MinimalGame::.ctor
// method line 2
.method private hidebysig
instance default void Start () cil managed
{
// Method begins at RVA 0x20f4
// Code size 29 (0x1d)
.maxstack 8
IL_0000: ldstr "score = "
IL_0005: ldc.i4.0
IL_0006: ldc.i4.s 0x0a
IL_0008: call int32 class [UnityEngine]UnityEngine.Random::Range(int32, int32)
IL_000d: box [mscorlib]System.Int32
IL_0012: call string string::Concat(object, object)
IL_0017: call void class [UnityEngine]UnityEngine.Debug::Log(object)
IL_001c: ret
} // end of method MinimalGame::Start
} // end of class MinimalGame
逆アセンブル結果の改変と.dllファイルへの再アセンブル
今回体験するプログラム改変ではランダムに変化する値を改変することを目指すため、まずはAssembly-CSharp.dllの逆アセンブル結果の中でrandomのような内容がないかを検索します。結果、48行目に
IL_0008: call int32 class [UnityEngine]UnityEngine.Random::Range(int32, int32)
のようにあるのが見つかり、前後を見てみると
IL_0000: ldstr "score = "
IL_0005: ldc.i4.0
IL_0006: ldc.i4.s 0x0a
IL_0008: call int32 class [UnityEngine]UnityEngine.Random::Range(int32, int32)
IL_000d: box [mscorlib]System.Int32
IL_0012: call string string::Concat(object, object)
のようになっています。UnityEngine.Random::Range(int32, int32)が引数に指定された上限と下限の間に一様分布する乱数を返すメソッドであることと、string::Concat(object, object)が引数を連結した文字列を返すメソッドであること、およびログに出力されるのがscore = Nのような文字列であることを考えると、
IL_0005: ldc.i4.0
IL_0006: ldc.i4.s 0x0a
IL_0008: call int32 class [UnityEngine]UnityEngine.Random::Range(int32, int32)
の部分を例えば
//IL_0005: ldc.i4.0
IL_0006: ldc.i4.s 99
//IL_0008: call int32 class [UnityEngine]UnityEngine.Random::Range(int32, int32)
のように改変すれば常にscore = 99になるのではないかと推定されます。このとき実際にそうなるか確信が持てなくても、攻撃者は当て推量で試してみて、期待どおりの結果が得られなければまた他の場所を探してみるといった試行錯誤的な方法でチートを試みてきます。
Ildasm.exeやmonodis(1)によって生成した逆アセンブル結果は、.NET Frameworkに含まれるIlasm.exeやMonoに含まれるilasm(1)といったツールによってアセンブルすることで、.dllファイルに変換することができます。今回の例ではこれが意図どおりの改変となることが、生成された.dllファイルで.apkファイル中のAssembly-CSharp.dllを上書きしたものをAndroid端末にインストールして実行してみることで確認できます。なお、この際には.apkファイルの電子署名を付け直す必要があります。
Unityは危ない? iOSは安全?
ここで紹介しているプログラム改変の例について、逆アセンブル結果が元になったC#ソースファイルの内容と酷似していることに気づかれた方もいるかと思います。MinimalGameというクラス名やStartというメソッド名だけでなく、スーパークラスがUnityEngine.MonoBehaviourであることや、void Start()のメソッドシグニチャとprivateメソッドであること、デフォルトコンストラクターを除けばMinimalGameクラスには他にメンバーはないことなどが、逆アセンブル結果から容易に読み取れます。
これはAssembly-CSharp.dllに含まれる内容が中間言語であることによるものです。中間言語はCPUによって実行される機械語ではなく、実行前にもう一度コンパイルされて機械語に変換されます。中間言語にはいろいろな種類のものがありますが、C#コードのコンパイル結果として出力される中間言語はクラスやメソッド等のメタ情報を豊富に含む極めて読みやすいものになっています。
C#を使用するUnityではアプリの一部として中間言語コードが含まれることについて、C++などを使用してアプリに中間言語コードが含まれないようにした場合に比べて、チート被害を受けやすくなるという声を聞くことがあります。これには、確かにそうだといえる部分とあまりそうともいえない部分があります。中間言語コードは解析や改変が容易であるというのは間違いありませんが、ネイティブの実行ファイルを逆アセンブルした結果も部分部分であればまったく読めないというほど複雑なものではありません。チートが行われる際にプログラムの広い範囲にわたって処理が大きく改変されることはほとんどないため、部分的にある程度は読めるのであればチートのおそれは十分にあるといえるでしょう。
また、ファイル改変・通信改変・メモリ改変はアプリを開発したプログラミング言語とは無関係に行われるチートですが、手法としてはこちらのほうが容易であることからプログラム改変に比べるとより多くのチート被害を発生させています。Unityアプリに対するチートが難しくないというのは正しい主張といえますが、Unityアプリでなくてもチートは難しくないというのもまた正しい主張といえるのではないでしょうか。
同様の話として、AndroidではiOSよりもチートの被害を受けやすいというものもあります。Androidアプリの開発に用いられるJavaは、C#のように中間言語にコンパイルされること、Androidではシェルアクセスが標準でサポートされていること、AndroidではGoogle Playを通さずに.apkファイルを直接インストールできること、などがその論拠とされます。これも確かにそうであるといえる部分はありますが、大して違わないというのが実際のところです。
なお、UnityであってもiOSアプリを作る場合には中間言語コードがアプリに含まれることはありません。これは、iOSアプリにビルドされる場合は中間言語コードから機械語コードへのコンパイルもアプリのビルド時に済まされるためです。この仕組みはIL2CPPと呼ばれるもので、2016年7月にリリースされたUnity 5.4からはAndroidアプリにビルドする場合でもこれがサポートされるようになりました。普及にはいましばらくの時間がかかるように思われますが、やがてはUnityで作られたアプリには中間言語コードが含まれるという認識もなくなっていくでしょう。
