コンパイラの仕様とx86のアーキテクチャが原因
 x86では,プログラム内で利用するデータの一時格納用途だけでなく,サブルーチンから戻ってくるときの戻りアドレスの格納などにもスタックを利用している。また,x86用の一般的なコンパイラは,プログラムのサブルーチン(関数)内で使用する変数(自動変数)領域や,サブルーチンに渡す引数の格納領域にも,スタックを使用している。プログラムの中でのサブルーチンを呼び出す流れを追ってみると,次のようになる。

(1)サブルーチンに渡す引数をスタックに格納
(2)現在コードを実行しているアドレスをスタックに積み,サブルーチンにジャンプ
(3)サブルーチン内で使用する自動変数領域をスタック領域に確保
(4)サブルーチンの処理の中で,外部からのデータを変数領域に格納
(5)サブルーチン内で使用するために確保した自動変数領域の後始末をし,呼び出し元に戻るため,(2)でスタックに積んだ戻りアドレスを取り出してそのアドレスにジャンプ


△ 図をクリックすると拡大されます
図7●バッファ・オーバーフロー攻撃によって不正なプログラムが動いてしまう仕組み
 ここで,(4)の処理のときに,プログラム内で確保したバッファに送り込まれたデータの大きさが,バッファの大きさを超えており,なおかつ戻りアドレスが格納されている部分に,今送り込んだデータのアドレスを示す値を格納しておいたらどうなるだろうか。上記(5)の処理のときに,このサブルーチンの呼び出し元に戻るつもりが,バッファ内に送り込まれたデータの部分にジャンプしてしまう。ここに不正なプログラムが格納されていたら,それが動き出してしまうのである(図7)。

 もちろん,(4)の処理のときに,外部から送り込まれたデータの大きさを調べ,プログラムが確保したバッファの大きさよりも小さいことを確認するか,大きい場合には,はみ出した部分を切り捨てるなどの処理をしていれば,戻りアドレスが上書きされることはなく,送りつけられた不正なプログラムが動き出してしまうこともない。

 だが,C言語やC++言語といった,現在主にシステム・プログラムの開発に使われているプログラミング言語には,バッファにデータを書き込む前にその大きさを確認することや,バッファの大きさを超えるデータの書き込みを禁止することは,文法上定められていない。つまり,確保したバッファに,その大きさを超えるデータを書き込んでも,文法上は間違いではないのである。そのため,うっかり危険なプログラムを書いてしまっても,コンパイル時にエラーにならないので,そのまま運用してしまうことがある。

 最も危険なのは,そもそもプログラマが,C/C++言語には潜在的にプログラムが危険になってしまう恐れがあることや,上記のような攻撃方法があることなどを知らずにプログラムを書いてしまうことだ。

 ちなみにBASIC,C#,Javaなど,文法上,確保したバッファ以外の領域にはデータを書き込めないよう定められているプログラミング言語もある。これらのプログラミング言語で開発すれば,原理上,バッファ・オーバーフロー攻撃は受けない(ただしこれら3つの言語ではOSのようなシステム・プログラムを開発できない)。

 つまりバッファ・オーバーフローのぜい弱性は,(1)現在のC/C++コンパイラのほとんどが自動変数をスタック領域に取ること,(2)x86では関数からの戻りアドレスも同じスタック領域に格納すること,さらに(3)x86のスタックにはアドレスの上位から下位に向かってデータが積まれ,データ自体はアドレスの下位から上位に向かって書き込まれること——が重なったことに起因する。関数からの戻りアドレスを書き換え,バッファ上に送り込んだ不正プログラムにジャンプさせるのが,バッファ・オーバーフロー攻撃の典型例である。そのため,関数からの戻りアドレスが書き換えられていないことを調べるか,バッファ(データ領域)上でプログラムを実行させなければ,バッファ・オーバーフロー攻撃を受けてもセキュリティが脅かされることはない。

メモリーをデータ領域とプログラム領域に区別する
 ハードウエアDEPは,バッファ・オーバーフロー攻撃によって実行される不正プログラムが,スタック領域——つまり本来はデータを格納する領域で実行されていることを利用する。あらかじめメモリー領域の仮想記憶の単位であるページごとに,データ領域/プログラム領域という目印を付けておき,データ領域でプログラムが実行されようとしたときに例外を発生するというものである。

 実は元々x86にも,ハードウエアによるデータ実行防止機能が実装されているのだが,Windowsはそれを使っていなかった(使えなかった)。

 x86にはセグメントと呼ぶメモリー管理機構がある。セグメントとは,メモリー領域を分割して利用する仕組みで,プログラムを格納するコード・セグメント,データを格納するデータ・セグメント,スタック用のスタック・セグメントなどに分ける。このセグメント機構に,コードの実行を許可/禁止する機能があるのだ。コード・セグメントにのみプログラムの実行を許可しておけば,データ・セグメントやスタック・セグメントでプログラムを実行できない。バッファ・オーバーフロー攻撃によってスタック・セグメント上のプログラムにジャンプしてしまっても,実行されない。

 だが,現在の32ビットWindowsはセグメント機構を使っていない。正確には,フラット・メモリー・モデルと呼ぶ,セグメント化しない(コード,データ,スタックが同じ1つのセグメントに置かれる)モードを使っている。16ビットWindowsでは,1つのセグメントの大きさが64Kバイトと小さかったため,セグメントを使わなければ64Kバイトを超えるメモリー領域を扱えなかった。その後x86は,32ビット化されたときに,フラット・メモリー・モデルを備え,セグメントを使う必要がなくなった。このモードでは,セグメントを使い分けなくても,OSとアプリケーションが連続した4Gバイトのアドレス空間にアクセスできるからだ。ところがこのモードでは,セグメントの切り替えという面倒な処理をする必要がなくなったものの,データ領域でのプログラム実行を禁止できないのである。

