読者です 読者をやめる 読者になる 読者になる

PE ファイルについて (7) - エクスポート編

第 7 回。
今回からデータ本体に踏み込んでいきます。
初回はエクスポート情報。
DLL が公開している関数の情報です。
コード多めで行きますよ。

dumpbin

まずは dumpbin の使い方から。
これまで /HEADERS オプションしか使ってきませんでしたが、DLL のエクスポート情報を表示するには /EXPORTS オプションを使います。

KERNEL32.DLL のエクスポート情報を表示したものの冒頭を掲載します。

> dumpbin /exports c:\windows\system32\kernel32.dll
Microsoft (R) COFF/PE Dumper Version 14.00.23506.0
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file c:\windows\system32\kernel32.dll

File Type: DLL

  Section contains the following exports for KERNEL32.dll

    00000000 characteristics
    5632CE68 time date stamp Fri Oct 30 10:56:56 2015
        0.00 version
           1 ordinal base
        1599 number of functions
        1599 number of names

    ordinal hint RVA      name

          1    0          AcquireSRWLockExclusive (forwarded to NTDLL.RtlAcquireSRWLockExclusive)
          2    1          AcquireSRWLockShared (forwarded to NTDLL.RtlAcquireSRWLockShared)
          3    2 00027640 ActivateActCtx
          4    3 00020FB0 ActivateActCtxWorker
          5    4 0002ABE0 AddAtomA
          6    5 0000D3F0 AddAtomW
          7    6 000655E0 AddConsoleAliasA
          8    7 00065740 AddConsoleAliasW
          9    8          AddDllDirectory (forwarded to api-ms-win-core-libraryloader-l1-1-0.AddDllDirectory)
         10    9 00046AF0 AddIntegrityLabelToBoundaryDescriptor

エクスポートと DEF ファイル

ちょっと回り道になりますが、まずは DLL から関数をエクスポートする方法について知っておきましょう。
ここでは最も基本的な DEF ファイルを使った方法を紹介します。

最低限の DEF ファイルはこんな感じです。

LIBRARY Hoge
EXPORTS
  Foo
  Bar
  Baz

これは、関数 Foo、Bar、Baz を公開する Hoge.dll の DEF ファイルです。
特に解説することはありません。

次に複雑な DEF ファイルの例を見てみましょう。

LIBRARY Hoge
EXPORTS
  Foo @2
  Bar @5 NONAME
  Baz = Hige.Sori

上から順に説明していきますね。

Foo には序数の指定(@2)があります。
すべての関数は序数という DLL 内で一意な数値を持ちます。dumpbin の結果で言うと ordinal がそれです。
序数は何に使うかと言うと、GetProcAddress で関数のアドレスを得る際に、関数名の代わりに使うことができます。
序数で呼び出すと、呼び出しがちょっとだけ高速になりますが、微々たるものです。わかりにくくなるので推奨される方法ではありません。
通常、DEF ファイルで序数を明示的に指定する必要はありません。
省略した場合、1 から順に、関数名の昇順で自動的に番号が振られます。

Bar には序数の指定に加えて NONAME キーワードが指定されています。
NONAME をつけると、この関数は無名になります。
通常、GetProcAddress は関数名でも序数でも指定できますが、無名関数は序数でしか呼び出すことができません。

Baz の行は関数の転送を指定しています。
この場合、Baz 関数の実体となるコードはこの DLL の中にはなく、Hige.dll がエクスポートする Sori 関数が実体であることを意味します。
クライアントは Baz 関数を呼び出しているつもりでも、実際には Hige.dll の Sori 関数を呼び出しているわけです。
DLL のバージョン アップに伴って一部の関数を別 DLL に切り出した場合の互換性維持などに使えます。

IMAGE_EXPORTS_DIRECTORY

エクスポート情報は IMAGE_EXPORTS_DIRECTORY 構造体に格納されています。
この構造体にアクセスするには、ImageDirectoryEntryToDataEx 関数を使います。
第 5 回の内容を思い出してください。

