x86 アーキテクチャで最も興味深く、よく使用される概念の 1 つが保護モードで、これは 4 モード(別名リング)でサポートされています:

これは把握しにくいアイデアだったので、この投稿ではできるだけ明確に説明しようと思っています。 この記事では、次の概念を取り上げます:

  • GDT, LDT, IDT.
  • Virtual Memory Translation.
  • ASLR and Kernel ASLR (KASLR).

まず基本から始めましょう、どんなコンピューターにも少なくとも(できれば)次のコンポーネントが備わっています。 CPU、ディスク、および RAM です。 これらのコンポーネントはそれぞれ、システムのフローにおいて重要な役割を担っています。 CPUはメモリ(RAM)上でコマンドやオペレーションを実行し、RAMは使用中のデータを保持し、高速で信頼性の高いアクセスを可能にし、ディスクは再起動やシャットダウン後も存在する必要がある永続的なデータを保持しています。 これは基本中の基本ですが、この記事を読みながら、どのコンポーネントのことを話しているのか自問自答することが重要です。

OS は、すべてを統括するソフトウェアであり、また、ハードウェアへのアクセスや、利便性やパフォーマンスを向上させるための、高速で便利、かつ一貫した効率的なインターフェイスを提供するソフトウェアでもあります。 カーネルの重要性を理解するためには、まず、カーネルの動作と直面する課題を理解する必要があります。

  • ハードウェアとソフトウェアの間を取り持つ。
  • これらの動作の多くは、プロセッサの寛大な助けを借りて行われ、x86 の場合、保護モードは、現在実行中の実行コンテキストにパワー(命令セット)を制限することを可能にするモードである。 任意の時点で、これらの世界のうちの 1 つにしか存在できません。 ユーザーの世界にいるときは、監督者が見たいと思うような世界を見ることができます。

    あなたがプロセスであるとします。 プロセスは、1つまたは複数のスレッドのコンテナです。 スレッドは実行コンテキストであり、それはマシン命令が実行される論理的な単位である。 つまり、スレッドがメモリアドレス 0x808080 から読み取るという実行をするとき、実際には現在のプロセスの仮想アドレス 0x80808080 を参照していることになります。 このアドレスの内容は、2つのプロセス間で異なることがおわかりいただけると思います。 つまり、同じプロセスのすべてのスレッドは同じアドレス空間を持ち、同じ仮想メモリにアクセスすることができます。

    So I have a thread that executes the following code:

    our thread execute the main function which will call our “func” function.The thread will execute the main function that will call our func function. スタックレイアウトは次のようになります:

    1. variable_a.
    2. parameter.
    3. return address – 20 行目のアドレス
    4. variable_b.

    例示すると、

    このコードでは、プロセス用に 3 つのスレッドを作成して、それぞれが ID、スタック セグメント、スタック ポインタを表示するようになっています。

    このプログラムの可能な出力は次のとおりです:

    見てわかるように、すべてのスレッドは同じ仮想アドレス空間を持っているので、同じスタックセグメントを持っていました。 スタック セグメントについての余談ですが、セグメント レジスタについては、GDT/LDT のセクションで詳しく説明しますが、今は私の言葉を聞いてください。 任意の時点で、プロセッサはスレッドをフリーズし、望む他のスレッドに制御を委ねることができます。 カーネルの一部として、スケジューラは、現在存在する (そして「準備完了」) スレッドに CPU を割り当てるものです。 スレッドを確実かつ効率的に実行するためには、各スレッドが独自のスタックを持ち、その中に関連する値(たとえば、ローカル変数やリターンアドレス)を保存できることが不可欠です。

    そのスレッドを管理するために、OS は TCB (Thread Control Block) という各スレッドの特殊構造を保持しており、その構造には特にスレッドのコンテキストとその状態(実行/準備/その他)が保存されています。

    • EBP -> スタックのベースアドレス、各関数はこのアドレスをベースアドレスとして使用し、そこからローカル変数とパラメータにアクセスします。
    • 汎用レジスタ -> EAX, EBX, etc…
    • Flags register.
    • C3 -> contain the location of the page directory (will be discussed later).
    • EIP – the next instruction to be performed.

    Besides the operating system needs to keep track after a lot of other things, including processes.Threads は他のスレッドも含め多くの事柄を追跡します。 プロセスについて、OS は PCB (Process Control Block) 構造を保存し、各プロセスには独立したアドレス空間があると述べました。 とりあえず、各仮想アドレスを物理アドレスにマッピングするテーブルがあり、そのテーブルがPCBに保存されていると仮定して、OSはそのテーブルを更新し、物理メモリの正しい状態に更新し続ける責任があります。 スケジューラが与えられたスレッドに実行を切り替えるたびに、そのスレッドの所有プロセス用に保存されたテーブルが CPU に適用され、仮想アドレスを正しく変換できるようになります。

    Global Descriptor Table

    私たちは皆、プロセッサが計算を行うのに役立つレジスタを持っていることを知っていますが、あるレジスタは他のレジスタよりも多く持っています(;)。 このテーブルは、すべての仮想アドレスを対応するプロセッサのモードにマップし、そのアドレスに対する権限 (読み取り/書き込み/実行) も含んでいます。 プロセッサの実行の一部として、次に実行する命令(およびそのアドレス)をチェックし、そのアドレスをGDTと照合して、その命令が有効かどうかを、希望するモード(CPUの現在のモードとGDTのモードを一致)とパーミッション(実行可能ではない場合は無効)に基づいて判断します。 例えば、gdtrレジスタに値をロードする命令である「lgdtr」は、前述のように監視モードでのみ実行可能です。 ここで強調したいのは、メモリ操作(命令の実行/無効な場所への書き込み/無効な場所からの読み取り)に対する保護は、OSが構築したこれらの構造体を用いて、プロセッサレベルでGDTとLDT(次に説明)が行うという点です。

    GDT/LDTのエントリーの中身はこんな感じです。

    http://wiki.osdev.org/Global_Descriptor_Table

    このとおりで、そのエントリに関するアドレス範囲が記載されている。 と、期待通りの属性(パーミッション)になっています。

    Local Descriptor Table

    GDT について述べたことは、小さな(しかし大きな)違いがある LDT についても当てはまります。 その名前が示すように、GDT はシステム上でグローバルに適用され、LDT はローカルに適用されます。 GDTはすべてのプロセス、すべてのスレッドのパーミッションを追跡し、それはコンテキストスイッチの間に変更されませんが、一方、LDTはそうです。 各プロセスが独自のアドレス空間を持っている場合、あるプロセスではアドレス0x10000000が実行可能で、別のプロセスでは読み取り/書き込みのみということがあり得るのは理にかなっていると言えるでしょう。 これは、ASLRが有効な場合に特に顕著です(後述します)。 LDT は、各プロセスを区別するパーミッションを維持する責任があります。

    1 つ注意していただきたいのは、ここで述べたことはすべて構造の目的ですが、実際には OS によっては構造の一部をまったく使用しない場合もあります。 これはすべてOSの設計の一部であり、トレードオフの関係です。

    Selectors

    特定の命令を実行するとき、プロセッサはどのようにしてGDTやLDTのどこを見るかを知るのでしょうか。

    https://en.wikibooks.org/wiki/X86_Assembly/X86_Architecture

    - Stack Segment (SS). Pointer to the stack. - Code Segment (CS). Pointer to the code. - Data Segment (DS). Pointer to the data. - Extra Segment (ES). Pointer to extra data ('E' stands for 'Extra'). - F Segment (FS). Pointer to more extra data ('F' comes after 'E'). - G Segment (GS). Pointer to still more extra data ('G' comes after 'F').

    各レジスタは16ビット長で、次のような構造になっています。

    http://www.c-jump.com/CIS77/ASM/Memory/M77_0290_segment_registers_protected.htm

    したがって、GDT/LDTへのインデックスがあり、LDTかGDTか、どのモード(RPL 0はスーパーバイザ、4はユーザー)でなければならないというビットも持っています。

    GDT/LDT は CPU に各仮想アドレスのパーミッションを伝え、IDT は「ゲートウェイ」関数から私たちの愛するカーネル(メモリの監視セクション内にあることは明らか)を指しているということが分かりました。 これらの構造が実行中のシステムでどのように動作するのか、

    Virtual Memory

    これがすべて一緒に動作する方法を理解する前に、もう 1 つの概念、仮想メモリについて説明する必要があります。 各仮想メモリ・アドレスを物理メモリ・アドレスにマッピングするテーブルがあると言ったのを覚えていますか? 実はもう少し複雑なのです。 OSは効率とパフォーマンスのためにメモリのページをディスクにスワップすることができますが、必要な仮想アドレスのメモリページがその時点でメモリ上にない可能性があります。 MMU (Memory Management Unit) は、仮想メモリを物理メモリに変換する責任を負うコンポーネントです。

    理解するうえで本当に重要なことは、監視モードのコードでさえ、すべてのモードのすべての命令が仮想アドレス変換のプロセスを通過していることです。 いったん CPU が保護モードになると、実行するすべての命令は仮想アドレスを使用し、決して物理アドレスにはなりません (実際の仮想アドレスが常にまったく同じ仮想メモリに変換されるようにするいくつかのトリックがありますが、それはこの記事の範囲外です)。 1083>

    では、このページディレクトリはどのように見えるのでしょうか。 効率性を考えると、この「テーブル」をできるだけ速くクエリできるようにする必要があります。また、このテーブルはプロセスごとに作成されるため、できるだけ小さくする必要があります。 この問題に対する解決策は、見事としか言いようがない。 MMU には 2 つの入力があり、変換する仮想アドレスと CR3(現在関連するページ ディレクトリへのアドレス)です。 x86 仕様では、仮想アドレスは 3 つの部分に切り分けられます:

    • 10 ビット数 – ページ ディレクトリへのインデックス.
    • 10 ビット数 – ページ テーブルへのインデックス.
    • 12 ビット数 – 物理アドレス自体へのオフセット.
    • 10 ビット数 – ページ ディレクトリのインデックス.

    したがって、プロセッサは最初の10ビット番号を取り、それをページディレクトリへのインデックスとして使用します。 各ディレクトリテーブルのエントリは、その後、仮想アドレスから最後の12ビットオフセットが物理的に正確な場所を指すように使用される4K境界メモリページへのポイントです。 このソリューションの優れた点は、

    • 各仮想アドレスがまったく関係のない物理アドレスに位置する柔軟性。
    • 関係する構造のスペース効率は驚くべきものです。

    Kernel vs User

    ここからが興味深い (そして不思議な) 話です。

    この記事では、OS がすべてを指揮し、カーネルを使用してそれを行うことを述べました。 すでに述べたように、カーネルは、すべてのプロセスの GDT において監視モードとしてのみマップされたメモリ セクションで実行されています。 しかし、カーネルはそのアドレス空間(OSによって異なりますが、通常は上半分)を私用に切り、アドレス空間を切り取るだけでなく、すべてのプロセスに対して全く同じアドレスで切り取るのです。 カーネルのコードは固定されており、変数や構造体への参照はすべてのプロセスで同じ場所にある必要があるため、これは重要なことです。

    Deeper into interrupts

    IDT が関数のアドレスを含むことは知っていますが、これらの関数は ISR (Interrupt Service Routine) と呼ばれ、ハードウェアイベントが発生したとき (キーボードのキーを押したとき) とソフトウェアが割り込みを開始したとき (たとえばカーネル モードに切り替えたときなど) に実行されるものがあります。 特に重要な割り込みの1つは、クロックの刻みです。 時計の針が動くたびに割り込みが発生し、そのISRによって処理されます。 OSのスケジューラはこのクロックイベントを利用して、各プロセスの実行時間や次のプロセスの順番を制御しています。 この割り込みは非常に重要で、発生したらすぐに処理する必要がありますが、すべてのISRが同じ重要性を持っているわけではなく、ここで割り込み間の優先順位が決まります。 例えば、キーボードのキーを押したとします。キーボードのISRが実行されている間、同じ優先度以下の割り込みは無視されます。 ISRの実行中に優先度2のクロックのISRが起動し(だから無効にならなかった)、クロックのISRに直ちに切り替わり、クロックが終了すると停止したところからキーボードのISRに制御を戻します。これらの割り込みの優先度をIRQL(Interrupt ReQuest Level)と呼び、IRQLが上がると優先度も高くなります。 最も優先度の高い割り込みは、決して途中の割り込みではなく、常に最後まで実行されます。 IRQLはWindows特有のもので、0-31の間の数字です。一方、Linuxでは、これは存在せず、Linuxはすべての割り込みを同じ優先度で扱い、本当に特定のルーチンを邪魔されたくないときには、単にすべての割り込みを無効化します。 このように、すべては設計と好みの問題です。

    それをすべて、私たちの愛するユーザーモードに接続しましょう。 そのクロックイベントのISRは、現在実行されているスレッドに関係なく実行されようとしており、さらに無関係なタスクのための別のISRに割り込むかもしれません。これは、カーネルがすべてのプロセスのために同じアドレスである理由の完全な例です。我々はそれが任意のユーザーモードのプロセスの単一の関数中に何度も起こるように割り込みを実行するたびにGDTとページディレクトリ(C3)を変更したくありません。 この定義は正確ではありませんが (すべての割り込みが外部または独立であるわけではありません)、ポイントを押さえるには良いことで、カーネルの仕事の大部分は、あらゆる場所 (入力デバイス) から常に発生するイベントを理解し、一方ではそれらのイベントを提供し、他方ではすべてが正しく相関していることを確認することです。

    では、すべてを理解するために、次の命令を実行する単純なユーザー モード アプリケーションから始めてみましょう。

    0x0000051d push ebp;

    CPU が実行中の各命令について、まずその命令のアドレス(この場合は ‘0x0000051d’ )をコード セグメント レジスタ(実行する命令なので ‘cs’ )で GDT/LDT に対して調べてテーブルで探すべきインデックス(セグメント レジスタが CPU に正確に場所を伝えることを覚えています)を確認します。 CPUは、命令が実行可能な場所にあり、正しいリング(ユーザーモード/カーネルモード)にあることを知ると、今度はその命令を実行し続けます。 この場合、「push ebp」命令はレジスタだけでなく、プログラムのスタックにも影響を与えるので(スタックにebpの内容をプッシュします)、CPUはGDT/LDTと比較してespレジスタ内のアドレス(スタックの現在の位置のアドレスで、スタックの位置なのでCPUはこれを行うにはスタックセグメントレジスタを使用することを知っています)もチェックして、その特定のリングで書き込みが可能かどうかを確認します。 CPU はすべてのセキュリティ面をチェックした後、今度はメモリにアクセスし操作する必要があります。 MMUは今、私たちは最終的に物理的なものにアドレスを変換することができますページディレクトリ(ページテーブルを指す)を指すCR3レジスタを使用して物理メモリアドレスに命令で指定された各仮想メモリを変換します。 その場合、OS はページフォルト (割り込みを発生させる例外) を生成し、データを物理メモリに移動して、実行を継続します (これはユーザー モード アプリには透過的です)。 ユーザモードアプリケーションから、int <num> という命令は、IDTのnumというインデックスにある関数に実行を移します。 実行がカーネル モードである場合、多くのルールが変更されます。各スレッドはユーザー モードとカーネル モードで異なるスタックを持ち、メモリ アクセスのチェックははるかに複雑で必須です。

    ASLR (Address Space Layout Randomization) は、各 OS で異なる実装がされていますが、コンセプトは、プロセスとそのロード ライブラリの仮想アドレスをランダムにすることです。

    本題に入る前に、この投稿に ASLR を含めることにしたのは、保護モードとその構造により、ASLR がどのようにこの種の機能を実現しているかを見る良い方法であるからです (この点に関しては、実装したものでも、責任を負うものでもありません)。 誰かが実行中のプロセスにコードを注入することができたとき、いくつかの有益な関数のアドレスを知らないことが、攻撃を失敗させる原因となります。

    私たちはすでにプロセスごとに異なるアドレス空間を持っていますが、これは ASLR なしではすべてのプロセスが同じベース アドレスを持つことを意味します。 プログラムをリンクする際、リンカーは固定されたベースアドレスを選択し、そのアドレスに実行ファイルをリンクします。 このため、同じリンカーがデフォルトのパラメータでリンクした実行ファイルは、すべて同じベースアドレスになります(ベースアドレスは必要に応じて設定できます)。 例えば、私は2つのアプリケーションを書きました。1つは「1.exe」、もう1つは「2.exe」と呼ばれます。exe “という2つのアプリケーションを書きましたが、どちらもVisual Studioの異なるプロジェクトでありながら、同じベースアドレスを持っていました(PEファイルのベースアドレスを確認するためにexeinfo PEを使用しました)。

    これらの二つの実行ファイルは同じベースアドレスを持っているだけでなく、両方とも ASLR をサポートしていません(私はそれを無効にしています)。

    ファイルの特性で PE 形式にも含まれていることが確認できる。

    では、両方の実行ファイルを同時に実行して、両方とも同じベース アドレスを共有してみましょう(ベース イメージを見るために、Sysinternals の vmmap を使用する予定です)。

    我々は、両方のプロセスはASLRを使っていないことと0x004000という同じベースアドレスがあることから、このことがわかります。 もし私たちが攻撃者で、この実行ファイルにアクセスできたとしたら、このプロセスの実行に自分自身を注入する方法を見つけたら、どのアドレスが利用可能になるかを正確に知ることができたはずです。

    Windows では、効率上の理由から特定の実行可能ファイルのランダム化アドレスが固定されているという興味深い実装の詳細があります。 つまり、たとえば calc.exe のアドレスをランダム化すると、2 回目に実行されたときのベース アドレスは同じになるということです。 つまり、もし私が2つの電卓を同時に開いたとしたら、それらは同じベースアドレスを持つことになります。 2台の電卓を同時に開くと、2台とも同じベースアドレスになり、2台とも閉じてからもう一度開くと、また同じベースアドレスになります。 なぜ効率が悪いかというと、よく使われるDLLについて考えてみてください。 DLLは多くのプロセスで使われていますが、もしプロセスのインスタンスごとにベースアドレスが違えば、コードも違ってきます(コードはこのベースアドレスを使ってデータを参照します)し、コードが違えば、プロセスごとにDLLをメモリに読み込む必要があります。 しかし、実際には、OSが画像を読み込むのは、その画像を使用するすべてのプロセスに対して一度だけです。 1083>

    Conclusion

    ここまでで、カーネルの動作をイメージすることができ、x86 アーキテクチャのすべての主要な構造がどのように連携して大きな絵になるかを理解し、ユーザー モードで危険なアプリケーションを恐れずに(またはほとんど恐れずに)実行できるようになったことでしょう。

    Articles

    コメントを残す

    メールアドレスが公開されることはありません。