物理アドレス拡張モードのPTEに制御ビットを定義
 AMD64に実装された拡張ウイルス防止機能は,x86が元々セグメント単位で対応していたのと同等の機能を,より単位の小さいメモリー管理機構であるページングに適用したものである。ページング処理とは,ハードディスクなどを利用して物理メモリー容量が小さくてもより大きなメモリー領域を扱えるようにする,仮想記憶のための仕組みだ。x86では,アプリケーションが扱う論理アドレスから,まず「リニア・アドレス」と呼ぶ32ビットのアドレスが生成され,次にページング処理によって物理アドレスが生成される。


△ 図をクリックすると拡大されます
図8●ハードウエアDEPを有効にすると,物理アドレス拡張モードで動作する
 AMD64のCPUでは,ページを管理するためのページ・テーブル・エントリ(PTE)に,拡張ウイルス防止機能を制御するビットを定義した。ただし,拡張ウイルス防止機能を制御するビットは,64ビットのPTEの中で予約領域だった最上位ビット(第63ビット)に定義された。64ビットPTEは,Pentium Pro(P6)以降のx86プロセッサが備えるPAEモードだけで利用する。主記憶空間が4Gバイトのモードで使う32ビットのPTEには,NX用のビットは定義されていない(予約領域がなく新たに割り当てられなかった)。

 従って,ハードウエアDEP機能を利用するためには,PAEモードでOSを起動する必要がある。実際,AMD64アーキテクチャのCPUを備えたPCでDEP機能を利用するときは,必ずPAEモードで起動する(図8

動的に確保したメモリーでのプログラム実行に影響する
 ハードウエアDEPは,データ領域としてマークされたメモリー領域でのプログラム実行を阻止する機能である。プログラムの実行を許可するか阻止するかの判断のよりどころは,バッファ・オーバーフロー攻撃とは実は全く関係なく,メモリーのページ単位に付けられた制御ビットだけである。

 そのため,プログラム内で確保したデータ領域にプログラムをロードしてそれを実行するようなプログラムを意図して作ったとしても,ハードウエアDEPはその実行を阻止してしまうことがある。例えば,圧縮した実行ファイルをメモリー上に展開し,それを実行するようなプログラムは注意が必要である。JIT(Just In-Time)コンパイルするようなプログラムも同様である。

 これらのような処理をするには,メモリー領域を確保するのに,Cランタイム関数のmallocや,Win32 APIのHeapAllocを使ってはいけない。これらの関数はメモリーをデータ領域として確保するからだ。プログラムを格納するためのメモリー領域を確保するには,VirtualAlloc APIにPAGE_EXECUTEなどのフラグを指定して確保する必要がある。


△ 図をクリックすると拡大されます
図9●ハードウエアDEPによる例外
 SP1の適用前や,ハードウエアDEPが無効になっている状態では,mallocやHeapAllocで確保したメモリー領域でもプログラムを実行できる。そのため,本来VirtualAlloc APIで確保しなければならないのにmallocなどを使っていても,ハードウエアDEPで例外が発生してしまうことが表面化しないので,プログラム開発時には注意が必要である。独自開発したプログラムが,ハードウエアDEPが有効になる環境で,SP1を当てたとたんに例外が発生するようになったら,メモリーの確保方法を疑ってみるとよいだろう(図9

例外ハンドラの安全性を確認するソフトウエアDEP
 一方のソフトウエアDEPは,ハードウエアDEPとは少々アプローチが異なる。ソフトウエアDEPは,プログラムが備えている例外ハンドラが安全であることを確認する。

 一般に,「0で除算した」などプログラムで例外が発生すると,例外ハンドラと呼ぶ,そのプログラムが用意した特別な関数にジャンプする(発生した例外に対応するハンドラがない場合は,Windowsがメッセージ・ボックスを表示して,そのプログラムが強制終了される)。

 Windowsは「構造化例外ハンドラ」と呼ぶ仕組みを備えている(C++の例外ハンドラは,構造化例外ハンドラに似ているが,より機能を付加したものである)。それに対応したプログラムではスタックに例外ハンドラへのジャンプ・テーブル(複数のジャンプ先アドレスの一覧表)を用意する。構造化例外ハンドラを備えたプログラムで例外が発生したときの処理手順は次のようになる。

(1)例外が発生
(2)発生した例外のタイプと発生場所を調べるため
に,プログラムが用意した専用関数をWindows
がコール(呼び出す)
(3)スタック上にある例外ハンドラへのジャンプ先アドレスを検索
(4)そのアドレス先の例外ハンドラにジャンプ

ソフトウエアDEPは,上記処理の中の(3)と(4)の間に,(4)でジャンプしようとしている領域が実行可能領域としてマークされていることを確認する。もしもジャンプ先がデータ領域だった場合は,ジャンプしない。これによって,不正な例外ハンドラにジャンプし,実行されることを防ぐ。

 コンピュータ・ウイルスの中には,自分の活動を検知されにくくするために,例外ハンドラを利用するものがある。例外ハンドラを自分が用意した不正プログラムに設定し,わざと例外を発生させるわけだ。こうすると,自身の中に用意した不正プログラムにジャンプするコードがプログラムの中で直接実行されるわけではないので,活動が分かりにくくなる。ソフトウエアDEPはこのような悪意のあるプログラムの実行を阻止できる。


(『日経Windowsプロ』2005年3月号掲載「今から備えるWindows Server 2003 SP1」より)