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() は、 の call になっています。この は、libc内のputs関数ではありません。本物のputs関数を呼び出すwrapperのようなものです。libc内の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>:
...

となっています。 先頭行 (80482a8 の jmp *0x804955c) に注目。アドレス 0x804955c に格納されている値にジャンプすることになっています。この、アドレス 0x804955c 周辺がGOT(Global Offset Table)という 間接ジャンプテーブル です。ちなみに、 などが存在するアドレス周辺は PLT (Procedure Linkage Table) と呼ばれています。


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 がいつどのように決めるかの戦略は二種類あります。

  1. オブジェクトがロードされた時(プログラムの起動時)に、dynamic linkerが全てのGOTのエントリに本当の関数のアドレス(libc.soのputsなど)を埋める
  2. オブジェクトがロードされた時(プログラムの起動時)には、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. の戦略が取られた場合、 の先頭のjmpで、いきなりlibc.soのputsに制御が移ります。まさに単なるwrapperです。わかりやすいですね。

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 の値」は、デフォルトで の2行目になっています。ここでは、0x80482ae ですね。ですから、jmp先は の2行目(次の行!)ということになります。0x80482ae から先は、dynamic linker(具体的には__dl_runtime_resolve関数)に、「アドレス 0x804955c の値」を本物のputs関数のアドレスに書き換えてもらった上で、本物のputs関数にジャンプする処理になっています*4。objdump結果を読んだり、gdbでstepiして追ったりすればわかります。2.の戦略の場合でも、2度目以降の 呼び出しでは、 の先頭のjmpで、いきなりlibc.soのputsに制御が移ります(1度目で「アドレス 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最強。

まとめ


冒頭の「最初にまとめ」のところをご覧くださいませ。おっと、cat /proc//maps よりも /usr/bin/pmap のほうが出力が見やすいかな。題名の [GCC] というタグも不適切だけどまぁいいか。


PT_GNU_RELRO で検索したところ、日本語の情報がなかったので書いてみました。

*1:未検証

*2:for i in *.so ; do readelf -l $i | grep GNU_RELRO > /dev/null && echo $i ; done などで調べると良いでしょう。Fedora5ではそれなりの数のsoがrelroされていました

*3:環境変数LD_BIND_NOWでも制御できるが、略

*4:蛇足: _dl_runtime_resolveは、80482aeでpushされた0x0によって、putsの解決を要求されていると理解します