44
26

More than 1 year has passed since last update.

Unity製アプリに対するよくある不正行為とその対策

Last updated at Posted at 2022-12-13

この記事は 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#の中間言語であるILdllとしてそのまま同梱されているので、それに対して解析を行います。
UnityではAssembly definitionsAssembly 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が入っています。

.dllILSpyを使うと以下のように解析することができます。

Player.cs
// オリジナル (←このコメントは執筆時付け足したもの)
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.cs
// 解析したもの (←このコメントは執筆時付け足したもの)
// 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

スクリーンショット 2022-12-04 173154.png

無事に実行ができるとDummyDllというフォルダが生成されるので、その中身をILSpyなどで解析します。

Player.cs
// 解析したもの (←このコメントは執筆時付け足したもの)
// 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)の該当箇所を探し出します。

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3332373033342f30633432393430382d353731652d343836362d313365632d3035663430643436383839322e706e67.png

私の知識量ではこの先どう編集すればよいのか分からないのでなにも言えませんが、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)
Player.cs
// オリジナル (←このコメントは執筆時付け足したもの)
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.cs
// 難読化したもの (←このコメントは執筆時付け足したもの)
// 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を筆頭とするAssetsAssetBundleを解析するツールが出回っています。

AssetStudio is a tool for exploring, extracting and exporting assets and assetbundles.
https://github.com/Perfare/AssetStudio

GitHubreadmeにも記載されていますが、以下のファイルをサポートしています。

  • 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

またAssetBundleLZ4LZMAに圧縮されていたとしても、問題なく解析することができます。

やり方

プロジェクト単位(フォルダを指定するとフォルダ内部のファイルを走査)もしくは解析したいファイルをAssetStudioに読み込ませます。

ちなみにローカルからAssetBundleを読み込む実装をしている場合は、大抵AssetBundleStreamingAssetsの中に含まれているので、○○_Data/StreamingAssetsの中にあります。

サーバーからロードしてローカルに保存しておく場合はC://User/[UserName]/AppData/LocalLow/[CompanyName]/[ProductName]Application.persistentDataPath)が多いと思います。

スクリーンショット 2022-12-04 021010.png

対策

ファイルを暗号化することが有効です。
AssetBundleに着目するなら以下の2種類の方法が考えられます。

  • 暗号化したファイルをAssetBundleに含める
  • AssetBundle自体を暗号化する

こちらに関しては先人の方々が色々と知恵を絞られて実装を考えられています。
AssetBundleの暗号化について図でまとめてみた
Unity 2018-2019を見据えたDeNAのUnity開発のこれから DeNA TechCon 2019
私もあまり詳しいわけではないので、詳細は割愛させてください。(勉強します...)

例えばAESを用いてAssetBundle自体を暗号化してみるとAssetStudioでは解析できないことが確認できます。

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3332373033342f31623563616330392d333938622d363064612d616333352d3635663630336262373431642e706e67.png

スクリーンショット 2022-12-04 020057.png

また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

やり方

メモリに格納されている値から、改竄したい値が格納されているアドレスを探し出します。

例. Score22から23へと変化させたときに、同じような値の変化をしているメモリのアドレスを調べる。
スクリーンショット 2022-12-04 164549.png

対策

メモリ上にそのままのデータを置かないようにする事が有効です。
よくある例としてはXORした値をメモリに書き込んでおき、利用する際に再度XORするというものです。

// XORの性質
平文 ^ 鍵 = 暗号文
暗号文 ^ 鍵 = 平文 

お試しでメモリ上にXORを利用した値を置くような自作intを作ってみました。

CustomInt.cs
[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 ToolkitXORしている)

📌 共通の機能
メモリ内の変数を保護します。
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)内に配置されているファイルを編集します。

スクリーンショット 2022-12-04 162632.png

ここは実装する人によって様々な手法を取っているので割愛します。

PlayerPrefsを利用している場合

レジストリエディターを利用して値の編集を行います。

Windows10の場合はタスクバーの検索ボックスにregeditと入力することでレジストリエディターを開けます。

スクリーンショット 2022-12-04 031806.png

HKEY_CURRENT_USER/SOFTWARE/[company name]/[product name]を選択すると、データの一覧が表示されるはずです。

Log.cs
// PlayerPrefsを用いてセーブをする
PlayerPrefs.SetInt("LogCount", count);

スクリーンショット 2022-12-04 025841.png

画像の場合では後ろに_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目安で技術ブログの記事を投稿しているので、よろしければこちらも是非。

44
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
26