【Unity】メモリの改ざんを防ぐ機能を実装する

アプリなどでメモリ上に保持してる値って外部ツールで割と簡単に読み取ったり変更されてしまうんですよね。変更したときにどのアドレスに保存されているみたいな位置を特定されると実行中に値を書き換えられてしまいます。

そこでこれらの行為を防止するためにの保護方法として一般的に、フィールドに持つ値を「シード値」XOR「値」=「保持する値」に変換して持っておけば読み取られても「ある程度は」セーフティにすることができます。

そこで今回はメモリ改ざん耐性を向上させる実装例を紹介したいと思います(あくまでクライアント側のアプリ内の話 + これを実装しても全く別のアプローチがされたら意味ないため、まぁ、やっておけば多少効果がある程度で覚えておきましょう)

確認環境

  • Unity 2021.3.14f1
  • VisualStudio 2022
  • Windows11

エディター上で動作を確認しています。

MemoryProtectorクラス

まずプリミティブ型に対して XOR で難読化するために以下のようなクラスを定義します。

各型の変換方法を定義します。

// データ保護機能の機能クラス
public readonly struct MemoryProtector
{
    private readonly long _seed;

    public MemoryProtector(long seed) => _seed = seed;

    //
    // Public Methods
    // - - - - - - - - - - - - - - - - - - - -

    public static MemoryProtector Create()
    {
        return new MemoryProtector((long)Rand.MaxRand() << 32 | (long)Rand.MaxRand(););
    }

    public byte Mask(byte value) => (byte)(value ^ (byte)_seed);
    public char Mask(char value) => (char)(value ^ (char)_seed);
    public short Mask(short value) => (short)(value ^ (short)_seed);
    public ushort Mask(ushort value) => (ushort)(value ^ (ushort)_seed);
    public int Mask(int value) => value ^ (int)_seed;
    public uint Mask(uint value) => value ^ (uint)_seed;
    public long Mask(long value) => value ^ _seed;
    public ulong Mask(ulong value) => value ^ (ulong)_seed;
    public unsafe float Mask(float value)
    {
        int f = *(int*)&value ^ (int)_seed;
        return *(float*)&f;
    }
    public unsafe double Mask(double value)
    {
        long d = *(long*)&value ^ _seed;
        return *(double*)&d;
    }
    public unsafe bool Mask(bool value)
    {
        int b = *(byte*)&value ^ (byte)_seed;
        return *(bool*)&b;
    }

    // stringも難読化できるけど死ぬほどパフォーマンスが悪いので微妙
    public unsafe string Mask(string str)
    {
        char[] _temp = new char[str.Length];

        fixed (char* pstr = str)
        {
            for (int i = 0; i < str.Length; i++)
            {
                _temp[i] = (char)(*(pstr + i) ^ (char)_seed);
            }
        }

        return new string(_temp);
    }
}

使い方

次に使い方です。

フィールドに上記のクラスをメンバーとして保持してプロパティなどで値を入出力するときにMask関数を通します。

初期化するときに各インスタンス毎にに seed 値が変わるため毎回異なる値で XOR をすることになります。従って外から値を追うのは結構大変になると思います。

public class Sample
{
    readonly MemoryProtector _p = MemoryProtector.Create();

    private int _id;
    public int ID
    {
        // 値の入出力時にMaskメソッドを通して変換をかける
        get => _p.Mask(_id);
        set => _id = _p.Mask(value);
    }

    private double _no;
    public double NO
    {
        // 値の入出力時にMaskメソッドを通して変換をかける
        get => _p.Mask(_no);
        set => _no = _p.Mask(value);
    }

    public void Show()
    {
        Console.WriteLine($"_id={_id}");
        Console.WriteLine($"_no={_no}");
    }
}

こんな風にメンバーに保護機能クラスを宣言してMaskを通すと、、

public static void Test()
{
    var s = new Sample
    {
        ID = 100,
        NO = 25.336,
    };

    s.Show();
    // 毎回異なる適当な値が出力される(=中身が難読化されている)
    // _id=383139670
    // _no=7.614587724129174E-247
    
    int id = s.ID;
    double no = s.NO;

    Console.WriteLine($"{id}, {no}");
    // > 100, 25.336
}

分かってるとは思いますが、マスクされた値の _id の値を直接どこかに保存して次にこのオブジェクトにロードしても二度と元に戻せないので注意してください。seed をランダムに決めてると元の seed が分からない限り値が復元できなくなります。保存するときは取り出してその値を別で暗号化するなどして保存しましょう。

あと取り出した値は保護してない変数に入れっぱなしにしてるとそこを特定されたりするので取得した値の扱いは気を付けましょう。全システム共通で seed を持っていてもいいと思いますが、毎回 seed を作成するオーバーヘッドがなくなる代わりに耐性が減るのでトレードオフというか共通 seed にはしないほうがいいかな?

ここら辺はシステムごとにどの程度対応するかは違うと思います。