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 関数の逆アセンブルを解読するのは大変なので,数当てゲームの仕様から注目すべき命令を考えていきます.
数当てゲームの仕様は以下のとおりです.
- 数字を入力する(秘密の数字を予想する).
- 入力した数字と秘密の数字を比較する.
- 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 の使い方を理解すれば,これ以外の方法でチートを行うことも可能です.
次回のブログでは,その方法や仕組みの解説になる,かもしれません.