第 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 がエクスポートする関数の名前と序数の対応テーブルを指す相対仮想アドレスです。
図とコードで理解する
メンバー逐次解説の後半はわけが分からないと思うので、図で表します。
コードも載せちゃいますよ。
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 は、以下のような処理を経て関数のアドレスを決定します。
- AddressOfNames が指す配列から関数名を探す。この配列は関数名の昇順にソートされているので、二分探索を使って高速に探索することができます。
- そのインデックスから AddressOfNameOrdinals が指す配列を見て AddressOfFunctions 配列のインデックスを得る。
- AddressOfFunctions 配列の RVA を DLL のベースアドレスに加算すると関数のアドレスが得られる。
GetProcAddress に名前ではなく序数値を指定するとちょっと高速になるのは、この処理がこう変わるからです。
- 指定された序数値から Base の値を引いて AddressOfFunctions 配列のインデックスを得る。
- AddressOfFunctions 配列の RVA を DLL のベースアドレスに加算すると関数のアドレスが得られる。
まとめ
ぐっと複雑になってきましたが大丈夫でしょうか。
次回からは何回かつかって、エクスポートの対となるインポート情報についてやります。
インポートはエクスポートの 3 倍くらい面倒くさいですよ。ぞぞぞ。