gdb を用いてプロ演1で実装した数当てゲームをチートしてみた.~ p コマンドでカンニング~

こんにちは.あらい大先生です.

SN コースのプログラミング演習1(以降ではプロ演1と呼びます)は,数当てゲームを作りながら C 言語の基礎を学べる科目となっています.
また,今年度では新たな試みとして,デバッグの際には gdb や lldb といった CLI でのデバッガを用いるようです.
SN実験3 の C プログラム脆弱性実習では gdb を利用するため,プロ演1を通じて gdb の操作に慣れておくと良いでしょう.

ところで,上原研では,ゲームのチート対策を研究テーマにすることができます.
多くの学生にとって,ゲームは身近な存在であるため,ゲームのチートは関心が高い社会問題でしょう.
一方で,ゲームのチートを技術的に理解し,防ぐためには,計算機の仕組みを充分に理解する必要があります.

そこで,gdb を用いた数当てゲームのチートを通じて,gdb の使い方や計算機の仕組みを勉強できる記事を書きました.

手順

以下の手順でチートを行います.

説明と再現性のために ソースコード動作確認 も記載しました.

ソースコード

数当てゲームのソースコードを以下に示します.

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main (void) {

	srandom(time(NULL));

	int rand_num = random();
	int input_num;

	printf("Please guess the secret number: ");
	scanf("%d", &input_num);

	if (input_num == rand_num) {
		printf("Correct!\n");
	} else {
		printf("Incorrect...\n");
	}

	printf("The secret number is %d\n", rand_num);

	return 0;

}

9 行目で,random() の返り値を rand_num へ代入しています.
プロ演1では,この後に rand_num の範囲を狭める処理を実装しますが,このプログラムには実装しません.

動作確認

このプログラムをコンパイルします.
ただし,プロ演1でのデバッグ演習とは異なり,-g を指定せず,-O0 のみを指定します.
-g を指定すると,rand_num の中身を簡単に確認できてしまって面白くないからです.

gcc -O0 kazuate.c -o kazuate

コンパイルが完了したら,試しに遊んでみましょう.

$ ./kazuate
Please guess the secret number: 1
Incorrect...
The secret number is 723442531
$ ./kazuate
Please guess the secret number: 2
Incorrect...
The secret number is 1176194064
$ ./kazuate
Please guess the secret number: 3
Incorrect...
The secret number is 881291385
$ ./kazuate
Please guess the secret number: 4
Incorrect...
The secret number is 561630185
$ ./kazuate
Please guess the secret number: 5
Incorrect...
The secret number is 1349271950

secret number の範囲が広すぎて正解できる気がしません.

gdb を用いてチートする.

秘密の数字を推測するのは困難なので,gdb を用いてカンニングします.
数当てゲームをデバッグするために,下記のコマンドを実行します.

$ gdb ./kazuate

コマンドを実行すると色々な文字列が出力されると思いますが,最後の行に (gdb),その1つ前の行に (No debugging symbols found in ./kazuate) が出力されていれば大丈夫です.

チートの方針を検討するために,main 関数を逆アセンブルします.
その前に,必要に応じてアセンブリ言語の記法を AT&T 記法から Intel 記法へ変更しておくと良いでしょう.

(gdb) set disassembly-flavor intel
(gdb) disas main

main 関数を逆アセンブルした結果を以下に示します.

Dump of assembler code for function main:
   0x0000000000001185 <+0>:     push   rbp
   0x0000000000001186 <+1>:     mov    rbp,rsp
   0x0000000000001189 <+4>:     sub    rsp,0x10
   0x000000000000118d <+8>:     mov    edi,0x0
   0x0000000000001192 <+13>:    call   0x1060 <time@plt>
   0x0000000000001197 <+18>:    mov    edi,eax
   0x0000000000001199 <+20>:    call   0x1030 <srandom@plt>
   0x000000000000119e <+25>:    call   0x1070 <random@plt>
   0x00000000000011a3 <+30>:    mov    DWORD PTR [rbp-0x4],eax
   0x00000000000011a6 <+33>:    lea    rdi,[rip+0xe5b]        # 0x2008
   0x00000000000011ad <+40>:    mov    eax,0x0
   0x00000000000011b2 <+45>:    call   0x1050 <printf@plt>
   0x00000000000011b7 <+50>:    lea    rax,[rbp-0x8]
   0x00000000000011bb <+54>:    mov    rsi,rax
   0x00000000000011be <+57>:    lea    rdi,[rip+0xe64]        # 0x2029
   0x00000000000011c5 <+64>:    mov    eax,0x0
   0x00000000000011ca <+69>:    call   0x1080 <__isoc99_scanf@plt>
   0x00000000000011cf <+74>:    mov    eax,DWORD PTR [rbp-0x8]
   0x00000000000011d2 <+77>:    cmp    DWORD PTR [rbp-0x4],eax
   0x00000000000011d5 <+80>:    jne    0x11e5 <main+96>
   0x00000000000011d7 <+82>:    lea    rdi,[rip+0xe4e]        # 0x202c
   0x00000000000011de <+89>:    call   0x1040 <puts@plt>
   0x00000000000011e3 <+94>:    jmp    0x11f1 <main+108>
   0x00000000000011e5 <+96>:    lea    rdi,[rip+0xe49]        # 0x2035
   0x00000000000011ec <+103>:   call   0x1040 <puts@plt>
   0x00000000000011f1 <+108>:   mov    eax,DWORD PTR [rbp-0x4]
   0x00000000000011f4 <+111>:   mov    esi,eax
   0x00000000000011f6 <+113>:   lea    rdi,[rip+0xe45]        # 0x2042
   0x00000000000011fd <+120>:   mov    eax,0x0
   0x0000000000001202 <+125>:   call   0x1050 <printf@plt>
   0x0000000000001207 <+130>:   mov    eax,0x0
   0x000000000000120c <+135>:   leave
   0x000000000000120d <+136>:   ret
