目次


セキュアなプログラマー

バッファー・オーバーフローに対抗する

今日最大の脆弱性を防止する

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: セキュアなプログラマー

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:セキュアなプログラマー

このシリーズの続きに乞うご期待。

1988年11月、VAXやSunのマシンを攻撃するために23歳のRobert Tappan Morrisが書いた「モリス・ワーム(Morris worm)」のために、多くの組織がインターネットから切り離す措置をとらざるを得ませんでした。一説によると、このプログラムでインターネット全体の10%を占有してしまったということです。2001年7月には「コード・レッド(Code Red)」と呼ばれる別のワームが、MicrosoftのIIS Web Serverを実行しているコンピューターを攻撃し、最終的に全世界で30万台以上に被害を与えました。2003年1月には「スラマー(Slammer)・・サファイア(Sapphire)とも呼ばれます」ワームがMicrosoft SQL Server 2000の脆弱性を攻撃し、韓国と日本で、一部のインターネットを使用不能にし、フィンランドの電話サービスを妨害し、アメリカの航空会社各社の予約システムやクレジットカード・ネットワーク、現金自動引出機を速度低下させました。こうした攻撃や、その他多くの攻撃はすべて、バッファー・オーバーフローと呼ばれる脆弱性を狙ったものなのです。

1999年にBugtraq(セキュリティの脆弱性を議論するメーリング・リスト)が行った非公式な調査によると、参加者の3分の2が、脆弱性の第一原因はバッファー・オーバーフローであると確信していると回答しています。(背景資料として、参考文献に「Buffer Overflows: Attacks and Defenses for the Vulnerability of the Decade」を挙げておきます。)また1997年から2002年3月までの間に発行されたCERT/CCからの警告の半分は、バッファー・オーバーフロー脆弱性を原因とするものです。

プログラムをセキュアにしたいのであれば、バッファー・オーバーフローについて知り、その防止方法や最新の自動対抗ツールとその限界、またプログラム中でバッファー・オーバーフローに対抗する方法について知っておく必要があります。

バッファー・オーバーフローとは何か?

バッファーを正式に定義すると、「同じデータ型で一つ以上のインスタンスを保持する、連続的なブロックからなるコンピューター・メモリ」となります。CやC++では、バッファーは通常、配列や、malloc()newのようなメモリ割り付けルーチンを使って実装されます。非常に一般的な種類のバッファーは単純に文字の連続です。オーバーフローは、バッファーに割り付けられたメモリ・ブロックの外にデータが加えられる事によって起こります。

攻撃者がバッファー・オーバーフローを引き起こすことができると、攻撃者はプログラム中の他の値を制御できるようになります。バッファー・オーバーフローを引き起こす方法は数多くありますが、最も一般的な手法は「スタック破壊」攻撃("stack-smashing" attack)です。スタック破壊攻撃を説明した古典的な記事として、かつてBugtraqメーリング・リストの主催者であったElias Levy(Aleph Oneとしても知られています)による「Smashing the Stack for Fun and Profit」があります(参考文献にリンクがあります)。

スタック破壊攻撃(または他のバッファー・オーバーフロー攻撃も)がどのように動作するかを理解するには、コンピューターがどのように動作するのか、少しばかりマシン言語レベルで知っている必要があります。UNIXライクなシステムでは、どのプロセスもテキスト、データ、スタックという3つの主な領域に分割する事ができます。テキスト領域にはコードと読み取り専用データがあり、通常は書き込みできません。データ領域には静的に割り付けられたメモリ(グローバルなデータや静的データ)と、動的に割り付けられたメモリ(よく、ヒープ(heap)と呼ばれます)があります。スタック領域はファンクション・コール/メソッド・コールを許可するために使われるもので、そのファンクションが終了した時にどこに戻るかを記録し、ファンクションで使われるローカル変数を保存し、ファンクションにパラメーターを渡すため、またファンクションからの戻り値を返すために使われます。ファンクションが呼ばれると、それをサポートするために、その都度新しいスタック・フレーム(スタック内部のメモリ領域)が使われます。それを頭に置いた上で、ちょっとしたプログラムを見てみましょう。

リスト1. ちょっとしたプログラム
void function1(int a, int b, int c) {
   char buffer1[5];
   gets(buffer1); /* DON'T DO THIS */
}
void main() {
  function(1,2,3);
}

リスト1に示すちょっとしたプログラムをgccを使ってコンパイルし、x86のLinuxで実行し、gets()へのコールの直後に中断したと考えてください。メモリの内容はどんな風に見えるでしょう。答えは図1です(メモリの配置は左から右にアドレスが上って行きます)。

図1. スタックの様子
メモリの下位メモリの上位
buffer1sfpretabc
<--- 大きくなる ---[ ][ ][ ][ ][ ][ ] ...
スタックの上位スタックの下位

