はじめに
こちらはKLab Engineer Advent Calendar 2023の10日目の記事です。
去年新卒入社だったのですが、体調崩して今回が初参加になります。
この記事ではCheatEngineなどのチートツールのメモリの検索避けを簡単に出来るC#ジェネリックを作ってみたのでその紹介になります。
本記事ではUnityで使っていますが、普通のC#プロジェクトでも使用可能です。
筆者はチートの専門家ではありません。
拙い点があるかもしれませんがご了承ください。
手法としてはよくあるXORしてメモリに保持するというものですが、実装を検索してみると、
- 値型ごとにclassを用意している (Vector3とかでも使いたい!)
- 浮動小数点に対応するためにUnSafeを使っている
- 既存のプログラムに対し置き換えるのがめんどくさい
とそのままでは少し扱いづらい点があったりします。
この点を克服したものが次のテンプレートになります。
■ 特徴
- 任意の値型(structも可)で使えるジェネリック
- Spanを用いたSafeなコード
- 暗黙的変換を実装しているので置き換えが比較的楽(structはきつい)
導入
safeなコードにするためにSpanやMemoryMarshalを使用しています。
Unity2021以降であればコピペするだけで使用する事が出来ます。
Unity2020以前でもSystem.Memory.dllを入れる事で使用する事が出来ます。
※動作環境はちゃんと確認はしてないのでだめだったらコメントください。
using System;
using System.Runtime.InteropServices;
public struct Protector<T> where T : struct
{
T _value;
byte[] _key;
public static implicit operator Protector<T>(T v) => new Protector<T>(v);
public static implicit operator T(Protector<T> v) => v.Value;
public override bool Equals(object obj) => obj is Protector<T> && Value.Equals(((Protector<T>)obj).Value);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString();
public Protector(T val)
{
_value = val;
_key = CreateKey();
Xor(ref _value);
}
public T Value
{
get
{
InitCheck();
var val = _value;
Xor(ref val);
return val;
}
set
{
InitCheck();
var work = value;
Xor(ref work);
_value = work;
}
}
void InitCheck()
{
if (_key is null)
{
_value = default;
_key = CreateKey();
Xor(ref _value);
}
}
static byte[] CreateKey()
{
var key = new byte[Marshal.SizeOf(typeof(T))];
new System.Random().NextBytes(key);
return key;
}
void Xor(ref T val)
{
var bytes = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref val, 1));
var keys = new ReadOnlySpan<byte>(_key);
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] ^= keys[i];
}
}
}
使ってみる
■値型の場合
基本的に宣言部分を置き換えるだけで動きます。
1点、GetType()がoverride出来ないので挙動が変わります。
Protector<decimal> d;
void Start()
{
//値型であれば基本getterを呼び出さなくて良い
//フィールドの場合初期化しなくてもdefaultが入る
//ToString()がちゃんと動く
Debug.Log(d); //0
//各種演算子も使える
//intを用いても怒られない
d += 10; //OK
d = d * d; //OK
decimal _ = d; //OK
d = 123; //OK
Debug.Log(d == 123); //True
//メソッド呼び出し、Equals()とGetHashCode()にも対応
Debug.Log(123m.Equals(d) && d.Equals(123m) &&
123m.GetHashCode() == d.GetHashCode()); //True
//ジェネリックの型情報が返ってしまうので注意
Debug.Log(d.GetType()); //Protector`1[System.Decimal]
}
■structの場合
getterを挟まないといけない場合が多く、既存のプログラムから乗り換える場合は修正点が多くなります。
諦めて置換していきましょう。
また、アクセスするたびにstruct全体をXORするので、自作Structではフィールドの値型に適用するのが良いと思われます。
void Start()
{
//初期化は元の型で出来る
Protector<Vector3> v = new Vector3();
//ToString()も働く
Debug.Log(v); //(0.00, 0.00, 0.00)
//Equals()とGetHashCodeに対応
Debug.Log(v.Equals(Vector3.zero) && Vector3.zero.Equals(v) &&
v.GetHashCode() == Vector3.zero.GetHashCode()); //true
//元の型との計算、比較は可能
//テンプレート同士での計算、比較は出来ない
var _ = Vector3.up + v; //OK
//_ = v + v; //エラー
Debug.Log(v == Vector3.zero); //OK True
//Debug.Log(v == v); //エラー
//別の型との計算は出来ない
//v = v * 1; //エラー
//メソッド呼び出しは出来ない
//v.Normalize(); //エラー
v.Value.Normalize(); //OK
//値型と同じくジェネリックの型情報が返ってしまう
Debug.Log(v.GetType()); //Protector`1[UnityEngine.Vector3]
}
チート対策出来てるか検証
CheatEngineを使って検索出来るかの検証をします。
検証のためボタンを押したら数字が増えるだけのアプリを作成しました。
- 赤ボタンが普通のint
- 青ボタンがジェネリックを使用したint
になります。
■赤ボタン(対策なしの普通のint)
■青ボタン(ジェネリックを使用)
おまけ
CheatEngineには.Netの情報を見る機能があります。
Mono→.Net Info
今回IL2CPPビルドで検証していますが、dllとして吐き出す都合上、クラスや関数、変数名がそのまま見れて、どのinstanceが現在存在しているかまで確認する事ができます。
また、関数もアセンブリを見ることが可能です。
このように変数を検索出来なかったとしても何をやっているか丸わかりだったりするので、しっかりとチート対策を行いたい場合は難読化ツールも併せて使用するのが良いと思います。
おわりに
普段はシェーダーとかを好んで触っているのですが、
自分のC#勉強の一環としてこちらのジェネリックを作ってみました。
本当は実行速度計測とかして載せた方が良いのでしょうが、そこまでの体力が無いのでご了承ください…