End of assembler dump.

上から順番に main 関数の逆アセンブルを解読するのは大変なので,数当てゲームの仕様から注目すべき命令を考えていきます.
数当てゲームの仕様は以下のとおりです.

  1. 数字を入力する(秘密の数字を予想する).
  2. 入力した数字と秘密の数字を比較する.
  3. 2つの数字が等しければ正解,そうでなければ不正解と出力する.

プロ演1では,1. を実現するために scanf() を用いるように指示されたので,main の逆アセンブルから scanf を探してみましょう.
すると,0x00000000000011ca <+69>: call 0x1080 <__isoc99_scanf@plt> が見つかります.
他に scanf は見当たらないので,この scanf が 1. と対応していると推測することができます.

この scanf の2つ後に 0x00000000000011d2 <+77>: cmp DWORD PTR [rbp-0x4],eax があります.
cmp は,「比較する」を意味する compare の略であり,ここでは [rbp-0x4]eax を比較します.
この cmp が 2. を実現していそうです.

さらに,cmp の直後に 0x00000000000011d5 <+80>: jne 0x11e5 <main+96> があります.
jne は jump not equal の略で,ここでは「何かと何かが等しくなければ 0x11e5 (<main+96>) へ分岐する」という意味です.
先ほどの [rbp-0x4]eax を比較した結果に応じて分岐先を決めているのかもしれません.
また,3. との対応を考えると,0x11e5 (<main+96>) は不正解のときの分岐先のように見えます.

以上から,0x00000000000011d2 <+77>: cmp DWORD PTR [rbp-0x4],eax を実行する直前の [rbp-0x4]eax の中身を確認したり,変更したりして,その後の挙動を観察すると良さそうです.
そのため,b (break) コマンドで 0x00000000000011d2 <+77>: cmp DWORD PTR [rbp-0x4],eax にブレイクポイントを設定します.

(gdb) b *(main+77)

Breakpoint 1 at 0x11d2 といった出力が得られれば,正しく動作しています.
次に,r (run) コマンドでプログラムを実行します.

(gdb) r

プログラムを実行すると,Please guess the secret number: が出力されるので,適当な数字を入力します.
ここでは 12345 と入力することにします.

すると,Breakpoint 1, 0x00000000080011d2 in main () と,その次の行に (gdb) が出力されます.
先ほど設定したブレイクポイントにより,プログラムが一時的に停止したということです.
ここで再び main 関数を逆アセンブルしてみましょう.

(gdb) disas main

ほぼ同じものが出力されたと思いますが,0x00000000000011d2 <+77>: cmp DWORD PTR [rbp-0x4],eax の行は僅かに異なると思います.

=> 0x00000000080011d2 <+77>:    cmp    DWORD PTR [rbp-0x4],eax

この => は「0x00000000000011d2 <+77>: cmp DWORD PTR [rbp-0x4],eax を実行する直前でプログラムが停止していますよ」ということを表しています.
これでブレイクポイントが機能していることを確認することができました.

それでは,p (print) コマンドで [rbp-0x4]eax の中身を確認します.
まずは eax から確認します.
eax はレジスタなので,$eax のように $ を付ける必要があります.

(gdb) p $eax
$1 = 12345

12345 という数字が出力されました.
これは入力した数字と一致しています.

ということは,[rbp-0x4] には秘密の数字が格納されているのでしょうか?
これも p コマンドで確認してみましょう.
ただし,[rbp-0x4][] で囲まれていることから,rbp - 4 の値ではなく,rbp - 4 番地にある値を確認することに注意しましょう.

(gdb) p $rbp - 4
$2 = (void *) 0x7ffffffee1ec
(gdb) p *(0x7ffffffee1ec)
$3 = 1869486706

ちなみに,以下のように int* でキャストすることで,1回の p コマンドで [rbp-0x4] の中身を確認することができます.
$rbp - 4 は void* 型なので p *($rbp - 4) で参照先を確認しようとしても,($rbp - 4) から何ビット目までを値とみなせば良いか分からないので,Attempt to dereference a generic pointer. が出力されるのでしょうか?)

(gdb) p *(int*)($rbp - 4)
$4 = 1869486706

とりあえず数字が表示されました.
p コマンドで,この数字を eax へ代入してみましょう.

(gdb) p $eax = 1869486706
$5 = 1869486706

先ほどのキャストを活用すれば,[rbp-0x4] の中身を確認しつつ,eax への代入も行えます.

(gdb) p $eax = *(int*)($rbp - 4)
$6 = 1869486706

eax への代入を済ませたら,c (continue) コマンドでプログラムの実行を再開させます.

(gdb) c

すると,以下の出力を確認することができます.

Correct!
The secret number is 1869486706

Correct! が出力されていますし,The secret number が [rbp-0x4] の中身と一致しています.
gdb を用いて数当てゲームをチートすることができたようです.

おわりに

計算機と gdb に関する若干の知識で,数当てゲームをチートすることができました.
もちろん,計算機の仕組み,および gdb の使い方を理解すれば,これ以外の方法でチートを行うことも可能です.
次回のブログでは,その方法や仕組みの解説になる,かもしれません.

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です