自作ゲームのチートを自作する
記事の概要
気づけばもう年末です。
年末年始はゲームをプレイして過ごす方もいらっしゃるのではないでしょうか。
ゲームには古来よりチートがついて回っています。
チートの原理を、チートを作成する体験を通じて、理解するのが本記事の目的です。
具体的には、自作のじゃんけんゲームのバイナリを書き換えます。
以下が前提です
- 簡単なC言語のプログラムが読める
- CPU : x86_64
- コンパイラ : gcc
※本記事はチートを推奨するものではありません。
技術的な理解を目的としています
チートの作成工程
チートには色々な手法がありますが、今回はプログラム(バイナリ)の一部を書き換える方法をとります。
基本的なチート作成の手順は以下のようになります。
バイナリの解析からやると大変なので、今回は「ゲームのソースコードを入手した」という設定で進めます。
じゃんけんゲーム
今回使用する自作ゲームはCUIのじゃんけんです。
おいおいそれはゲームなのか?という疑問が聞こえてきそうですね!
ゲームです!
プログラムフローは以下のようになっています。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
// コンピュータのグーチョキパー選択
char getComputerChoice() {
srand(time(0)); // シード作成
int num = rand() % 3; // 0~2の値をランダムに生成
if (num == 0) return 'R'; // Rock(ぐー)
if (num == 1) return 'P'; // Paper(ぱー)
return 'S'; // Scissors(ちょき)
}
// 勝敗判定
int determineWinner(char user, char computer) {
if (user == computer) {
return 0; // あいこ
} else if ((user == 'R' && computer == 'S') ||
(user == 'S' && computer == 'P') ||
(user == 'P' && computer == 'R')) {
return 1; // 勝利
} else {
return 2; // 敗北
}
}
// 勝敗表示
void displayResult(int result) {
if (result == 0) {
printf("あいこ!\n");
} else if (result == 1) {
printf("勝利!\n");
} else {
printf("敗北!\n");
}
}
int main() {
char userChoice, computerChoice;
int result;
// ユーザー入力の受付
printf("グーなら'R'、パーなら'P'、チョキなら'S'を入力してください\n");
// 簡単にするため、入力チェックはしません
scanf(" %c", &userChoice);
// コンピュータのグーチョキパー選択
computerChoice = getComputerChoice();
// 勝敗判定
result = determineWinner(userChoice, computerChoice);
printf("コンピューター : %c\n", computerChoice);
printf("ユーザー : %c\n", userChoice);
// 結果の表示
displayResult(result);
return 0;
}
作成するチートについて
じゃんけんのチートとして思い浮かぶのは、「必ず勝利する」でしょうか。
「必ず勝利する」ようにゲームを変更するにはどうすればよいでしょうか?
手に入れたソースコードを見ると、勝敗判定はdetermineWinner関数で行われています。
int determineWinner(char user, char computer) {
if (user == computer) {
return 0; // あいこ
} else if ((user == 'R' && computer == 'S') ||
(user == 'S' && computer == 'P') ||
(user == 'P' && computer == 'R')) {
return 1; // 勝利
} else {
return 2; // 敗北
}
}
勝利時は1を返すようになっています。
この関数が必ず1を返すようにすればよさそうですね。
チートのイメージ
そのためには、図のように必ずreturn 1を通るようにしたいですね。[1]
C言語であればgotoすればよさそうです。
では早速、バイナリでチートを作成していきましょう。
チートの作成
先ほどの「じゃんけんゲーム」をコンパイルします。
gcc -o game game.c
バイナリの編集箇所を確認する
じゃんけんゲームのバイナリを見るために逆アセンブルします。
objdump -d -S -M intel ./game > game.asm
逆アセンブルの結果の一部(game.asm)です。
じゃんけんゲームの逆アセンブル(一部)
うーん、わかりませんね!
godboltというサイトを使用すると、Cのコードとアセンブリの対応関係がわかり便利です。
godboltの表示内容
これを見ると、
- determineWinner関数はmovzx命令から始まっている
- そのより前の命令は、スタックのプロローグです(説明は割愛します)
- return 1に対応するアセンブリは、.L9:(mov命令)と書かれている箇所
ということがわかります。
再度、「実際に逆アセンブルした結果」でdetermineWinner関数の中身を確認してみましょう。
determineWinner関数のアセンブリ
今回やりたいことは、「determineWinner」関数に入ったら即座にreturn 1のところにgotoする"です。
これと同じことをバイナリ(アセンブリ)で行いましょう。
以下はここまで調べた内容である、(C言語とバイナリの対応関係)をまとめたものです。
C言語 | バイナリ(アセンブリ) |
---|---|
determineWinner関数の開始位置 | 0x128F(movzx命令) |
return 1;の位置 | 0x12C3(mov命令) |
goto | ジャンプ命令 |
つまりバイナリ目線でやることは、0x128Fから0x12C3にジャンプするです。
ジャンプ命令
ジャンプをする場合、ジャンプ命令を使用します。
今回はジャンプの距離が短いので、ショートジャンプ命令(0xEB)というものを使用します。
ショートジャンプ命令は2バイト命令で、以下のようになっています
- 最初の1byteはショートジャンプ命令を表す
- もう1バイトはオフセット(ジャンプする距離)
ジャンプのオフセット(ジャンプ距離)を計算します。
この計算は少しややこしいです。
ショートジャンプの例
このようにメモリアドレス100番にショートジャンプ命令が配置されているとします。
100番にショートジャンプを表す0xEBがあり、101番にオフセット(ジャンプ距離)が置かれます。
ジャンプ先は107番だとします。
この場合、オフセットは緑色の大きさとなります。
つまり、「ジャンプ命令」と「ジャンプ先」の間の個数となります。
バイナリの編集
よって今回の場合は、
- ジャンプ元のアドレス : 0x128f(0f b6 45 fc)
- ジャンプ先のアドレス : 0x12c3(b8 01 00 00 00)
- ジャンプのオフセット : 0x12c3 - 0x1291 = 0x32
となります。
この命令をどう追加すればよいでしょうか?
単純に追加することは不可能なので、既存の命令を上書きします。
そのため、determineWinner関数の開始位置であるmovzx命令を上書きします。
やることが決まったので、バイナリエディタ(今回はvscodeのhex)で編集しましょう!
編集前
編集後
movzxは4バイト命令、ショートジャンプは2バイト命令。
命令サイズに差があるため、余ります(4-2=2)。
余りをnop命令(何もしない命令、0x90)で穴埋めします。
チートの動作検証
では実行してみましょう。
以下はじゃんけんゲームの通常の動作。
通常のじゃんけんゲーム
チート後(バイナリ編集後)の動作
通常のじゃんけんゲーム
「負け」や「あいこ」の場合でも勝利してますね、成功です!
バイナリをたった2バイト(nopを含めると4バイト)書き換えるだけで、必ず勝つことができるようになりました!
-
最初にreturn 1;すればええやん、などと思われるでしょう。記事の都合です。 ↩︎
Discussion