この記事は DeNA 23 新卒 Advent Calendar 2022 の14日目の記事です。
1. はじめに
こんにちは。初アドベントカレンダー参加&初Qiita執筆の佐藤(@hanaaaaaachiru)です。
普段はインディーゲーム制作を行ったり、はなちるのマイノートという技術ブログを運営していたりします。
今回はUnity製アプリに対するよくある不正行為とその対策について紹介したいと思います。
ただ通信に関連した話(例.URL
から推測して未公開のAssetBundle
を取得する、不正なスコアの送信)はせず、あくまでクライアント側のみを扱います。
また他人に迷惑をかけるような利用は必ずしないようにしてください。よろしくお願いします。
2. 目次
1.はじめに
2.目次
3.実験環境
4.下準備
5.よくある不正行為
5-1.コード解析
5-2.アセット解析
5-3.メモリ改竄
5-4.セーブデータ等のファイル改竄
6.おわりに
3. 実験環境
Unity 2021.3.0f1
Windows10
Build Target : Standalone Windows
4. 下準備
実験台になってもらうプロジェクトを作成しました。
中身は解析用に適当に作ったものなので遊べません。
5. よくある不正行為
以下について取り上げます。
- コード解析
- アセット解析
- メモリ改竄
- セーブデータ等のファイル改竄
書きすぎると不正行為を助長してしまう可能性があるので、ざっくりと概要だけ取り上げます。
5-1 コード解析
概要
プログラムを解析をするツールを用いて、どんな処理が行われているか調べることができます。
結論からいうとIL2CPP
を用いることでコード解析の難易度を上げることができるので、特に理由がない限り有効にしましょう。
IL2CPP (Intermediate Language To C++) は Unity が開発したスクリプティングバックエンドで、さまざまなプラットフォームのプロジェクトをビルドするときに Mono の代わりに使用できます。IL2CPP を使用してプロジェクトをビルドする場合、Unity は選択したプラットフォーム用のネイティブのバイナリファイル (例えば .exe、apk、.xap など) を作成する前に、スクリプトやアセンブリからの IL コードを C++ に変換します。IL2CPP を使用すると、Unity プロジェクトのパフォーマンス、セキュリティ、プラットフォームの互換性を向上させることができます。
https://docs.unity3d.com/ja/2019.4/Manual/IL2CPP.html
やり方
IL2CPPを使わない場合
C#
の中間言語であるIL
がdll
としてそのまま同梱されているので、それに対して解析を行います。
UnityではAssembly definitions
やAssembly References
を自身で定義しなければ、開発者が書いたコードは(基本は)Assembly-CSharp.dll
に含まれています。
例えばILSpy
というツールを使うことで逆コンパイルをすることができます。
ILSpy is the open-source .NET assembly browser and decompiler.
https://github.com/icsharpcode/ILSpy#ilspy----
StandaloneWindows
向けにビルドした場合は、○○_Data/Managed
の中に.dll
が入っています。
.dll
をILSpy
を使うと以下のように解析することができます。
// オリジナル (←このコメントは執筆時付け足したもの)
using UnityEngine;
public class Player : MonoBehaviour
{
[SerializeField] private float xBound;
private Transform _transform;
private void Awake()
{
_transform = transform;
}
private void Update()
{
if (Input.GetKey(KeyCode.LeftArrow) && _transform.position.x > -xBound)
{
_transform.Translate(-0.1f, 0, 0);
}
if (Input.GetKey(KeyCode.RightArrow) && _transform.position.x < xBound)
{
_transform.Translate(0.1f, 0, 0);
}
}
}
// 解析したもの (←このコメントは執筆時付け足したもの)
// Player
using UnityEngine;
public class Player : MonoBehaviour
{
[SerializeField]
private float xBound;
private Transform _transform;
private void Awake()
{
_transform = base.transform;
}
private void Update()
{
if (Input.GetKey(KeyCode.LeftArrow) && _transform.position.x > 0f - xBound)
{
_transform.Translate(-0.1f, 0f, 0f);
}
if (Input.GetKey(KeyCode.RightArrow) && _transform.position.x < xBound)
{
_transform.Translate(0.1f, 0f, 0f);
}
}
}
ちなみにILSpy
は中身を覗くだけですが、dnSpy
というツールでは書き換えも可能です。
dnSpy is a debugger and .NET assembly editor. You can use it to edit and debug assemblies even if you don't have any source code available.
https://github.com/dnSpy/dnSpy#dnspy---latest-release
IL2CPPを使う場合
例えばIl2CppDumper
というIL2CPP
でビルドされたバイナリから元のManaged DLL
に変換するツールを用いることでコードの解析をすることができます。
Unity il2cpp reverse engineer
Complete DLL restore (except code), can be used to extract MonoBehaviour and MonoScript
https://github.com/Perfare/Il2CppDumper
Windows Standalone
の場合は以下の2つのファイルをIl2CppDumper
に読み込ませます。
GameAssembly.dll
○○_Data/il2cpp_data/Metadata/global-metadata.dat
無事に実行ができるとDummyDll
というフォルダが生成されるので、その中身をILSpy
などで解析します。
// 解析したもの (←このコメントは執筆時付け足したもの)
// Player
using Il2CppDummyDll;
using UnityEngine;
[Token(Token = "0x2000005")]
public class Player : MonoBehaviour
{
[Token(Token = "0x4000007")]
[FieldOffset(Offset = "0x18")]
[SerializeField]
private float xBound;
[Token(Token = "0x4000008")]
[FieldOffset(Offset = "0x20")]
private Transform _transform;
[Token(Token = "0x600000A")]
[Address(RVA = "0x196510", Offset = "0x195910", VA = "0x180196510")]
private void Awake()
{
}
[Token(Token = "0x600000B")]
[Address(RVA = "0x196540", Offset = "0x195940", VA = "0x180196540")]
private void Update()
{
}
[Token(Token = "0x600000C")]
[Address(RVA = "0x196260", Offset = "0x195660", VA = "0x180196260")]
public Player()
{
}
}
Address
属性の値から、バイナリ(GameAssembly.dll
)の該当箇所を探し出します。
私の知識量ではこの先どう編集すればよいのか分からないのでなにも言えませんが、CPU
の命令セットを理解していれば編集できそうです。(勉強します...)
対策
コードの難読化を行うことが有効だと思います。
例えばConfuserEx2
という難読化ツールを用いることで、.dll
(.exe
)に対してコードの難読化を行うことができます。
ConfuserEx 2 is a free and open-source protector for .NET applications.
https://github.com/mkaring/ConfuserEx/wiki/Introduction
ConfuserEx2
は様々な難読化(protection
)を設定することができるのですが、例えば以下のprotection
を設定した場合はコードが以下のように変換されます。
-
Anti IL Dasm Protection
(anti ildasm
) -
Control Flow Protection
(ctrl flow
) -
Protection Hardening
(harden
)
// オリジナル (←このコメントは執筆時付け足したもの)
using UnityEngine;
public class Player : MonoBehaviour
{
[SerializeField] private float xBound;
private Transform _transform;
private void Awake()
{
_transform = transform;
}
private void Update()
{
if (Input.GetKey(KeyCode.LeftArrow) && _transform.position.x > -xBound)
{
_transform.Translate(-0.1f, 0, 0);
}
if (Input.GetKey(KeyCode.RightArrow) && _transform.position.x < xBound)
{
_transform.Translate(0.1f, 0, 0);
}
}
}
// 難読化したもの (←このコメントは執筆時付け足したもの)
// Player
using UnityEngine;
public class Player : MonoBehaviour
{
[SerializeField]
private float xBound;
private Transform _transform;
private void Awake()
{
_transform = base.transform;
}
private void Update()
{
if (Input.GetKey(KeyCode.LeftArrow))
{
goto IL_000f;
}
goto IL_0102;
IL_0102:
int num;
int num2;
if (!Input.GetKey(KeyCode.RightArrow))
{
num = -1390479301;
num2 = num;
}
else
{
num = -1172218411;
num2 = num;
}
goto IL_0014;
IL_0014:
while (true)
{
uint num3;
switch ((num3 = (uint)num ^ 0xAAD3DB21u) % 7u)
{
case 3u:
break;
default:
return;
case 5u:
{
int num6;
int num7;
if (_transform.position.x <= 0f - xBound)
{
num6 = -1214332492;
num7 = num6;
}
else
{
num6 = -1936459645;
num7 = num6;
}
num = num6 ^ ((int)num3 * -488439093);
continue;
}
case 1u:
_transform.Translate(0.1f, 0f, 0f);
num = (int)(num3 * 2036419465) ^ -1946491989;
continue;
case 0u:
_transform.Translate(-0.1f, 0f, 0f);
num = ((int)num3 * -1231275453) ^ -798382778;
continue;
case 6u:
{
int num4;
int num5;
if (_transform.position.x >= xBound)
{
num4 = 771771467;
num5 = num4;
}
else
{
num4 = 245490753;
num5 = num4;
}
num = num4 ^ (int)(num3 * 1098800460);
continue;
}
case 4u:
goto IL_0102;
case 2u:
return;
}
break;
}
goto IL_000f;
IL_000f:
num = -1743280129;
goto IL_0014;
}
}
このコードはIL2CPP
を利用せずにビルドをし、難読化した後にILSpy
を用いてコード解析したものです。
補足ですがプラットフォーム依存のprotection
があったり、一緒に難読化しない他アセンブリが存在する場合はメソッド名を変換してはいけない等いくつか注意点があります。
https://github.com/mkaring/ConfuserEx/wiki/Protections
またUnityの難読化アセットであるObfuscatorを利用するのも有効かつ簡単な手法でしょう。
5-2 アセット解析
概要
AssetStudio
を筆頭とするAssets
・AssetBundle
を解析するツールが出回っています。
AssetStudio is a tool for exploring, extracting and exporting assets and assetbundles.
https://github.com/Perfare/AssetStudio
GitHub
のreadme
にも記載されていますが、以下のファイルをサポートしています。
- Texture2D : convert to png, tga, jpeg, bmp
- Sprite : crop Texture2D to png, tga, jpeg, bmp
- AudioClip : mp3, ogg, wav, m4a, fsb. support convert FSB file to WAV(PCM)
- Font : ttf, otf
- Mesh : obj
- TextAsset
- Shader
- MovieTexture
- VideoClip
- MonoBehaviour : json
- Animator : export to FBX file with bound AnimationClip
またAssetBundle
がLZ4
やLZMA
に圧縮されていたとしても、問題なく解析することができます。
やり方
プロジェクト単位(フォルダを指定するとフォルダ内部のファイルを走査)もしくは解析したいファイルをAssetStudio
に読み込ませます。
ちなみにローカルからAssetBundle
を読み込む実装をしている場合は、大抵AssetBundle
がStreamingAssets
の中に含まれているので、○○_Data/StreamingAssets
の中にあります。
サーバーからロードしてローカルに保存しておく場合はC://User/[UserName]/AppData/LocalLow/[CompanyName]/[ProductName]
(Application.persistentDataPath
)が多いと思います。
対策
ファイルを暗号化することが有効です。
AssetBundle
に着目するなら以下の2種類の方法が考えられます。
- 暗号化したファイルを
AssetBundle
に含める -
AssetBundle
自体を暗号化する
こちらに関しては先人の方々が色々と知恵を絞られて実装を考えられています。
AssetBundleの暗号化について図でまとめてみた
Unity 2018-2019を見据えたDeNAのUnity開発のこれから DeNA TechCon 2019
私もあまり詳しいわけではないので、詳細は割愛させてください。(勉強します...)
例えばAESを用いてAssetBundle
自体を暗号化してみるとAssetStudio
では解析できないことが確認できます。
またAssetBundle
に限らずとも、普通のProject
内のAsset
に対しても暗号化処理を挟む手法を先人の方達が色々と考えられています。
Unity製アプリにおいてアセットを暗号化する手法
こちらのサイバーエージェントの記事が分かりやすく、ScriptedImporter
を利用したり、BuildPlayerWindow.RegisterBuildPlayerHandler
(IPreprocessBuildWithReport
だとAssetBundle
ビルドができないので使っていない模様)を使って暗号化処理を挟むあたりはなるほどなぁという感じです。
5-3 メモリ改竄
概要
Cheat Engine
を筆頭とするメモリ改竄を行うツールが出回っています。
Cheat Engine is a tool designed to help you with modifying single player games without internet connection so you can make them harder or easier depending on your preference(e.g: Find that 100hp is too easy, try playing a game with a max of 1 HP)
https://cheatengine.org/aboutce.php
やり方
メモリに格納されている値から、改竄したい値が格納されているアドレスを探し出します。
例. Score
を22
から23
へと変化させたときに、同じような値の変化をしているメモリのアドレスを調べる。
対策
メモリ上にそのままのデータを置かないようにする事が有効です。
よくある例としてはXOR
した値をメモリに書き込んでおき、利用する際に再度XOR
するというものです。
// XORの性質
平文 ^ 鍵 = 暗号文
暗号文 ^ 鍵 = 平文
お試しでメモリ上にXOR
を利用した値を置くような自作int
を作ってみました。
[Serializable]
public struct CustomInt : IFormattable, IEquatable<CustomInt>, IComparable<CustomInt>, IComparable<int>, IComparable
{
private static readonly Random Random = new Random();
[SerializeField] private int key;
[SerializeField] private int fakeValue;
public CustomInt(int value) : this(GenerateKey(), value){}
public CustomInt(int key, int value)
{
this.key = key;
fakeValue = Encrypt(key, value);
}
public static int Encrypt(int key, int value)
=> key ^ value;
public static int Decrypt(int key, int value)
=> key ^ value;
public static int GenerateKey()
=> Random.Next();
private static CustomInt Add(CustomInt input, int x)
{
var value = Decrypt(input.key, input.fakeValue);
value += x;
input.fakeValue = Encrypt(input.key, value);
return input;
}
public static implicit operator CustomInt(int value)
=> new CustomInt(GenerateKey(), value);
public static implicit operator int(CustomInt value)
=> Decrypt(value.key, value.fakeValue);
public static CustomInt operator ++(CustomInt input)
=> Add(input, 1);
public static CustomInt operator --(CustomInt input)
=> Add(input, -1);
public override bool Equals(object obj)
=> obj is CustomInt customInt && Equals(customInt);
public override int GetHashCode()
=> Decrypt(key, fakeValue).GetHashCode();
public bool Equals(CustomInt other)
=> other != null && key == other.key && fakeValue == other.fakeValue;
public int CompareTo(CustomInt other)
=> Decrypt(key, fakeValue).CompareTo(Decrypt(other.key, other.fakeValue));
public int CompareTo(int other)
=> Decrypt(key, fakeValue).CompareTo(other);
public int CompareTo(object obj)
=> Decrypt(key, fakeValue).CompareTo(obj);
public override string ToString()
=> Decrypt(key, fakeValue).ToString();
public string ToString(string format, IFormatProvider formatProvider)
=> Decrypt(key, fakeValue).ToString(format, formatProvider);
}
またAnti Cheat Toolkitというアセットにも同様の機能が備わっています。(Anti Cheat Toolkit
もXOR
している)
📌 共通の機能
メモリ内の変数を保護します。
https://assetstore.unity.com/packages/tools/utilities/anti-cheat-toolkit-2021-202695?locale=ja-JP
5-4 セーブデータ等のファイル改竄
概要
Unityでセーブを行う際は大抵以下のパスにファイルが保存されています。(Standalone Windows
の場合)
-
C://User/[UserName]/AppData/LocalLow/[CompanyName]/[ProductName]
(Application.persistentDataPath
)
PlayerPrefs
を利用している場合はかなり特殊で、HKCU/Software/[company name]/[product name]
という場所に入っています。
やり方
PlayerPrefsを利用していない場合
C://User/[UserName]/AppData/LocalLow/[CompanyName]/[ProductName]
(Application.persistentDataPath
)内に配置されているファイルを編集します。
ここは実装する人によって様々な手法を取っているので割愛します。
PlayerPrefsを利用している場合
レジストリエディターを利用して値の編集を行います。
Windows10
の場合はタスクバーの検索ボックスにregedit
と入力することでレジストリエディターを開けます。
HKEY_CURRENT_USER/SOFTWARE/[company name]/[product name]
を選択すると、データの一覧が表示されるはずです。
// PlayerPrefsを用いてセーブをする
PlayerPrefs.SetInt("LogCount", count);
画像の場合では後ろに_h1852915458
のような文字列が追加されていましたが、簡単に特定・編集することができました。
対策
PlayerPrefs
を利用する・しないに関わらず暗号化したデータを格納することが有効です。
セーブ関連で有名なUnityのアセットとしてはEasy Saveが挙げられます。(AES
による暗号化)
Easy Save supports AES encryption with an 128-bit key, which can be enabled in the default settings or by using an ES3Settings object as a parameter.
https://docs.moodkie.com/easy-save-3/es3-guides/es3-encryption-compression/
後Anti Cheat ToolkitというアセットにはPlayerPrefs
を暗号化する機能があるみたいですね。
This is an Obscured analogue of the PlayerPrefs class.
Saves data in encrypted state, optionally locking it to the current device.
Automatically encrypts PlayerPrefs on first read (auto migration), has tampering detection and more.
https://codestage.net/uas_files/actk/api/class_code_stage_1_1_anti_cheat_1_1_storage_1_1_obscured_prefs.html
6. さいごに
マサカリ大歓迎です。
また普段から週2目安で技術ブログの記事を投稿しているので、よろしければこちらも是非。