Operating Systems Development Series | |
Portable Executable (PE)
はじめにようこそ! どうも、長くなりそうです。 この章では、高度なトピックである PE 実行可能ファイル形式を取り上げます。PE リソース、ダイナミックリンク、その他をカバーする予定です。また、この章は、可能な限り完全な情報にするために、より多くの情報を含むように更新される予定です。 この章に含まれるほとんどの内容は、情報提供のみを目的としており、完全性を期すためと、読者がサポートを提供したい場合のために含まれているに過ぎません。また、提供された情報の多くは、公式の PE 仕様書にも記載されていることに注意してください。 この章を終えれば、ローダーを開発し、シングルタスク環境をサポートするために必要なものは全て揃うでしょう。 さぁ、はじめましょう ファイルフォーマット概要Portable Executable (PE)ファイルフォーマットは、Windows や ReactOS のような Windows 系 OS を含むいくつかのオペレーティングシステムで使われている標準的な実行ファイルフォーマットです。また、EFI (Extensable Firmware Interface)搭載機で起動する際に使用される標準的なファイルフォーマットでもあります。 PE実行ファイル形式は、再配置、シンボルテーブル、リソース、ダイナミックバインディングなどをサポートする複雑な形式である。 用語VA (バーチャルアドレス)仮想アドレス(VA)とは、現在のプログラムの仮想アドレス空間(VAS)内の線形アドレスのことである。PE実行形式では、すべてのアドレスが仮想アドレスになります。これらのアドレスは32ビットのリニアアドレスである。 RVA (相対的仮想アドレス)相対仮想アドレス(RVA)とは、実行プログラムのベースアドレスからの相対的なVAである。PE実行形式は多くの部分でRVAを使用していますので、RVAとは何か、RVAからリニアアドレスを取得する方法について知っておくことが重要です。RVAとは、ベースアドレスからのオフセットに過ぎません。従って、リニア・アドレスを得るには、ベース・アドレスにRVAを加えればよいのです。
これはパース時に多くの部分でこの計算を行う必要があるので重要である。 セクションとセクション・テーブルセクション 高度な実行ファイル形式では、一般にプログラムセクションを使用してリンクプロセスを簡略化し、ソフトウェアに構造を与えます。 セクションは、実行イメージまたはオブジェクトファイル内に命令とデータを格納するための標準的な方法を提供することによって、リンクプロセスを簡略化します。 セクションは通常、セクションの中にある要素に関連する名前を持っています。例えば、.dataは可変の未初期化データを格納する一般的なセクション名です。その他のセクション名には、歴史的な背景があります。例えば、.textは実行可能なコードやオブジェクトコードを含むセクションの典型的な名前です。.bssは一般的に、グローバルでプログラム全体の初期化データに使用されます。 C++ツールチェーンを例にとると、グローバル名前空間またはstaticとして定義された変数は.bssに格納されます。コンパイル後に生成されるバイトコードは.textに格納されます。 PE実行ファイル形式は、通常、以下のセクション名のうちの1つ以上を含んでいます。
セクション表 プログラムファイルやオブジェクトファイルには、複数のセクションが含まれています。各セクションのベースロケーションとセクションの名前は、通常セクションテーブルに格納されます。セクション・テーブルは、構造体の単純なリンクリストやハッシュ・テーブルなど、さまざまな実装があります。 シンボルとシンボル・テーブルシンボル C++でプログラミングをしていると、有名な未定義シンボルのリンカーエラー(C言語では警告)に遭遇したことがあるでしょう。その通り、昔のMSVCではエラーなしで完全にコンパイルとリンクができました)。これは、リンク時に定義が解決できなかった関数を呼び出したり、変数を名前で参照したりした場合に起こります。 リンカは関数や変数をシンボルと呼んでいます。シンボルには名前と、それが何であるかという情報(例えばデータ型や値など)が含まれています。コンパイル時に、コンパイラはこれらのシンボルを追跡して、最終的なプロ グラムがリンクできることを保証しなければなりません。もし、現在の翻訳ユニットで定義されていないシンボルが使用されているが、EXTERNシンボルである場合、コンパイラはオブジェクトファイルにシンボルを書き込む際に、それをEXTERNシンボルとしてマークする必要がある。 リンクの段階で、EXTERNとマークされたシンボルにまだ値が関連付けられていない(シンボルが定義されていない)場合、リンカーは上記のエラーを出します。 シンボルは、プログラマがモジュール、翻訳ユニット、またはライブラリにまたがって変数や関数を定義するためのものです。 このため、高級言語を使用すると名前の衝突が起こりやすいので、変数名や関数名には一般的に名前潰し(name mangling)が行われています。もちろん、アセンブリ言語には適用されませんが、適用される名前のマングリングは複数の要因に依存し、ツールチェーンによって異なります。 では、見てみましょう。右はC言語の関数宣言とそのマングル化されたシンボル名です。マングリングされた名前の中の数字は、パラメータのバイト数です。
呼び出し規約が_cdeclの関数は、アンダースコアのみが先頭に付くことに注意してください。これにより、Cの関数をアセンブリ言語で簡単に定義でき、Cのコードで簡単にその関数を呼び出すことができます。 C++の名前のマングリングに関する標準はありません。コンパイラによっては、?h@YAXH@Zのようなシンボリックな名前を出すものもあれば、void h(int)という同じ関数に対して__7h__FiやW?h$n(i)vといった名前を出すものもあります。このため、アセンブリ言語で使用するのは非現実的です。しかし、まだ可能性はあります。 シンボル表 セクションテーブルと同様にシンボルテーブルが存在します。シンボル・テーブルは、シンボル名やシンボルに関する情報(エクスポートされたシンボルかどうか、データ型、プロパティなど)をソフトウェアが検索できるようにするものである。シンボル・テーブルは通常、情報のリンクリストか、ハッシュ・テーブルで実装される。 構造概要MSVC++の章でPE実行形式の構造を見てきました。 PE実行形式をメモリにロードすると、そのメモリはロードしたファイルの正確なコピーを含むことになります。これは、PEファイル形式の最初の構造内の最初のバイトが、実際にファイルがメモリにロードされたところから最初のバイトに位置することを意味します。 例えば、PEファイルを1MBにロードした場合、メモリ内のフットプリントは次のようになります。 ![]() MSVCの章を読んでいる読者には、上記の画像は見慣れたものに見えるはずです。上の画像を見ると、PEファイルが1MBにロードされた場合、最初のディスク上の構造体であるIMAGE_DOS_HEADERがメモリ内のその位置から始まり、ファイル内の残りの構造体が続きます(パディングを含む)。 上の画像も単純化しすぎで、決してPEファイルフォーマットの完全な姿を示しているわけではありません。 PEファイルフォーマットの構造はかなり大きく、多くの構造体とテーブルで構成されています。 以下は、その完全な形式です。
上の表は、ファイルの最初から最後までの完全なフォーマットを示しています。重要な項目は、プログラムを実行するために必要なパース方法を示しています。その他の情報は、情報提供のみを目的として提供されています。IMAGE_OPTIONAL_HEADER構造体の中で重要なのは、エントリポイントのアドレスを含むメンバと、画像のベースアドレスだけである。 このファイルの各セクションのパースについては、次節以降で詳しく説明する。また、テーブルやディレクトリを解析する際に使用される他の構造体も同様に紹介します。 IMAGE_DOS_HEADER構造体IMAGE_DOS_HEADERはPEファイルの最初の構造体である。これは、プログラムファイルとそれをロードする方法に関するグローバルな情報を含んでいます。この構造体に含まれる情報のほとんどは、DOS ソフトウェアに関連するものであり、 後方互換性のためにのみサポートされています。 この構造体は,次のような形式になっている。
さてさて、この構造体には興味深いものがたくさんあります。オペレーティングシステムは通常CSにスタック空間とコード記述子の値を割り当てるので、CS:IPとSS:SPの初期メンバは無視する必要があります。これらのメンバは、DOSエリアやv8086モードを必要とするソフトウエアの時代に目立つようになった。 STUBプログラムさて、それではIMAGE_DOS_HEADER構造体の直後にDOSスタブプログラムがあることに注目してください。 これは、実は便利なプログラムなのです。これは、DOSの中からWindowsのプログラムを実行しようとすると、「このプログラムはDOSモードでは実行できません」と表示するプログラムなのです。 このスタブプログラムを変更するには、/STUBというリンカーオプションを使用します。
DOSが実行ファイルをロードしようとすると、IMAGE_DOS_HEADER構造体を解析し、有効なDOSプログラムであるため、DOSスタブプログラムを実行しようと試みます。Win32サブシステムの下で実行される場合、Windowsローダーはスタブプログラムを無視します。 画像_NT_HEADERSSTUB プログラムに続いて、PE ヘッダー構造体のフォーマットを含むIMAGE_NT_HEADERSという構造体があります。以下は、その構造体である。
署名は、"PE0 "にマッチする必要があります(ただし、"PE0 "はヌル文字)。IMAGE_FILE_HEADERには、ローダーが使用する追加情報とIMAGE_OPTIONAL_HEADER構造体の全サイズが格納される。IMAGE_OPTIONAL_HEADERは、ファイルの中で最も大きく、最も重要な構造体である。 また、定義されたサイズを持っていない。 e_lfanewはメモリ上のこの構造体へのRVAなので、この構造体の位置を特定するために、OSローダーは次のことを実行する必要があります。
これは、imageBaseがプログラムファイルがメモリにロードされた場所を参照していると仮定しています。DOS などの古い OS では、このヘッダのメンバは認識されないので、これらの OS では無視されます。 この構造体には、他の2つのヘッダー構造体のフォーマットが含まれています。ここでは、そのうちの最初の構造体を見てみましょう。 IMAGE_FILE_HEADERIMAGE_FILE_HEADERは、COFF (Common Object File Format)ヘッダー構造体です。次のような形式になっています。
この構造体はあまり複雑ではありません。上記のほとんどは、デバッガ(シンボルテーブルの解析)にのみ有用です。SizeOfOptionalHeaderは重要です - IMAGE_OPTIONAL_HEADERはサイズが定義されていないため、このメンバーで構造体のサイズを知ることができます。 Machineは以下の値のいずれかになります。
通常の場合、x86アーキテクチャ向けに開発しているので、0x014cであるべきである。 特性はビットフラグで構成されており、リンカがビット単位でORすることで、ローダに実行イメージの種類の異なる特性を知らせることができます。以下はその形式である。
Windows のヘッダでは、IMAGE_FILE_RELOCS_STRIPPED や IMAGE_FILE_EXECUTABLE_IMAGE など、これらのフラグを設定する際に使用できる定数を定義して使用しています。 見ての通り、この構造体のほとんどは、ローダーが画像を読み込む方法に関する情報のみを提供するためのものです。しかし、ちょっと待ってください!リソース、シンボルテーブル、デバッグ情報......これはどこにあるのでしょうか?ああ、IMAGE_OPTIONAL_HEADERが定義されたサイズを持っていない理由を見よ。見てみましょう。 IMAGE_OPTIONAL_HEADERうーん、これだ。これはファイルの中で最も複雑な構造です。しかし、この構造体は以前にも見たことがあるはずです。
まず、最後のメンバーであるDataDirectoryを見てください。定数IMAGE_NUMBEROF_DIRECTORY_ENTRIESは、長年にわたって変更することができますし、これまでも変更されてきました。これは、この構造体のサイズを変更することができるメンバーです。このメンバーについては、もう少し後で詳しく見てみましょう。 このヘッダーは明らかにオプションではないのに、なぜ「オプション」と呼ばれているのか、興味を持たれたかもしれません。これは、COFFオブジェクトファイルではオプションであるためです。実行可能なイメージではオプションではありませんが、オブジェクトファイルではオプショナルです :) マジックは、以下のいずれかになります。
この構造体の多くのメンバーは、それほど複雑ではない。 subsystemメンバは Windows 固有のものである。プログラムが正しく実行されるために、どのようなサブシステムが必要かを Windows に伝える。以下の値のいずれかになります (完全を期すためにここに掲載します)。
DllCharacteristicsメンバは、ローダがDLLに関する情報を得るためのビットフラグを含む。以下のフォーマットで記述する。
AddressOfEntryPointは重要なものです。このメンバには、イメージのエントリポイント関数のRVAが含まれています(DLLにはエントリポイントが不要なため、NULLでもかまいません)。 これだけです。.text、.data、.bssなどの他のメンバーについて興味があるかもしれません。また、DataDirectoryという厄介なメンバーもありますが、これはまだ見ていません。 これらのメンバーについては、後ほど詳しく見ていきます。とりあえず、プログラムの実行を見てみましょう。 プログラムの実行この段階で、もしあなたがプログラムを実行したいだけなら、すべての情報は提供されています。プログラムをロードした後、ローダーがすべきことは、オプショナル・ヘッダからAddressOfEntryPointメンバを探し出し、そのアドレスを呼び出すことです。これはRVAであり、ローダーはこのアドレスをImageBaseに追加して、エントリポイント関数へのリニアアドレスを取得する必要があることを忘れないでください。 以下はその一例です。
PE 実行ファイルを実行するのに必要なのは、これだけです :) データディレクトリ概要リソース、シンボルテーブル、デバッグ情報、インポート、エクスポートテーブルなどは、 オプションのヘッダにあるDataDirectoryメンバからアクセスすることができます。このメンバーはIMAGE_DATA_DIRECTORY の配列であり、 これらの情報を含む他の構造体にアクセスするために使用されます。IMAGE_DATA_DIRECTORYは以下のような形式になっています。
DataDirectoryは、IMAGE_DATA_DIRECTORYの 配列であることを忘れないでください。この配列の各エントリによって、アクセスしたい異なるデータにアクセスすることができます。 以下は、インデックスのエントリです。
例えば、エクスポートテーブルを読み込む場合は、DataDirectory[0]を参照する。リソースを読み込む場合は、DataDirectory[2].VirtualAddressを参照します。 これらのセクションには、特定のデータを解析するために必要な独自の構造体が含まれています。 より便利なものをいくつか見てみましょう。 エクスポートテーブルの読み込みエクスポートテーブルには、ライブラリやDLLからエクスポートされたすべての関数と、そのDLL内の関数アドレス、関数名、序数などが含まれています。Win32 API 関数GetProcAddress()は、序数または名前によってモジュールのエクスポートテーブルを解析し、そこからアドレスを返すことによって動作します。 これは、エクスポートテーブルを読むことが有用である方法の 1 つです。 エクスポートテーブルを解析するためには、まずエクスポートディレクトリの構造を取得する必要があります。これはDataDirectory[0] を取得することで行われます。
IMAGE_DATA_DIRECTORY構造体のVirtualAddressはRVAなので、イメージベースに追加する必要があることに注意してください。これで、exportDirectoryは、この素敵な構造体を指すようになります。
こちらは簡単ですね。AddressOfFunctionsは関数アドレスの配列を指すRVAです。 ただし、関数アドレスもRVAです。AddressOfNamesは関数名のリストへのポインタです。 しかし、これらのアドレスはすべてRVAですので、関数名とアドレスを正しく取得するためにイメージベースに追加する必要があります。 AddressOfNameOrdinalは序数列のリストへのRVAです。序数は、アドレスではなく、エクスポートされた関数を表す単なる番号なので、RVAではありません。 エクスポートテーブルを正しく解析するためには、ループで行う必要があります。例えば
これは、DLLのサポートに便利なGetProcAddress()を実装するために使用できます。 インポートテーブルの読み込みさて...エクスポートテーブルを読むのはそれほど難しくはないでしょう?インポートテーブルを読むのはそれほど難しくありませんが、エクスポートテーブルより少し複雑です。OK、OK、インポートテーブルを読んで何になるのでしょうか?読むというより、書き込むのです。プログラムのインポートテーブルにエントリを書き込むことで、GetProcAddress()を呼び出すことなく、ライブラリやDLLをまたいだ関数コールを可能にします。Windowsでは、遅延ロードされたDLLとシステムDLLでこれを実行します。 インポートテーブルを読み込むには、インポートディレクトリ構造を見つける必要があります。これはDataDirectory[1]にあります。
importDirectoryは、記述子の配列を指すことに注意することが重要である。これらのエントリはそれぞれ、インポートされたモジュール、例えばインポートDLLを表しています。この構造体を見てみましょう。
Name、OriginalFirstThunk、FirstThunkはRVAであることに注意することが重要です。つまり、データを適切に解析するために、画像ベースにアドレス(これらはポインタです)を追加する必要があります。Nameは、kernel32.dllのようなインポートモジュール名を指し示すRVAです。これはヌル文字で終端しています。 インポート記述子の配列で作業していることを思い出してください。この配列に含まれるインポート記述子の数をどうやって知ることができるでしょうか?配列はNULLのIMAGE_IMPORT_DESCRIPTORで終わっているので、各エントリをループする簡単な方法は次のとおりです。
TimeDateStampには、適切な時間/日付、または以下の値のいずれかを指定します。
ForwarderChainは、DLL間の呼び出しを他のDLLに転送するDLL Forward Referencingをサポートする場合にのみ使用されます。たとえば、Windowskernel32.dllの一部の呼び出しは、他のDLLに転送されます。 FirstThunkはIATを指し、OriginalFirstThunkはインポートされたすべての関数を表す構造体の配列を指します。 これがインポートネームテーブル(INT)です。これらのメンバは両方ともRVAです。 別の構造体が出てくることはおわかりですね。見てみましょう。
OriginalFirstThunkは、IMAGE_THUNK_DATA構造体の配列を指し示すRVAです。 うっ、やった、また構造体だ。でも、これは小さいものです。
以上で完了です。最初のパラメータは0でも構いませんが、これは関数が使用する可能性のある序数をローダーに示唆するものに過ぎません。Nameは、関数の名前を表す文字の配列です。 ここからが本題です。IATは、関数を表すアドレスのリストに過ぎません。関数とは?このIMAGE_THUNK_DATA配列の中にある関数です。IMAGE_THUNK_DATAの構造体を見て、それが関数名を表す単なるアニオンであることに気づいてください。これがインポート名テーブル(INT)です。 例えば、IMAGE_THUNK_DATA[3]にある関数の現在のアドレスを取得したい場合を考えてみましょう。そのアドレスはIATの3番目のドワードになり、IMAGE_IMPORT_DESCRIPTOR->FirstThunkで読み取ることができます。 そこで、関数名とアドレスを取得してみます。
画像結合 ここが面白いところです。IATには、実行時またはビルド時に、インポートされた関数のアドレスを入力することができます。拘束されたイメージとは、ビルド時にIATが関数に拘束されるイメージのことである。非拘束型イメージとは、ロード時にOSローダーによってIATが埋められるイメージのことです。 バウンデッドイメージの場合、外部DLL内の関数を呼び出すには、以下のようにします。
画像がバインドされていない場合、IATはジャンクを含んでいます。その場合、上記のコードが動作するようにIATを更新するのはOSローダーの責任となります。これは、ロードされたDLLモジュールのエクスポートテーブルを読み込んで(GetProcAddress()を呼び出し)、そのインポート関数のIATエントリを上書きすることで実行できます。IAT の上書きは、上記の方法で行うことができます - 関数の IAT エントリを取得したら、それを上書きするだけです :) 。 この方法は、DLLや他のモジュールにフックをインストールする際にも有効です。 サポートリソースはじめに Windows カーネルがディスクから何もロードせずに画像を表示し、XML 設定ファイルを扱うことができることを不思議に思ったことはありませんか?リソースを追加する作業をしたことがあるが、OSでサポートすることは可能だろうかと思ったことはないだろうか。その答えは、"もちろん!"です。 しかし、リソースのパースは、他のディレクトリタイプより少し複雑です。他のセクションと同様に、基本的なIMAGE_RESOURCE_DIRECTORY構造体があり、オプションのヘッダーのDataDirectoryメンバーから取得することができます。
これらのセクションにアクセスする方法がパターン化されていることにお気づきですか?そうそう、新しい構造体について説明します。
この構造体は、最後の3つを除いて、あまり面白いフィールドを持っていません。 Win32のリソースを扱ったことがある人なら、リソースがIDや名前で識別できることをご存知かもしれません。この構造体の 2 つのメンバによって、これらのエントリの数とエントリの合計量 (NumberOfNamedEntries + NumberOfIdEntries) を知ることができ、すべてのエントリをループする際に役立ちます。 おそらく推測できるように、エントリは DirectoryEntries 配列に格納されています。DirectoryEntriesはIMAGE_RESOURCE_DIRECTORY_ENTRY構造体の配列で構成されており、その形式は次のとおりです。
さて、これは醜い構造体です。この構造体は、1つのリソース、つまりリソースディレクトリを表しています。 リソース・ディレクトリの構造 リソースなのかリソースディレクトリなのか?ちょっと立ち止まってみましょう。(リソースはツリーとして格納されることを知っておくことが重要です。このツリーは次のような構造になっています。
リソースグループにはいくつかの種類があり、このグループに含まれるリソースの種類を知ることができます。 以下、グループIDを示します。
まず、リソースディレクトリのすべてのエントリをループすることについて見てみましょう。
これだけなら簡単でしょう?IMAGE_RESOURCE_DIRECTORY_ENTRYのIdメンバは、グループ ID を格納するために使用されます。ビットマップを探すなら、ルートディレクトリのビットマップグループにあるので、ID=2のエントリーを探せばいいのです。 IMAGE_RESOURCE_DIRECTORY_ENTRYはリソースエントリとディレクトリの両方を表すので、それが何であるかを見分けるにはどうしたらよいでしょうか。もちろん、DataIsDirectoryメンバです。このメンバが設定されている場合、それはディレクトリです。このメンバがセットされている場合、それはディレクトリです。ああ、しかし、それがディレクトリである場合、どのようにディレクトリを読むことができますか?見てみましょう。
これも悪くはない。エントリーがディレクトリの場合、上記はOffsetToDirectoryから新しいディレクトリへのオフセットを取得し、それをstartOfResourceSection に追加しています!?そう、これはオフセットであって、RVAではないのです。そうだ.なぜマイクロソフトは、なぜ!? リソースセクションの開始位置は、実際にはIMAGE_RESOURCE_DIRECTORY_ENTRY配列の最初のメンバーのアドレスです。 したがって、このアドレスをOffsetToDirectoryから得たオフセットに追加すれば、このディレクトリのIMAGE_RESOURCE_DIRECTORY構造へのポインタを取得することができるわけです。はい、それからこれらのディレクトリエントリを読み込む全処理が始まります :) 特定のリソースのためにディレクトリをパースしている最中であれば、ディレクトリ内のすべてのリソースエントリをループするだけです。resourceEntry IDフィールドが、探そうとしているリソースID(ここではプログラム固有のID)と一致すれば、リソース・データを見つけたことになります。 リソースデータは...ゾクゾクするような構造で格納されています。ディレクトリエントリ構造のOffsetToDataメンバから取得することができます。OffsetToDirectoryメンバと同様、これもリソースセクションの開始点からのオフセットです。 ポインタを取得すると、リソースデータを取り出すことができます。その構造体を見てみよう。
以上です。OffsetToDataは実際のリソースデータへのRVAで、Sizeはそのデータのサイズ(バイト数)です。例えば、ビットマップリソースを探している場合、OffsetToDataはビットマップのBITMAPINFOHEADER構造体を指すRVAとなり、どのビットマップローダでも扱うことができます。 まとめこの章はこれで終わりです。今後、デバッグデータ、COMDATSなどのセクションを追加し、更新していく予定です。 この章にはデモはありません。PE 実行可能ファイルフォーマットの内部動作に興味があり、それを使って作業したい人のために主にリリースされます。本編では、プログラムをロードして実行するだけかもしれないので、その他の情報は完全性のためにのみ提供されます。デモのためにテキストで提供されるすべてのコードは、動作確認済みです(若干の修正あり)。 次の章では、PE実行ファイル形式を使用し、ユーザーモードプログラムをサポートするためのローダーを構築します。その後、マルチタスクに進みます! |