AIDEMOIRE

【アイデモワール】

脆弱性に関する実験:スタックオーバーフロー(その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
スタック上のプログラムを実行する方法