ULONG uSize = 0;
auto pExports = static_cast<IMAGE_EXPORTS_DIRECTORY *>(
  ImageDirectoryEntryToDataEx(pvBase, TRUE, IMAGE_DIRECTORY_ENTRY_EXPORT, &uSize, NULL);

uSize はこのデータ ディレクトリのサイズです。
IMAGE_DATA_DIRECTORY 構造体の Size の値に等しく、sizeof(IMAGE_EXPORTS_DIRECTORY) とは異なります。

構造体の定義はこうなっています。
winnt.h に含まれています。

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;
    DWORD   Base;
    DWORD   NumberOfFunctions;
    DWORD   NumberOfNames;
    DWORD   AddressOfFunctions;     // RVA from base of image
    DWORD   AddressOfNames;         // RVA from base of image
    DWORD   AddressOfNameOrdinals;  // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

では、メンバー解説です。

Characteristics

使用されていません。常にゼロです。

TimeDateStamp

このファイルの作成時刻です。
32bit の Unix 形式で表されます。

MajorVersion

MinorVersion

使用されていません。常にゼロです。

Name

この DLL のファイル名の ASCII 文字列への相対仮想アドレスです。

Base

この DLL がエクスポートする関数の序数のベース値です。

NumberOfFunctions

この DLL がエクスポートする関数の数です。

NumberOfNames

この DLL がエクスポートする名前付き関数の数です。

AddressOfFunctions

この DLL がエクスポートする関数の相対仮想アドレスの配列を指す相対仮想アドレスです。
わけがわかりませんね。後で詳しくやります。

AddressOfNames

この DLL がエクスポートする関数の名前の相対仮想アドレスの配列を指す相対仮想アドレスです。
後でやります。

AddressOfNameOrdinals

この DLL がエクスポートする関数の名前と序数の対応テーブルを指す相対仮想アドレスです。

図とコードで理解する

メンバー逐次解説の後半はわけが分からないと思うので、図で表します。
f:id:aetos382:20160112153940p:plain

コードも載せちゃいますよ。

ULONG uSize = 0;
auto pExports = static_cast<IMAGE_EXPORT_DIRECTORY *>(
  ImageDirectoryEntryToDataEx(pvBase, bLoad, IMAGE_DIRECTORY_ENTRY_EXPORT, &uSize, nullptr));

auto pFuncs = static_cast<DWORD *>(RvaToVa(pvBase, pExports->AddressOfFunctions, bLoad));
auto pNames = static_cast<DWORD *>(RvaToVa(pvBase, pExports->AddressOfNames, bLoad));
auto pOrdinals = static_cast<WORD *>(RvaToVa(pvBase, pExports->AddressOfNameOrdinals, bLoad));

printf_s("ordinal hint RVA      name\n");

for (int i = 0; i < pExports->NumberOfFunctions; ++i)
{
	if (pFuncs[i] == 0)
	{
		continue;
	}

	printf_s("%7u ", pExports->Base + i);

	int hint = -1;
	LPCSTR pName = "[NONAME]";

	for (int j = 0; j < pExports->NumberOfNames; ++j)
	{
		auto ordinal = pOrdinals[j];
		if (ordinal != i)
		{
			continue;
		}

		hint = j;
		pName = static_cast<LPCSTR>(RvaToVa(pvBase, pNames[j], bLoad));

		break;
	}

	if (hint != -1)
	{
		printf_s("%4u ", hint);
	}
	else
	{
		printf_s("     ");
	}

	auto pFunc = RvaToVa(pvBase, pFuncs[i], bLoad);
	LPCSTR pForwardedTo = nullptr;

	if (pFunc >= pExports && pFunc < reinterpret_cast<LPBYTE>(pExports) + uSize)
	{
		pForwardedTo = static_cast<LPCSTR>(RvaToVa(pvBase, pFuncs[i], TRUE));
	}

	if (pForwardedTo == nullptr)
	{
		printf_s("%0.8X ", pFuncs[i]);
	}
	else
	{
		printf_s("         ");
	}

	printf_s("%s", pName);

	if (pForwardedTo != nullptr)
	{
		printf_s(" (forwarded to %s)", pForwardedTo);
	}

	printf_s("\n");
}

RvaToVa 関数については前回の記事を参照してください。

このプログラムを使って、先ほどの Hoge.dll を表示すると、このような結果が得られます。
dumpbin /exports の出力を再現しています。

ordinal hint RVA      name
      2    1 000110E6 Foo
      3    0          Baz (forwarded to Hige.Sori)
      5      000110D2 [NONAME]

Base と序数

Base は序数のベース値です。DEF ファイル中で明示的に序数を指定した関数がある場合は、その最小値になります。
DEF ファイル中で序数を指定した関数がない場合は 1 になります。
上記の例では Foo が最小の序数値 2 を持ちますので、Base は 2 になります。

序数を指定した関数と指定していない関数が混在している場合、指定しなかった関数の序数値は、関数名の昇順に、最小の空き番号が振られます。
Baz は序数値の指定がありませんので、最小の空き番号である 3 が振られています。

3 つの配列

AddressOfFunctions、AddressOfNames、AddressOfNameOrdinals はそれぞれ、配列を指す RVA です。
その配列の要素数は、AddressOfFunctions が NumberOfFunctions 個、AddressOfNames と AddressOfNameOrdinals は NumberOfNames 個あります。

AddressOfFunctions

AddressOfFunctions が指す配列は、関数のアドレスへの RVA を持っています。
GetProcAddress が返すのは、この配列の要素の値に DLL のベースアドレスを足したアドレスです。
ところで、Bar は序数値 5 を持ちます。序数値 4 が空いていますね。
この場合、NumberOfFunctions は 4 になります。Foo、Baz、(なし)、Bar の 4 つという意味です。
序数に抜け番がある場合、配列には 0 が入っています。
この配列のインデックスに Base の値を足したものが、その関数の序数になります。

AddressOfNames

AddressOfNames が指す配列は、関数の名前への RVA を持っています。
この配列には関数名が昇順にソートされて格納されています。
dumpbin の出力で hint として表示されるのはこの配列のインデックスです。
DEF ファイルで NONAME を指定した関数は含みません。
今回は Foo と Baz が名前を持ちますので、NumberOfNames は 2 です。

AddressOfNameOrdinals

AddressOfNameOrdinals が指す配列は、名前と序数を対応付けています。
この配列は AddressOfNames が指す配列と対になっています。
この配列の最初の要素が 1 ということは、関数名 Baz は AddressOfFunctions の配列の 1 番目(0 ベースです)にあたるという意味です。序数のベース値が 2 ですから、Baz の序数は 3 になります。
2 番目の要素は 0 ですが、これは関数名 Foo が AddressOfFunctions の配列の 0 番目にあたることを表します。序数は 2 ですね。

関数の転送

エクスポート情報は IMAGE_EXPORT_DIRECTORY 構造体に始まり、その後にこうした配列が続きます。
そのサイズの合計は IMAGE_DATA_DIRECTORY 構造体の Size メンバーで表されます(ImageDirectoryEntryToDataEx 関数でも取得できます)。
AddressOfFunctions の要素が、この領域内を指している場合、この関数は別の DLL に転送されていることを意味します。
その RVA の指す先が、DEF ファイルに書かれた、転送先の DLL 名と関数名のペアになっています。
この名前には拡張子を含みませんので、転送先のファイル名の拡張子は必ず ".dll" でなければなりません。他の拡張子だとロードに失敗します。

GetProcAddress の中身

GetProcAddress は、以下のような処理を経て関数のアドレスを決定します。

  1. AddressOfNames が指す配列から関数名を探す。この配列は関数名の昇順にソートされているので、二分探索を使って高速に探索することができます。
  2. そのインデックスから AddressOfNameOrdinals が指す配列を見て AddressOfFunctions 配列のインデックスを得る。
  3. AddressOfFunctions 配列の RVA を DLL のベースアドレスに加算すると関数のアドレスが得られる。

GetProcAddress に名前ではなく序数値を指定するとちょっと高速になるのは、この処理がこう変わるからです。

  1. 指定された序数値から Base の値を引いて AddressOfFunctions 配列のインデックスを得る。
  2. AddressOfFunctions 配列の RVA を DLL のベースアドレスに加算すると関数のアドレスが得られる。

まとめ

ぐっと複雑になってきましたが大丈夫でしょうか。
次回からは何回かつかって、エクスポートの対となるインポート情報についてやります。
インポートはエクスポートの 3 倍くらい面倒くさいですよ。ぞぞぞ。