ld -z relro で GOT overwrite attack から身を守る
GOT overwrite?
"GOT overwrite" という、(ここでは特にLinuxの)プログラムに対する攻撃方法があります。攻撃が成功すると、そのプロセスの権限での任意コード実行等、深刻な被害を受けます。最近のGNU ld(リンカ)のオプションを用いると、この攻撃から身を守ることができるそうですので、紹介します。
最初にまとめ (こまかいことはあとで)
GOT overwrite から身を守るには、gccでプログラムをリンクするときに、 -Wl,-z,now,-z,relro をつけるだけです。起動時間が遅くなるというトレードオフがありますが、GOTがreadonlyになります。GOTがreadonlyなら、GOT overwrite attack を受けたときに、プロセスがSEGVしてくれますので、安全性が高まります。プロセスのメモリマップを確認すると、きちんと w が落ちています。
% gcc -Wl,-z,now,-z,relro foo.c % readelf -S a.out | grep .got [20] .got PROGBITS 08049fd8 000fd8 000028 04 WA 0 0 4 % ./a.out & [32304] % cat /proc/32304/maps | grep 08049 08049000-0804a000 r--p 00000000 08:02 6391415 /home/yupo/tmp/a.out (^ 普通のバイナリと違ってGOTにw (=書き込み許可) が無い)
GOTは libhoge.so も個々に持っていますので、それら共有ライブラリを作成する際にも同じオプションを使うと良いでしょう*1。最近のglibcは最初から -Wl,-z,now,-z,relro になっているそうです*2。お手元のglibcがそうなっているかどうかは、readelfコマンドで、ELFのプログラムヘッダを読めば確認できます。PT_GNU_RELRO があれば、-z relro されています。
% readelf -l /lib/libc-2.4.so | grep GNU_RELRO GNU_RELRO 0x12d0c0 0x00c9a0c0 0x00c9a0c0 0x01f40 0x01f40 R 0x1 % readelf -d /lib/libc-2.4.so | grep NOW 0x0000001e (FLAGS) BIND_NOW STATIC_TLS 0x6ffffffb (FLAGS_1) フラグ: NOW
起動時間とセキュリティ強度のバランスを取るには、DSOのチューニングについて理解する必要があります(って私も怪しい限りですが :-)。このpdfにいろいろ書いてあります。あと、このサイトをDSOとかPICとかPIEで検索して把握すると良いと思います(私もちゃんと読んでいませんが orz)。
PLT/GOT経由の関数呼び
ここからstep-by-stepの解説です。なにげに(Web上には)あまり解説がないので...目新しい話ではないですが&間違いがあったらゴメンナサイですが、一応。興味がなければ、「GOT overwrite attack の実験」までスキップしてください。
GOTというのは、ごく単純に書くと、「別の共有ライブラリ中の関数(例えばlibc.soの中のputs関数)を呼ぶときに参照する間接ジャンプテーブル」です。詳しくは Linkers & Loaders などを参照してください。
例えば、次のようなコードを書いたとして
% cat hello2.c #include <stdio.h> int main() { puts("hello\n"); return 0; } % gcc -Wall -o hello2 hello2.c
main関数周辺を逆アセンブルしてみると、次のようになります。
% objdump -d hello2 | grep -A 10 '<main>:' 08048384 <main>: 8048384: 8d 4c 24 04 lea 0x4(%esp),%ecx 8048388: 83 e4 f0 and $0xfffffff0,%esp 804838b: ff 71 fc pushl 0xfffffffc(%ecx) 804838e: 55 push %ebp 804838f: 89 e5 mov %esp,%ebp 8048391: 51 push %ecx 8048392: 83 ec 04 sub $0x4,%esp 8048395: c7 04 24 64 84 04 08 movl $0x8048464,(%esp) 804839c: e8 07 ff ff ff call 80482a8 <puts@plt> 80483a1: b8 00 00 00 00 mov $0x0,%eax ...
C言語でのputs() は、
ということだけ覚えておいて
% objdump -d hello2 | grep -A 10 '<puts@plt>:' 080482a8 <puts@plt>: 80482a8: ff 25 5c 95 04 08 jmp *0x804955c 80482ae: 68 00 00 00 00 push $0x0 80482b3: e9 e0 ff ff ff jmp 8048298 <_init+0x18> 080482b8 <__libc_start_main@plt>: 80482b8: ff 25 60 95 04 08 jmp *0x8049560 80482be: 68 08 00 00 00 push $0x8 80482c3: e9 d0 ff ff ff jmp 8048298 <_init+0x18> 080482c8 <__gmon_start__@plt>: ...
となっています。
GOTとPLTの開始アドレスは、readelfコマンドでセクション情報を表示させれば得ることができます。それぞれ、0x08048298と0x0804954cのようです。
% readelf -S hello2 | egrep -e '( .got | .plt )' [11] .plt PROGBITS 08048298 000298 000040 04 AX 0 0 4 [20] .got PROGBITS 0804954c 00054c 000004 04 WA 0 0 4
遅延BIND
で、肝心の「アドレス 0x804955c の値」ですが、これは dynamic linker が実行時に決めます。dynamic linker がいつどのように決めるかの戦略は二種類あります。
- オブジェクトがロードされた時(プログラムの起動時)に、dynamic linkerが全てのGOTのエントリに本当の関数のアドレス(libc.soのputsなど)を埋める
- オブジェクトがロードされた時(プログラムの起動時)には、GOTに特別な値を入れておき、本当の関数のアドレス調査を、その関数の初回呼び出し時まで遅延する
どちらの戦略を取るかは、プログラムをリンクするときに決めることができます*3。
% gcc -Wall -o hello2 -Wl,-z,now hello2.c % readelf -d hello2 | grep NOW 0x00000018 (BIND_NOW) 0x6ffffffb (FLAGS_1) フラグ: NOW
上記のように、-Wl でリンカ(ld)に -z now オプションを渡せば1.の戦略が取られます。1. なオブジェクトかどうかは、readelf -d すればわかります。1. の戦略が取られた場合、
080482a8 <puts@plt>: 80482a8: ff 25 5c 95 04 08 jmp *0x804955c 80482ae: 68 00 00 00 00 push $0x0
そうではなく、-z now を渡さなかった場合、2.の戦略が取られます(lazy relocation)。こちらがデフォルトです。PLTは2.の戦略のため(など)に存在しています。
2. の戦略の場合、「アドレス 0x804955c の値」は、デフォルトで
ここまでのまとめ
(1) 別のsoの関数呼びは、次のようにPLTを経由する。
% objdump -d hello2 | grep -A 10 '<main>:' 08048384 <main>: ... 804839c: e8 07 ff ff ff call 80482a8 <puts@plt>
(2) PLTの先頭で、GOT(のputs用のエントリ)に書かれているアドレスにジャンプする。このアドレスは次のどちらかである。
- libc.soのputsのアドレス
の2行目
080482a8 <puts@plt>: 80482a8: ff 25 5c 95 04 08 jmp *0x804955c 80482ae: 68 00 00 00 00 push $0x0
GOT overwrite attack の実験
というように、PLT経由の関数呼び出しはいろいろめんどくさいんですけど、攻撃するのは非常に簡単です。"format string bug" 等の、「アドレス空間中の任意の4バイトを書き換え可能」なプログラムの欠陥を悪用して、GOT(の例えばputs関数用)の値をお好きな値Xに書き換えてしまえば良いわけです。そうすれば、プログラムが次にputsを呼ぼうとした瞬間に、PLT経由でアドレスXのコードが実行されます。
実際に試してみましょう。次のコードを gcc hello.c -o hello でコンパイルします。
// hello.c (IA32) #include <stdio.h> #include <stdlib.h> #include <unistd.h> static int my_puts(const char* s) { write(1, "HEHE\n", 5); _exit(1); } int main(int argc, char** argv) { unsigned int* got_addr = (unsigned int*)strtoul(argv[1], NULL, 16); *got_addr = (unsigned int)my_puts; puts("hello"); return 0; }
次に、GOT(のputs用エントリの場所)を調べます。
% objdump -d hello | grep -A 1 '<puts@plt>' 08048324 <puts@plt>: 8048324: ff 25 68 96 04 08 jmp *0x8049668 ...
0x8049668 らしいので、そこを書き換えるように指定しつつ hello を実行します。
% ./hello 0x8049678 HEHE
puts("hello\n"); を呼んだはずが、my_puts() が呼ばれてしまいました。簡単に攻撃成功です。
GOT overwrite attack 防御
GOTを書き込み禁止にしてしまえばこの問題は解決します。
普段から書き込み禁止にしておいて、dynamic linkerがGOTをいじる瞬間だけdynamic linkerが自ら、mprotect(2)で書き込み許可を出す方法もありますが、これはmprotectの呼び出し回数が多くなってしまい、非常にコストがかかる(実行が遅くなる)そうで、現実的ではないそうです。
そこで、
- 遅延BINDしない。プログラム実行開始時にGOTを全部書き換える
- 全部書き換え終わったら、GOTを書き込み禁止にする
という現実的な方法が考えられました。gccの-Wlオプションをちょっといじるだけでこういう動作にできます。
% gcc -Wall -g -o hello_ro -Wl,-z,now,-z,relro hello3.c % objdump -d hello_ro | grep -A 1 '<puts@plt>:' 08048344 <puts@plt>: 8048344: ff 25 e8 9f 04 08 jmp *0x8049fe8 % ./hello_ro 0x8049fe8 zsh: segmentation fault ./hello_ro 0x8049fe8
確かに、SEGVしてくれました。どこでSEGVしたかを一応確認すると、
% catchsegv ./hello_ro 0x8049fe8 *** Segmentation fault Register dump: EAX: 08049fe8 EBX: 00c9bff4 ECX: 00000000 EDX: 08048444 ESI: 00b69cc0 EDI: 00000000 EBP: ffffbfb8 ESP: ffffbf90 EIP: 080484b0 EFLAGS: 00010286 ...
ダンプのEIP(プログラムカウンタ)によれば、080484b0 の命令で死んだことがわかります。プログラムを混合モードでobjdumpすると、
% objdump -S hello_ro | grep -B 16 80484b0 8048480: 83 ec 24 sub $0x24,%esp unsigned int* got_addr = (unsigned int*)strtoul(argv[1], 0, 16); 8048483: 8b 41 04 mov 0x4(%ecx),%eax 8048486: 83 c0 04 add $0x4,%eax 8048489: 8b 00 mov (%eax),%eax 804848b: c7 44 24 08 10 00 00 movl $0x10,0x8(%esp) 8048492: 00 8048493: c7 44 24 04 00 00 00 movl $0x0,0x4(%esp) 804849a: 00 804849b: 89 04 24 mov %eax,(%esp) 804849e: e8 c1 fe ff ff call 8048364 <strtoul@plt> 80484a3: 89 45 f8 mov %eax,0xfffffff8(%ebp) *got_addr = (unsigned int)my_puts; 80484a6: b8 44 84 04 08 mov $0x8048444,%eax 80484ab: 89 c2 mov %eax,%edx 80484ad: 8b 45 f8 mov 0xfffffff8(%ebp),%eax 80484b0: 89 10 mov %edx,(%eax) <--- ここ
x86アセンブラに目が慣れていないとつらいですが、確かに、*got_addr に書き込んだ場所で死んでくれています。
(補足 6/22) ちなみにこの攻撃は、exec-shieldによるコード実行禁止(NX)、exec-shieldによるライブラリ/スタックアドレスのランダム化、SSPによるスタックベースバッファオーバーフローの検出あたりが有効になっていても成功します。上に書いたようにRELROするか、バイナリをPIEにするとよいでしょう(gcc -fPIE -pie hello.c)。PIE最強。