Posted at

チート対策について考えてみる(メモリ改竄編)

More than 3 years have passed since last update.

ゲームロジックが実装されてるクライアントアプリ内のメモリを改ざんすることで、ゲームの進行状況や結果を偽装する行為に対する簡易的な対応法を紹介する。

この方法で防ぐことができるのは、チート用のツールを利用しているようなケースであり、バイナリの直接改竄等には無力である。

大多数のチーターは、このようなツールを利用していると思われるため効果は大きそう。


どうやって改竄を防ぐのか

ツールを使ったメモリの改竄は「特定の値から特定の値へ変化したメモリを探す」ことによって行われる。

この特定の値とは、ユーザーが認識できるゲーム画面上に表示されている情報であることが多い。(スコアとか)

今回は、この特定の値XOR ビット演算を使って、画面上に見えている値とは異なる値をメモリに保持させることで、メモリアドレスの特定を難しくするという方法をとる。


XOR とは

ビット演算の排他的論理和です。

2度繰り返すと元に戻るという性質を利用します。


実装してみる(Unity用)

public unsafe class DataProtector {

readonly Int64 SEED = 0;

public DataProtector() {
var rnd = new System.Random();
SEED = (Int64)rnd.Next() << 32 | (Int64)rnd.Next();
}

public DataProtector(int seed) {
var rnd = new System.Random(seed);
SEED = (Int64)rnd.Next() << 32 | (Int64)rnd.Next();
}

public Int32 Mask(Int32 v) {
return v ^ (Int32)SEED;
}
public UInt32 Mask(UInt32 v) {
return v ^ (UInt32)SEED;
}
public Int64 Mask(Int64 v) {
return v ^ (Int64)SEED;
}
public UInt64 Mask(UInt64 v) {
return v ^ (UInt64)SEED;
}

public float Mask(float v) {
var x = *(Int32*)&v ^ (Int32)SEED;
return *(float*)&x;
}
public double Mask(double v) {
var x = *(Int64*)&v ^ (Int64)SEED;
return *(double*)&x;
}

public bool Mask(bool v) {
var x = *(byte*)&v ^ (byte)SEED;
return *(bool*)&x;
}
}

プロダクトで使うには、もっと高度にラップして使うことになるでしょう。(C# にも代入演算子のオーバーロードが欲しい。。)

C++ はテンプレートとか使って、プリミティブ型でもユーザー定義型でも、まるごと XOR かければいいんじゃないかな。


次のようなコードでテストしてみる

var dp = new DataProtector();

{
int a = 123;
int b = dp.Mask(a);
int c = dp.Mask(b);
Debug.LogWarning(string.Format("int: a = {0}, b = {1}, c = {2}", a, b, c));
} {
float a = 1.23f;
float b = dp.Mask(a);
float c = dp.Mask(b);
Debug.LogWarning(string.Format("float: a = {0}, b = {1}, c = {2}", a, b, c));
} {
double a = 3.21;
double b = dp.Mask(a);
double c = dp.Mask(b);
Debug.LogWarning(string.Format("double: a = {0}, b = {1}, c = {2}", a, b, c));
} {
bool a = false;
bool b = dp.Mask(a);
bool c = dp.Mask(b);
Debug.LogWarning(string.Format("bool: a = {0}, b = {1}, c = {2}", a, b, c));
}


 結果

int: a = 123, b = 1524643113, c = 123

float: a = 1.23, b = 3.581122E-18, c = 1.23
double: a = 3.21, b = 4.79174088278181E-179, c = 3.21
bool: a = False, b = True, c = False

a はオリジナルの値、b はメモリに保持する値、cb から元に戻した値です。

ac が一致し、正しく復元できているのがわかります。

また、メモリに保持する b の値は予測不可能な値になっており目的を達成しています。

bool の値が True に変化するのは「0 以外は True」として扱われる為です。

False はメモリ上で 0x00 と格納されているため、0 ^ (何かの値)の結果、0 以外の値になる可能性が高いからです。

結果的に、True と False どちらも True になる可能性が高いのですが、メモリ上では「True = 0x01, False = 0x00」のように綺麗な値にはならず、デタラメな値になるので bool 値の識別は困難になっています。


改竄検知

続いて改竄を検知する手法を考えてみます。

こちらは、ロジック計算終了後に呼ぶ save() とロジック計算開始時に呼ぶ valid() で、データの改竄を検知する手法です。これらの関数以外では、XOR 等の余計な処理を行わないので、速度的な影響は殆ど発生しない。

template <typename T>

class DataHolder {
public:
DataHolder() : _data(new T), _checksum(0) {
};

virtual ~DataHolder() {
delete _data;
};

T* const data() const {
return _data;
}

void save() {
_checksum = checksum_with_prefix(0, (uint8_t*)_data, sizeof(T));
};

bool valid() const {
return 0 == checksum_with_prefix(_checksum, (uint8_t*)_data, sizeof(T));
};

private:
T* _data;
uint16_t _checksum;
};

今回利用している checksum 関数は github で公開しています。チェックサムでもハッシュ値でもバイト列を数値化出来るものであれば何でもよい。

save(), valid() や値の変更を正しく行わないといけないため扱いが難しい。


テスト

typedef struct {

int level;
int attack;
} Player;

int main(int argc, const char* argv[]) {
DataHolder<Player> player;
player.data()->level = 1;
player.data()->attack = 10;
player.save();

// save() と valid() の間でメモリが書き換えられた!
player.data()->attack = 999;

if (player.valid()) {
std::cout << "valid: true" << std::endl;
} else {
std::cout << "valid: false" << std::endl;
}

return 0;
}


おわりに

ユーザーに安心して楽しくプレイできるように、ゲームの健全性を保つためにチート対策は重要です。

なので、このようなノウハウがもっともっと公開されるといいな!