はじめに
この記事においてはプロセス=アプリケーションのことです。プロセスと表現するときはOS内で動作中のもの、アプリケーションと表現するときはプログラ厶そのもの、というニュアンスです。
また、仮想記憶に関する基礎知識が必要になりますので、以下をご参照下さい。
プロセス内のメモリ保護について
アプリケーションは起動時にメモリ領域(仮想アドレス)が割り当てられます。アプリケーションは与えられたメモリの範囲内で自由に使えますが、自由であるがゆえに、プログラミングでしっかりメモリの管理をしないと脆弱性となります。
一番有名なのがバッファオーバーフロー攻撃です。これは、ある配列(アレイ)用にメモリアドレスを割り当てた後に、そのサイズを超える代入があった場合、スタック領域ですら上書きしてしまいます。
上記の例ではただ単に文字列を入れただけですが、命令等を自由に入力することも可能です。なので例えばその変数に『命令』と『その命令への戻りアドレス』をセットで代入すれば、アプリケーション内で不正な命令を実行することができます。
以下は脆弱性のあるプログラム上でバッファオーバーフロー攻撃を受けたときのイメージです。
C言語で作られたプログラムはメモリ上では以下4つの領域に分かれています。
- 機械語の命令が格納される Text Segment
- プログラムに規定されたGlobal変数等の初期値が格納される Initialize/Uninitialize Data Segment
- 必要に応じて動的に増える汎用のメモリ領域である Heap Segment
- 関数を呼び出した際の戻り先アドレスなどを格納していく Stack Segment
上の例では、Heap領域に配置された配列array1に『「shellを起動しろ」という命令コード(一般にシェルコードと呼ばれます)』と『その命令コードが格納されたメモリアドレス(つまりarray1[0]のメモリアドレス)』を含む値を代入します。これによりHeap領域からはみ出してしまい、Stack領域の関数Aからの戻り先アドレスを上書きするのです。そしてプログラムが実行の中で関数Aから呼び出し元に戻ろうとしたときに、"AA" に戻るはずが "XX" に戻ってしまい、シェルが起動されるわけです。このプログラムが root で動作していた場合は、root の権限でシェルを操作できる状態になってしまいます。
このような理由で、プログラミングするときは配列等に割り当てたサイズを超える代入が起こらないように、Bounds Checkするのがお作法として常識なのです。
Spectre の攻撃では、Bounds Check を施している正常なアプリケーション内に悪意あるコードを埋め込み、この Bounds Check をすり抜けてアプリケーション内の情報を抜き取られてしまうことが示されています。
プロセス間のメモリ保護について
複数のプロセス間が互いのメモリ領域を浸食しないようにメモリ領域の管理を行うのは、CPUに組み込まれたMMU(メモリ管理ユニット: Memory Management Unit)の仕事です。
プロセス内のメモリ領域保護はアプリケーション自らが(つまりプログラマが)行わなければなりませんが、プロセス間のメモリ領域保護の場合は、OSとMMUが協調して保護します。
具体的には、各プロセスはMMU内に各々のページテーブルを持ちます。ページテーブルはプロセスが理解している仮想アドレスを、メモリが実際に持つ物理アドレスに変換してくれるものです。このページテーブルにより、プロセスは互いの物理アドレス領域に入ってこれないように保護されるのです。
しかし例外があります。それはカーネル空間(OS用メモリ領域)です。
各プロセスはOS上で動作し、OSの助けを借りながら動作するので、OS のメモリ領域にアクセスできる必要があります。それが頻繁に必要になるものも多いため、パフォーマンスを考慮し、ページテーブルには、アプリケーション自身に与えられたメモリ領域(ユーザー空間)の他に、カーネル空間が共通して、ページテーブルに用意されています。
ただし、そのままだと危険なので、ページテーブルのカーネルメモリ領域のページには supervisor-bit を付け、CPUがカーネルモード(スーパーバイザーモード)で動作しているときのみにアクセス許可しています (例えば Windows PC だと『管理者権限』と呼ばれるものです)。ユーザモードでアクセスしようとすると『メモリアクセス違反』として処理されます(通常はプロセスが停止します)。ユーザモードとカーネルモードの切り替えは、マルチタスクOSの実行プロセスの切り替えでも利用される Context Switch によって実施します。
また、カーネルモードになったからと言って何でもかんでもアクセスできる訳ではなく、システムコール等のあらかじめOSアクセス用として用意されたライブラリ関数を使う必要があります。
これにより、どこぞのプログラマが作ったか分からぬアプリケーションが、勝手にカーネルメモリ領域に入ってこれないように保護されるわけです。
Meltdown の公表に先駆けて実装が進んだ KAISER (KPTI)
2017年前半までは、このページテーブルは各プロセスで1つのみとするのがごく一般的な実装でした。つまり、アプリケーション用の仮想アドレスとカーネル領域の仮想アドレスが、1つのページテーブルを使うようになっていました。
しかし今回の Meltdown の登場により、状況は変わりました。投機実行を実装したアウト・オブ・オーダー実行のセキュリティホールを突いた、アプリケーションのサイドチャネル攻撃によりカーネルメモリ領域を観測できることが(公表より前に一部の関係者に)知られたからです。
この攻撃は、ユーザモードのプロセスが(アクセス権違反である)カーネル領域へアクセスしようとするだけで、違反にはなるもののL1Dキャッシュに痕跡が残せる、というものでした。
そこで速やかに打ち出された対策が『KAISER(Kernel Address Isolation to have Side-channel Effectively Removed)』(読み方:かいざー)です。これは、ページテーブルを2つに分割し、1つをそのアプリケーション用、もう1つをカーネルメモリ領域の呼び出し用として管理する機能です。(厳密には、もともとはKASLRという「カーネルのメモリ空間をランダム化し、どこに配置されているかを秘匿化する」セキュリティ技術が突破され、その対策としてこのKAISERの策定を進めていたようですが、Meltdownにも効果的だと分かり、急スピードで実装が進んだ、ということのようです)
これにより、ユーザモードではカーネルメモリ領域に『メモリアクセス違反』すら出来ない状態を作り上げたのです。
この KAISER は、すぐに KPTI(Kernel Page-Table Isolation) という呼び名に変わりました。
Spectre と Meltdown の本質的な違い
以上のことから、Meltdown の特徴としては以下が挙げられます。
- 1つの悪意あるプログラムを OS 上で実行されることにより起きる
- もともとページテーブルから見えているがアクセス権違反となるカーネルメモリ領域に、直接アクセスをする
- アクセス権違反にはなるが、投機実行の機能により、L1D キャッシュに痕跡が残せるので、その痕跡を元に、アクセスしようとしたメモリ領域を知ることができる
これに対し、Spectre の攻撃の本質は、カーネルメモリ領域を読み込むことではありません。汎用的な説明としては『1つの正常なアプリケーション内に、悪意あるプログラムやスクリプトを埋め込んで、正常なアプリケーション内のメモリ領域を観測により読み込む』というものです。
具体例としては、『Web サーバに悪意ある JavaScript コードを埋め込み、Firefox という正常なアプリケーションからそのコードを読み込んで実行し、Firefox の中からパスワード保存領域を観測により読み込む』といったことができます。
Spectre のさらに恐ろしいところは、『ユーザがカーネル権限で実行可能な eBPF インタープリタにより、カーネル領域へのアクセスができる』ことや、『クラウド事業者から貸し出された仮想サーバのルート権限を使って、事業者が管理する仮想ホストのカーネル領域や、他のユーザの仮想サーバのカーネル領域が読み込める』ことです。
つまり、攻撃の実装として、ユーザがカーネル権限(=ルート権限)を持った状態で実行できるツール(eBPF や KVM)を使えば、Meltdown と同じようなことを『メモリアクセス違反無しで(カーネル権限/ルート権限を持ってるからその点では違反してない)』実行できるのです。
これらのことからもわかるように、Meltdown の対策として為されている KAISER/KPTI は、Spectre の回避策にはなり得ません。
まとめ
Spectre と Meltdown の共通点と相違点のポイントをまとめてみました。
コメント