x86を含む多くのプロセッサーは、高位アドレスから低位アドレスに「下に向かって成長する(grow down)」スタックをサポートしています。ですから、あるファンクションが別のファンクションを呼ぶ度に、システムにスタック用のメモリが無くなるまで、左側(アドレスの低い方)にデータが次々に追加されるのです。この例ではmain()function1()を呼ぶと、cの値、次はbの値、その次はaの値をスタックにプッシュして行きます。その次にはreturn (ret)の値をプッシュするのですが、return (ret)function1()に対してfunction1()の終了後に、main()のどこに戻るかを指示しているのです。また「saved frame pointer: sfp」と呼ばれるものもスタックに記録されますが、これは常に保存されるものではなく、今回の問題を理解するためには理解する必要のないものです。いずれにせよfunction1()が起動すると、図1で低い方のアドレス位置にあるbuffer1()のために空間を確保します。

では攻撃者が、buffer1()の処理できる以上のデータを送りつけたと想像してみてください。次には何が起こるでしょう? 実はCやC++ではこの問題を自動的にはチェックしません。ですからプログラマーが意図的に防止措置をとらないと、その次の値はメモリ中の「次の場所」に行ってしまうのです。これはつまり攻撃者がsfp (saved frame pointer)を上書きし、次にret(戻り番地)を上書きできてしまう、ということです。こうなってしまうと、function1()が終了すると「戻る」のですが、main()に戻るのではなく、攻撃者が実行したいと思うコードに(それがどんなものであれ)戻ることになってしまうのです。

よくあるのは、攻撃者が実行したいと思う悪意のコードでバッファーをオーバーランさせ、その後で、送りつけたその悪意のコードを指すように戻り値を書き換えるというものです。これはつまり攻撃者が、攻撃全体を基本的に一つの操作だけで設定できるということを意味するわけです! Aleph Oneの記事(参考文献)ではそうした攻撃コードがどのように作られるかを詳細に説明しています。例えば、ASCII の0 (NUL)文字をバッファーに置くのは難しい場合が多いのですが、この記事では攻撃者が通常、どういう方法でこの問題を回避しているかについて説明しています。

スタックを破壊して戻りアドレスを変更する以外にもバッファー・オーバーフローを引き起こす方法はあります。戻りアドレスを上書きする代わりにスタックを破壊し(スタック上のバッファーをオーバーフローさせ)、その後でローカル変数を上書きして攻撃コードを作るというものです。バッファーはスタック上にある必要は全くありません。ヒープ中で動的に割り付けられたメモリ(「malloc」や「new」とも呼ばれる領域)や、一部の静的位置にあるメモリ(「global」メモリや「static」メモリなど)でも良いわけです。基本的に、攻撃者がバッファーの境界をオーバーフローさせる事ができてしまうと、あなたはおそらくトラブルに巻き込まれていることになります。ただ、最も危険なバッファー・オーバーフロー攻撃はスタック破壊攻撃です。これは、プログラムが攻撃に対して脆弱である場合には、攻撃者が特に簡単にマシン全体の制御を奪うことができてしまうからです。

なぜバッファー・オーバーフローがそれほど普通なのか?

古いものであれ新しいものであれ、ほとんどどんなコンピューター言語でも、バッファーをオーバーフローさせようとしても、その言語自体が自動的にそれを検出・防止します(例外を上げるとか、必要なだけバッファーにもっと空間を与える等により)。ところがそうでない言語として、CとC++の2つがあるのです。CやC++は頻繁に、メモリの残り部分どこにでも追加的なデータを書き込むことを簡単に許してしまい、これがとんでもない悪用を招いてしまうのです。さらに悪い事には、CやC++でバッファー・オーバーフローに常に対応したコードを書くのは難しく、偶発的にバッファー・オーバーフローを許してしまいがちなのです。CやC++は非常に広く使われているので、問題が大きくなります。例えばRed Hat Linux 7.1のコード行の内、86%がCまたはC++で書かれています。つまり、実装言語がこの問題に対して防御できないので、膨大な量の脆弱なコードがあるわけです。

これはCやC++言語自体では簡単に修正できません。この問題はC言語の基本的な設計判断(特にポインターや配列をCでどのように扱うかについて)に基づいているのです。C++は大部分Cのスーパーセットなので、同じ問題を持っています。これを防止する、「安全な」C/C++の互換バージョンもあるのですが、パフォーマンスの点で非常に問題が多いのです。それにこの問題を防止しようとC言語を変更すると、もはやCではなくなってしまうのです。多くの言語(JavaやC#など)は構文的にはCに似ていますが、全く異なる言語であり、既存のCやC++のプログラムをそうした言語に変更するのは大仕事になります。

他の言語のユーザーも安心すべきではありません。一部の言語にはバッファー・オーバーフローを引き起こし得るような「エスケープ」構文があるのです。Adaは通常バッファー・オーバーフローを検出・防止するのですが(起こそうとすると例外を上げます)、各種のプラグマ(pragmas)はこれを使用不可にできるのです。C#は通常バッファー・オーバーフローを検出・防止するのですが、プログラマーが一部のルーチンを「不安全(unsafe)」として定義することができ、そういうコードがバッファー・オーバーフローを引き起こせるのです。ですからこうしたエスケープ機構を使っている場合には、C/C++プログラムの場合と同じような防止機構を使う必要があるのです。多くの言語が(少なくとも部分的には)Cで実装されており、どんな言語で書かれたプログラムも、基本的に全てCやC++で書かれたライブラリに依存しています。ですから全てのプログラムがこうした問題を受け継いでいる事になり、従ってこれが一体どういうものなのかを知る事が重要になってくるのです。

CやC++で間違って、バッファー・オーバーフローを引き起こすもの

基本的に、プログラムがデータを読んだり、バッファーにコピーしたりする時にはいつも、コピーをする前に十分な空間がある事をチェックする必要があります。それが起こり得ないことを示せるのであれば例外になりますが、往々にしてプログラムは時と共に変更が加わり、起きえない事が起きてしまう事があるのです。

残念ながらCやC++にあるファンクション(または一般的に使用されるライブラリ)には、このチェックすら行わないものがたくさんあるのです。そうしたファンクションやライブラリは注意して使わないと脆弱性となってしまうため、プログラム中でこれらを使っているところにはどこも要注意です。次のリストはこの問題がいかに一般的なものかを示すのが目的なので記憶する必要はありませんが、こうしたファンクションにはstrcpy(3)strcat(3)、sprintf(3) (いとこのvsprintf(3)も)、それにgets(3)があります。scanf()ファンクションのセット(scanf(3)、fscanf(3)、sscanf(3)、vscanf(3)、vsscanf(3)、それにvfscanf(3))は最大長を定義しないフォーマットを簡単に使えるので、問題を引き起こします(信頼できない入力を読み込む際に「%s」フォーマットを使うのは、まず確実に間違いです)。

他に危険なファンクションとしてはrealpath(3)getopt(3)、getpass(3)、streadd(3)、strecpy(3)、strtrns(3)などがあります。理論的にはsnprintf()は比較的安全なはずであり、最近のGNU/Linuxシステムでは安全です。ただし非常に古いUNIXやLinuxシステムでは、snprintf()が実装しているはずの防止機構を実装していないのです。

Microsoftのライブラリには、Microsoftのプラットフォームの問題と同じような問題を引き起こす、他のファンクションもあります(こうしたファンクションにはwcscpy()tcscpy()、mbscpy()、wcscat()、_tcscat()、_mbscat()、それにCopyMemory()などがあります)。MicrosoftのMultiByteToWideChar()を使っている場合には、共通した、危険な間違いがあります。この機能は最大サイズを文字数として要求するのですが、プログラマーはよく、サイズをバイトで規定してしまい(普通はこの方がより一般的な要求です)、バッファー・オーバーフロー脆弱性を招いてしまうのです。

別の問題として、CやC++は整数に対して型定義が非常に弱く、通常、整数操作での問題を検出しないのです。CやC++ではプログラマーが手動で検出作業をする必要があるので、数字の操作を間違えて攻撃を受けやすくしてしまうようなことが簡単に起きてしまうのです。特にバッファー長さを追跡する必要がある時や、何かの長さを読むような時にこれがよく起こります。ではこれを保存するために記号付きの値を使ったら何が起きるでしょう。攻撃者はその値を「負にし」、その後でそのデータを非常に大きな正の数字と解釈させることができるでしょうか。異なるサイズ間で数値を変換する時に、攻撃者がこれを悪用する事ができるでしょうか。数字のオーバーフローは悪用可能でしょうか。場合によると、整数の取り扱い方が脆弱性を招くのです。

バッファー・オーバーフローに対抗する新しい細工

当然ながら、プログラマーが一般的な間違いをしないようにするのは困難であり、プログラムを(そしてプログラマーを!)別の言語に変更するのは多くの場合難しいものです。それならば、下にあるシステムにこうした問題を自動的に検出させてはどうでしょう。スタック破壊攻撃は特に簡単に行えるので、少なくともスタック破壊攻撃に対して防御する事は良いはずです。

一般的には、下にあるシステムを変更して一般的なセキュリティの問題を防ぐようにするのは素晴らしい考えであり、そのテーマに関しては後の記事でも触れたいと思います。調べてみると防止対策は数多くあり、最もよく行われる方法は次のようにグループ分けすることができます。

  • カナリア・ベースの防御(Canary-based defenses)。これにはStackGuard(Immunixで使われています)やssp/ProPolice(OpenBSDで使われています)、Microsoftの/GSオプションがあります。
  • 非実行スタック防御(non-executing stack defenses)。これにはSolar Designerの非実行スタック・パッチ(OpenWallで使われています)やexec shield(Red Hat/Fedoraで使われています)があります。
  • その他の手法。これにはlibsafe(Mandrakeで使われています)やスタック分割(split-stack)の手法があります。

残念ながらこれまで見つかった手法にはどれも弱点があり、万能ではありませんが、役には立ちます。

カナリア・ベースの防御

研究者であるCrispen CowanはStackGuardと呼ばれる面白い手法を作り出しました。StackGuardはCコンパイラ(gcc)を変更し、戻りアドレスの前に「カナリア値(canary value)」と呼ばれる値を挿入するのです。この「カナリア」は炭坑でのカナリアと同じような役割を果たし、何かがおかしくなった時には警告します。StackGuardはファンクションが戻る前に、カナリア値が変更されていないかをチェックします。攻撃者が(スタック破壊攻撃の一部として)戻りアドレスを上書きすると、おそらくカナリアの値が変わるので、システムが停止するのです。これは有効な手法ですが、バッファー・オーバーフローが他の値を上書きするのを防ぐ事はできないという点には注意してください(上書きされたその値は、システムへの攻撃のためにまだ使えるかも知れないのです)。この手法を拡張して他の値も保護できるようにする作業も行われています。StackGuard(やその他の防御手法)はImmunixで使われています。

最初ProPoliceと名付けられた、IBMのスタック破壊防御(stack-smashing protector: ssp)はStackGuardの手法の変形です。StackGuardと同じように、sspは修正したコンパイラー(gcc)を使用して、スタック・オーバーフローを検出するためにファンクション・コールにカナリアを挿入します。ところがProPoliceはこの基本的な概念に面白い細工を追加しているのです。ProPoliceはローカル変数が保存されているところを再配列して、ファンクション引数にあるポインターをコピーし、ポインターも他のどの配列よりも前になるようするのです。バッファー・オーバーフローではポインター値を修正できないことになるので、これでsspの防御が強化されるのです(攻撃者がポインターを制御できると、ポインターを使ってプログラムがデータをどこに保存するかを制御できてしまいます)。ProPoliceは、デフォルトでは全てのファンクションを防御するわけではなく、必要と思われるファンクションのみを防御します(主に文字列に関するファンクション)。理論的にはこれで防御を少し弱める事になりますが、このデフォルトによって、大部分の問題を防御しつつパフォーマンスが改善されるのです。これは現実的な方法であり、アーキテクチャに依存せず容易に広く使用できるような形でgccを使うことで、この手法を実装しているのです。セキュリティに腐心したとして広範囲の分野で評価されているOpenBSDは、2003年5月リリースの時点で、そのディストリビューション全体に渡ってssp(ProPoliceとしても知られています)を使用しています。

MicrosoftはStackGuardの動作に基づいてCコンパイラーにカナリアを実装するために、コンパイラー・フラグ(/GS)を追加しています。

非実行スタック防御(non-executing stack defenses)

別の手法として、スタック上のコードの実行ができないようにすることから出発しているものがあります。残念ながら、x86プロセッサー(最も一般的なプロセッサー)のメモリ保護機構では簡単にこれをサポートできません。通常、ページが読めれば実行できてしまうのです。Solar Designerという名の開発者が、カーネルとプロセッサー機構をうまく組み合わせて、Linuxカーネル用に「非実行スタック防御パッチ(non-exec stack patch)」を作る事を思いついたのです。このパッチを使うと、スタック上のプログラムはもはやx86の上では普通に実行できません。スタック上に実行形式のプログラムが必要な場合がある事が分かったのです。それには信号処理やトランポリン処理(trampoline handling)が含まれます。トランポリンはコンパイラー(例えばGNAT Adaコンパイラー)が時々生成する風変わりな構造で、ネストされたサブルーチンのような構造をサポートします。Solar Designerは、攻撃を防ぐ一方でこうした特別な場合が動作するようにするにはどうすべきかも割り出したのです。

Linuxでこれをするための最初のパッチは1998にLinus Torvaldsに拒否されましたが、理由が面白いのです。たとえコードがスタックに置かれなくても、攻撃者はバッファー・オーバーフローを使ってプログラムを既存のサブルーチン(Cライブラリにあるようなルーチン)に「戻し」、攻撃を生成する事ができるのです。つまり、非実行スタックがあるだけでは十分ではないのです。

しばらく後、この問題に対抗する新しい考え方が開発されました。すべての実行可能コードを「ASCII armor(アーマー:鎧、装甲)」と呼ばれる領域に移すのです。これがどのように動作するかを理解するには重要なのですが、攻撃者は典型的なバッファー・オーバーフロー攻撃を使ってもASCII NUL文字(0)を挿入できないことが多いのです。つまりプログラムを、中にゼロがあるアドレスに戻るようにするのは攻撃者にとって難しいのです。そのため実行可能コードを全て、中にゼロがあるアドレスに移すと、プログラムに対する攻撃をずっと困難なものにするのです。

このプロパティを持った、連続したメモリ領域として最大なのは0から0x01010100までの一連のメモリ・アドレスなので、これがASCII armor領域と名付けられました(このプロパティを持ったアドレスは他にもあるのですが、連続していません)。実行不能スタックと組み合わせると、これは非常に貴重なものです。実行不能スタックで新しい実行可能コードを送り出すのを防ぎ、ASCII armorで、既存のコードを改変した実行不能スタック回避をしにくくします。これはスタックやバッファー、それにファンクション・ポインターのオーバーフローに対する防御になり、しかもどれも再コンパイルの必要はないのです。

ところがASCII-armorは全てのプログラムに対して動作するわけではないのです。大きなプログラムではASCII-armor領域に入りきらないかも知れず(従って保護も不完全かも知れません)、場合によっては攻撃者が、攻撃者が目的とする場所に0を入れる事もできてしまうかもしれません。また実装によってはトランポリンをサポートしておらず、そのためトランポリンを必要とするプログラムが、保護を外さざるを得ないかも知れません。Red HatのIngo Molnarはこの考え方を「exec-shield」パッチに採り入れ、これがFedraコアで使われています(Red Hatから無料で入手可能なディストリビューション)。最新バージョンのOpenWall GNU/Linux (OWL)では、Solar Designerによるこの手法を使った実装を使っています(参考文献にリンクがあります)。

他の手法

他にも手法はたくさんあります。一つは標準ライブラリ・ルーチンを、より攻撃を受けにくくすることです。Lucent Technologiesでは、スタック破壊攻撃を受けやすいとして知られているstrcpy()など、いくつかの標準Cライブラリ・ファンクションのラッパーであるLibsafeを開発しています。LibsafeはLGPLライセンスで入手可能なオープンソースのソフトウェアです。こうしたファンクションのlibsafeバージョンは、配列の上書きがスタック・フレームを超えないようにチェックします。ただしこの手法はこうした特定のファンクションのみを保護し、一般的なスタック・オーバーフローの脆弱性は保護しません。また、保護するのはスタックのみで、スタック中のローカル値は保護しません。Libsafeの元々の実装ではLD_PRELOADを使っていますが、これは他のプログラムと競合します。LinuxのMandrakeディストリビューション(バージョン7.1時点)にはlibsafeが含まれています。

さらに別の手法として「split control and data stack(制御・データスタック分割)」と呼ばれる手法があります。この考え方ではスタックを2つのスタックに分割し、1つを制御情報(例えば「戻り」アドレス)の保存に、もう1つをその他のデータ全ての保存に使うというものです。Xuやその他ではgccでこれを実装しており、StackShieldではアセンブラーでこれを実装しています。これでは戻りアドレスの操作がずっと困難になりますが、呼んでいるファンクションのデータを変更しようとするバッファー・オーバーフロー攻撃に対しては保護できません。

実行形式のプログラムの場所をランダム化する方法を含めて、実は他にも手法はあるのです。Crispenの「PointGuard」はカナリアの概念をヒープにまで拡張しています。どのようにして今日のコンピューターを守るかは、活発な研究の対象となっています。

一般的な保護では不十分

これほど多くの異なった手法があるという事は何を意味するのでしょうか。ユーザーにとっての朗報としては、革新的な手法が数多く試されている、ということです。長い目で見れば、いろいろ使ってみる事でどの手法が最善か、分かりやすくなります。また多様な防御方法がある事で、攻撃者がその全てをくぐり抜けるのがより困難になります。ところが多様性があるということは同時に、こうした手法のどれかとインターフェースするようなコードは書かないようにする必要があることを意味します。現実的にはこれは簡単です。スタックフレームの低レベル操作をするようなコードや、スタック配置に関して想定するようなコードを書かないようにしさえすれば良いわけです。それは良い助言ではあるのですが、そうした手法は存在しないのです。

オペレーティング・システムの販売者にとって答えは明快です。少なくとも1つの手法を選び、それを使うのです。バッファー・オーバーフローは一番の問題であり、こうした手法のうち最善のものであれば、そのディストリビューションの持つ脆弱性として現在は未知のものでも、その影響を半分近く減少できる可能性が高いのです。カナリア・ベースの手法と非実行スタック・ベースの手法のどちらが良いかは議論が分かれるところですが、どちらもそれぞれ強みがあります。両者を組み合わせる事もできますがパフォーマンス損失が多そうなので、実例はほとんどありません。他の手法は、少なくとも単体では推奨できません。libsafeもsplit control and data stack(制御・データスタック分割)も保護としては限界があります。対応策として最悪なのは当然ながら、何も防御策を講じない事です。まだ何も防御策を講じていないディストリビューションは、すぐに防御策を講じるべきです。ユーザーとしては2004年からは、バッファー・オーバーフローに対して少なくとも何らかの自動防御策を講じていないオペレーティング・システムを選ぶべきではありません。

ただし、そうした防御策を講じたからと言ってバッファー・オーバーフローを忘れて良いわけではありません。どの手法も効果が無くなってしまう可能性もあるのです。ファンクションの別のデータを変更する事でバッファー・オーバーフローを引き起こせるかも知れず、これまで説明した手法ではそれに対抗できません。こうした手法の多くは、(例えばNUL文字のような)生成困難な値が挿入されてしまうと無効になってしまいますが、マルチメディアや圧縮データがより一般的になってきており、そうした挿入も容易になってきています。根本的にはこうした手法は全て、プログラム奪取攻撃(program-takeover attack)をサービス不能攻撃(denial-of-service attack)にレベルを落とす事で、バッファー・オーバーフロー攻撃の被害を減少させています。残念ながらコンピューターが、より致命的に重要な用途にも使われるようになるにつれ、サービス不能攻撃でも重大問題になります。ですからディストリビューションが少なくとも1つ有効な防御策を講じ、開発者はそうした手法に(対抗するのではなく)協調して作業する必要があるのですが、やはり開発者がまず最初に良質なソフトウェアを書く必要があるのです。

C/C++での対応策

バッファー・オーバーフローに対する簡単な対応策は、バッファー・オーバーフローを防ぐ言語に切り替える事です。CとC++を除く高位言語はすべてバッファー・オーバーフローに対抗する機構が組み込まれています。それでも多くの開発者は色々な理由から、やはりCやC++を使うのです。ではどうしたら良いでしょう。

バッファー・オーバーフローに対抗するには、それぞれ異なる数多くの方法があるのですが、2つの手法に分けられる事が分かりました。静的割り付けバッファー(statically allocated buffers)と動的割り付けバッファー(dynamically allocated buffers)です。そこで最初に、これら2つの手法がどういうものなのかを説明しましょう。次に静的手法による2つの例(標準のCstrncpy/strncatとOpenBSDのstrlcpy/strlcat)と、動的手法による2つの例(SafeStrとC++のstd::string)について説明する事にします。

大きな選択:静的割り付けバッファーと動的割り付けバッファー

バッファーが持つ空間には制限があります。ですから、空間が足りなくなる問題に対応するには、大きく分けて2つの可能性があることになります。

  • 「静的に割り付けたバッファー」による手法。バッファーが足りなくなれば、それまで。文句を言ってもバッファーに追加する事はもうできません。
  • 「動的に割り付けたバッファー」による手法。バッファーが足りなくなると、メモリ全体が足りなくなるまでバッファーのサイズを動的に調整します。

静的手法には欠点があります。実際、静的手法では時によると別の脆弱性を生む事にもなるのです! 静的手法は基本的に「余分な」データを捨て去ります。ともかくプログラムがその結果のデータを使うとすると、攻撃者はバッファーをまず一杯にし、データが切り捨てられた時に、攻撃者が望む何かでそのバッファーを満たすようにするかもしれません。ですから静的手法を使っている場合には、攻撃者が可能な限り悪意の攻撃を行っても、一部の想定を無効にすることがないようにしておくべきであり、いくつかのチェックを行って最終結果を調べるのも良い考えです。

動的手法には多くの利点があります。(一定の制限をかけるのではなく)規模を拡大して、より大きな問題に対抗するようにもでき、切り捨てがセキュリティ問題を引き起こす事もありません。ところが独特の問題もあるのです。任意サイズのデータを受け付けてしまうと、メモリが足りなくなるかも知れず、しかもそれが入力中にだけ起こるとは限らないのです。どんなメモリ割り付けも失敗する事はあり、それを本当の意味でうまく処理するCやC++プログラムを書くのは簡単ではないのです。実際にメモリが足りなくなる前であっても、コンピューターがビジーで使い物にならなくなるようにすることができてしまうのです。簡単に言えば動的手法は、攻撃者にとってサービス否定攻撃を作るのがずっと簡単なのです。ですからやはり入力を制限する必要はあります。さらに、どんな場所でもメモリの枯渇(memory exhaustion)をうまく処理するように、プログラムを注意深く設計する必要がありますが、それは簡単ではないのです。

標準Cライブラリ手法

最も単純な手法の1つは、バッファー・オーバーフローを防止するために設計された標準のCライブラリ・ファンクション(これはC++を使っている場合でも可能です)、特にstrncpy(3)strncat(3)を使う事です。こうした標準Cライブラリ・ファンクションは普通、静的割り付け手法をサポートしており、データがバッファーに収まらない時にはデータを捨て去ります。この手法での最大の利点として、こうしたファンクションがどんなマシンでも利用可能であり、C/C++開発者なら誰でも知っていることが挙げられます。非常に多くのプログラムがこの方法で書かれ、確実に動作するのです。

残念ながら、正しくこれを行うのが驚くほど困難なのです。その問題としては次のようなものが挙げられます。

  • strncpy(3)strncat(3)も、バッファーの合計サイズではなく、残った空間の量を要求するのですが、これが問題なのです。なぜならバッファーのサイズは一旦割り付けられれば変わりませんが、バッファーに残っている空間は、データが追加されたり削除されたりする度に変化するからです。つまり、空間がどれだけ残っているかを常に追跡するか、再計算し続けなければならないということになります。この追跡や再計算は間違ってしまう場合がよくあり、そうした間違いがバッファー・オーバーフロー攻撃の余地を作ってしまうのです。
  • どちらのファンクションもオーバーフロー(とデータ・ロス)が起きた時に簡単なレポートを出しません。ですからそれを検出しようとすると、さらに余計な作業をしなければなりません。
  • ファンクションstrncpy(3)はソース文字列がコピー先の長さ以上の時には、NUL終端もしません。これが後で大混乱を引き起こすのです。ですからstrncpy(3)を実行した後は、多くの場合コピー先を再終端する必要があります。
  • strncpy(3)はソース・ストリングの一部のみをコピーするのにもよく使われます。その時には、コピーされる文字数は通常、ソース・ストリングに関する情報に基づいて計算されます。危険なのは、もし使用可能なバッファー空間を考慮に入れておかないと、strncpy(3)をたとえ使ったとしてもバッファー・オーバーフロー攻撃を許してしまうのです。これもNUL文字をコピーしないので、それも問題になります。
  • sprintf()を、バッファー・オーバーフローを防止するような使い方をすることができるのですが、逆に誤ってオーバーフローを許してしまいがちなのです。sprintf()ファンクションは制御ストリングを使って出力フォーマットを規定しますが、その制御ストリングにはよく「%s」(ストリング出力)を含んでいるのです。ストリング出力に精度規定子(precision specifier、例えば「%.10s」)を含んでいる場合には、出力の最大長さを規定する事でバッファー・オーバーフローを防止する事ができます。「*」を精度規定子として使う(例えば「%.*s」)ことすらできるので、制御ストリングに最大長さを埋め込んでおく代わりに、その最大長さを渡すことができるのです。問題なのは、sprintf()は簡単に間違って使うことができてしまうのです。「フィールド幅」(例えば「%10s」)は最大長さではなく、最小長さしか規定しません。「フィールド幅」規定子はバッファー・オーバーフローを許し、フィールド幅規定子と精度幅規定子は、ほとんど同じに見えます。違うのは、安全な方には終止符(a period)があるという点のみです。もう一つの問題は、精度フィールドは1つのパラメーターの最大サイズしか規定しませんが、バッファーは全てのデータを組み合わせた最大サイズに合わせて大きさを決める必要があるという点です。
  • scanf()ファミリーのファンクションは最大幅の値を持っており、少なくともIEEE標準1003-2001ではこれらファンクションは最大幅以上を読んではならないと明確に規定しています。残念ながら、その点をすべての仕様が明確にしているわけではなく、全ての実装が適切にこうした制限を実装しているかどうかは明確ではないのです(今日のGNU/Linuxシステムでは適切に動作します)。もしscanf()ファミリーのファンクションを使っている場合には、インストール中か、初期化中にちょっとしたテストを実行して、正しく動くことをチェックしておいた方が賢明です。

strcpy(3)には気になるパフォーマンスの問題もあります。理論的にはstrcpy(3)strcpy(3)の安全な置き換えなのですが、strcpy(3)はソース文字列の終わりに突き当たると、コピー先をNULで埋めてしまうのです。そうすべき妥当な理由は無いので、これは非常におかしな事なのですが、最初からこうなっており、一部のプログラムではこれを前提にしています。これはつまり、strcpy(3)からstrcpy(3)に変更するとパフォーマンスが低下するということを意味します。今日のコンピューターでは大した問題ではありませんが、やはり気になる問題です。

では標準Cライブラリのルーチンを使ってバッファー・オーバーフローを防止できるでしょうか? できます。ただし簡単ではありません。この選択で進むつもりであれば、これまで説明した全てのポイントを理解する必要があります。あるいは別の手段を使うこともできます。それをこれから説明します。

OpenBSDのstrlcpy/strlcat

OpenBSDの開発者達は、自分たちが開発した新しいファンクション、strlcpy(3)strlcat(3)に基づく、また別の静的手法を開発しました。これらのファンクションはストリングのコピーと連結(concatenation)をするのですが、はるかに間違いを起こしにくい方法で行うのです。これらファンクションのプロトタイプは以下の通りです。

size_t strlcpy (char *dst, const char *src, size_t size);
size_t strlcat (char *dst, const char *src, size_t size);

strlcpy()ファンクションはNUL終端のストリングを「src」から「dst」にコピーします(size-1文字まで)。strlcat()ファンクションは、NUL終端ストリングsrcdstの最後にアペンドします(ただし、size-1文字を超える文字はコピー先に入りません)。

一見すると、このプロトタイプは標準のCライブラリ・ファンクションと大して違わないように見えます。ところが実際には特筆すべき違いがいくつかあるのです。どちらのプロトタイプも、コピー先のバッファーの(残っている空間ではなく)合計サイズをパラメーターとして使います。つまり、(間違いやすい)サイズの再計算を常時行う必要が無いのです。また、どちらのファンクションも、少なくともサイズが1であれば、コピー先がNUL終端されていることを保証するのです(長さゼロのバッファーには何も入れることができません)。バッファー・オーバーフローが起きていなければ、戻り値は常に、結合したストリングのサイズです。これでオーバーフローの検出が実に簡単になります。

残念ながらstrlcpy(3)strlcat(3)はUNIXライクシステムの標準ライブラリには必ずあるというわけではありません。OpenBSDとSolarisでは<string.h>に組み込んでいますが、GNU/Linuxシステムでは組み込んでいません。ただしこれはそれほど困難な問題ではありません。どちらも小さなファンクションなので、下にあるシステムが提供していない場合でも、自分のプログラムのソースに含めてしまうこともできてしまうのです。

SafeStr

MessierとViegaが「SafeStr」ライブラリを開発していますが、これは必要に応じてストリングのサイズを自動調整する、Cでの動的手法です。Safestrストリングは大部分のmalloc()実装で使われているのと同じ細工を使うことで、通常のC「char *」ストリングに容易に変換できます。Safestrは重要な情報を、ポインターがあちこち渡し回される「前の」アドレスに保存します。この細工の利点は既存のプログラムでSafeStrが簡単に使えるという点です。SafeStrは「読み取り専用」や「信頼できる」ストリングもサポートするので、これも便利な点です。一つ問題なのは、XXL(Cに例外処理と資産管理(asset management)のサポートを追加するライブラリ)が必要なことで、単にストリングを処理するだけのために大げさなライブラリを持ち込むことになります。Safestrはオープンソースの、BSD流のライセンスでリリースされています。

C++ std::string

C++ユーザーにとってもう一つの対応策は、標準のstd::stringクラスですが、これは動的手法(バッファーが必要に応じて大きくなります)です。C++がこのクラスを直接サポートしているので、これにはほとんど何も悩むところはなく、使うに当たって何も特別なことは必要なく、他のライブラリもおそらくこれを使うでしょう。std::stringは通常、それ自体でバッファー・オーバーフローに対して防御していますが、その中から普通のCストリングを抽出すると(例えばdata()c_str()を使って)、上で説明したような全ての問題がまた出てくることになります。また、data()は必ずNUL終端ストリングを返すわけではないことも覚えておく必要があります。

歴史的に色々な理由から、多くのC++ライブラリや既存のプログラムは独自のストリング・クラスを作ってきました。そのためそうしたライブラリを使ったり、そうしたプログラムを修正したりするには、(常に異なるストリング型を変換したり逆変換したりする必要があるため)std::stringを使うのがより面倒で非効率になります。こうした他のストリング・クラスの全てがバッファー・オーバーフローに対して防御するわけではなく、Cにある、保護されていないchar*型への自動変換を行う場合には、それらの一部には簡単にバッファー・オーバーフロー脆弱性が持ち込まれてしまいます。

ツール

バッファー・オーバーフロー脆弱性を、広がる前に検出できるようにするツールもいくつかあります。例えば私のFlawfinderやViega's RATSはソースコードをくまなく検索し、不適切に使われている可能性のあるファンクションを特定します(それぞれのパラメーターに基づいてランク付けします)。こうしたツールの欠点は、不完全であるということです。一部のバッファー・オーバーフロー脆弱性は見逃してしまい、実際には問題ではないものを「問題」として特定してしまいます。とはいえこうしたツールを使うことで、手動で探すよりもずっと短時間でコード中に潜む問題を特定できるようになるので、使うだけの価値はあるものです。

まとめ

知識を蓄えて注意を払い、またツールを使えば、CやC++でバッファー・オーバーフロー脆弱性を防止することができます。ただし、それは簡単なことではなく、特にCでは難しいと言えます。CやC++を使ってセキュアなプログラムを書こうとするなら、バッファー・オーバーフローとその防止方法を十分に理解する必要があります。

対応策の一つは他のプログラミング言語を使うことですが、これは今日ほとんど全てのプログラミング言語がバッファー・オーバーフローを防止できるためです。ただし別の言語を使ったからと言って全ての問題を解決できるわけではありません。多くの言語がCのライブラリに依存しており、(スピードを安全に優先させて)防御策を停止できる機構を持っているものも数多くあります。それに何よりも、どんな言語を使おうと、脆弱性を引き起こすような間違いを開発者が犯してしまうことは多分にあるのです。

どんなに工夫しても、間違いのないプログラム開発は信じられないほど困難なものであり、注意深く見直したとしても、いくつかの間違いを見逃してしまうことが多々あります。セキュアなプログラムを開発するために最も重要な方法の一つは特権を最小にするということです。つまり、そのプログラム各部分が持つ特権は必要最低限のもののみで、それ以上の特権は持たない、というようにする必要があります。こうすることで、例えそのプログラムが欠陥を持っていたとしても(欠陥無しのものがどこにあるでしょうか?)、その欠陥がセキュリティの悪夢に変わることだけは防げる可能性が高くなります。では一体どうしたらこれを現実的に行えるでしょう? 次の記事では、必然的に犯してしまう間違いから自分自身を守れるように、Linux/UNIXシステムで現実的に特権を最小限に抑えるためにはどうすべきかを調べて行きます。


ダウンロード可能なリソース


関連トピック

  • David著のSecure Programming for Linux and Unix HOWTOはセキュアなソフトウェアをどのようにして開発すべきかについての詳細を説明しています。
  • The What, Why, and How of the 1988 Internet Worm」は1998年のモリス・ワーム(Morris worm)に関して、より詳細な説明をしています。
  • CERT(R) Advisory CA-2001-19 "Code Red" Worm Exploiting Buffer Overflow In IIS Indexing Service DLLはコード・レッドに関して、より詳細な説明をしています。
  • Frontline: Cyber War!: The Warnings?」はコード・レッドやスラマー(Slammer)を含め、各種の攻撃と、その影響として知られているものを説明しています。
  • Davidによる「More than a Gigabuck: Estimating GNU/Linux's Size」はRed Hat Linux 7.1のソースコードを調査しています。この調査によると、このディストリビューションには3千万行以上の物理コード行(SLOC)があり、その86%がCまたはC++で書かれています。また、このLinuxディストリビューションをアメリカ国内で、独自の伝統的手段で開発するには10億ドル(Gigabuck)、8千人年以上を要する(2000年USドルにて)とも述べています。
  • IBMのstack-smashing protector (ssp, also known as ProPolice)Webサイトはsspに関してさらに詳しい情報を提供しています。sspはOpenBSDで使われています。
  • Linux kernel patch from the Openwall Project」はLinuxカーネル(非実行形式のスタック動作を含めて)に対しての、Solar Designerによる現状のパッチを説明しています。
  • Flawfinder project pageでは、CやC++プログラムでの問題を検出するためのGPL化したツールである、Flawfinderを提供しています。
  • O'Reilly & Associatesでは、Gene Spafford、Simson Garfinkel、Alan Schwartzによる、Practical UNIX & Internet Security, 3rd Editionからの一連の抜粋をSecure Programming Techniquesという名前で公開しています。
  • developerWorks の Linux ゾーンには Linux に関する豊富な記事が用意されています。
  • Developer BookstoreのLinuxセクションにはLinux関連の書籍が豊富に取り揃えられています。

コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=231399
ArticleTitle=セキュアなプログラマー: バッファー・オーバーフローに対抗する
publish-date=01272004