インポートアドレステーブルと API フック

概要

ある実行可能ファイルから、他の実行可能ファイル (DLL) にある関数を呼び出すために、実行可能ファイルのヘッダー情報の中のインポートセクションという場所に記録されている情報を使います。

インポートセクションには、「この EXE (または DLL) は foo.dll の Bar() 関数を呼び出しますよ。アドレスはここです。」 というような情報が書いてあります。この資料ではその仕組み、特に実際の関数のアドレ スが記載されているインポートアドレステーブルの仕組みを説明します。最後にこれを活用する例として、MessageBox API をフックして自前の関数を呼び出してみます。

インポート セクションと IAT

いわゆる普通の実行ファイル (EXE) は PE (Portable Executable) 形式と呼ばれるフォーマットです。その詳細は別の回でご説明したいと思いますが、今回の件に関連しては、「PE ファイル (EXE や DLL) ではヘッダーにいくつかのセクションがある。そのセクションのひとつがインポートセクションだ」 ということだけ把握しておけば十分です。

※PE の中身を見るためのツールには Platform SDK の DumpBin や次の URL からダウンロードできる PEBrowser 等があります。
SmidgeonSoft.com
http://www.smidgeonsoft.com/

さて、インポートセクションは IMAGE_IMPORT_DESCRIPTOR の配列として表現されています。IMAGE_IMPORT_DESCRIPTOR は winnt.h で定義されており、次のような構造をしています。

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;
        DWORD   OriginalFirstThunk;         // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
    };
    DWORD   TimeDateStamp;               // 0 if not bound,
    DWORD   ForwarderChain;               // -1 if no forwarders
    DWORD   Name;
    DWORD   FirstThunk;                      // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;

ここで注目していただきたいのは、 OriginalFirstThunk, Name 及び FirstThunk です。

Name には、インポートする DLL 名への RVA (相対仮想アドレス) が入ります。例えば、user32.dll の MessageBoxA を使用している場合には、Name に "user32.dll" という名前への RVA が入ります。(RVA はモジュールのベースアドレスからのオフセットをあらわします)

OriginalFirstThunk と FirstThunk は、どちらも次のような IMAGE_THUNK_DATA の配列を指しています。IMAGE_THUNK_DATA は下記の通りの構造で、DWORD サイズの共用体であることがわかります。名前は四種類ありますが、状況に応じて意味が変わります。

typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // PBYTE
        DWORD Function;             // PDWORD
        DWORD Ordinal;
        DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

たいていの場合、OriginalFirstThunk が指し示す IMAGE_THUNK_DATA 配列が意味するのは、 IMAGE_IMPORT_BY_NAME への RVA です。IMAGE_THUNK_DATA の要素ひとつがインポートされる関数ひとつに対応します。 (最 上位ビットが立っている場合のみ、残りの 31 ビットはインポート関数の序数を示します)

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;
    BYTE    Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

IMAGE_IMPORT_BY_NAME は上記のように、"ヒント" と "名前" から構成されています。Hint はインポートされる関数の序数がいくつになるかということに関してローダに渡す値です。プログラムからは普通使いません。もうひとつの Name にはインポートされる関数名を示す文字列が入ります。このため、OriginalFirstThunk からつらなる IMAGE_THUNK_DATA 配列を INT (import name table) と呼んだりします。

一方、FirstThunk が指し示す IMAGE_THUNK_DATA 配列には、インポートされる関数の実際のメモリアドレスが格納されます。これ は、モジュールのロード時にローダがアドレスを解決して、値を書き込んでくれます。こちらの配列は IAT (import address table) と呼ばれます。

上記の関係を図に表すと次のようになります。

上記のように、あるモジュールから外部の関数を呼び出す場合には、そのモジュールの IAT を参照してアドレスを取得し、その関数を呼び出します。ですから、IAT を書き換えることで、API のフックを実現することも可能です。次のサンプルでは IAT と INT をダンプすることと、 IAT を書き換えることによって、MessageBoxA をフックして自前の MyPrintFunc を呼び出します。

IAT/INT のダンプと API フックの例

サンプルコードはこちらからダウンロードできます。

 サンプルコードのダウンロード [iat_test.zip, makefile]

main 関数の一番下の方で MessageBoxA を呼び出しています。このため普通は下のスクリーンショットのように、Hello と書いたメッセージボックスが表示されます。

このサンプルコードでは、MessageBoxA の呼び出しをフックして、MyPrintFunc 関数を呼び出すようにしています。フックが成功したときは、下のスクリーンショットのように、メッセージボックスではなくコンソールに文字が表示されま す。

それでは、コード内にコメントを記載します。

#include <windows.h>
#include <stdio.h>
#include <Dbghelp.h>

// MessageBox の呼び出しをこの関数にフックします。
// MessageBox API の呼び出し規則は __stdcall ですから、これにも __stdcall を指定しています。
// フックを行うときには、呼び出し規則をそろえる必要があります。(このページの下に補足を記載しました)

int __stdcall MyPrintFunc ( HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
    printf ("------------------------\n");
    printf ("Hello, I'm MyPrintFunc!\n");
    printf ("  %s\n", lpText );
    printf ("  %s\n", lpCaption );
    printf ("------------------------\n");
    return 0;
}

int main (int argc, char* argv[]) {

    // このモジュールのベースアドレスです。RVA を取得した際、実際のアドレスに変換するために
    // 必要になります。

    DWORD Base = (DWORD) GetModuleHandle ( NULL );

    ULONG cbSize = 0;
    PIMAGE_IMPORT_DESCRIPTOR pImageImportDescriptor = NULL;
    PIMAGE_THUNK_DATA pImageThunkData = NULL;
    PIMAGE_THUNK_DATA pOrgImageThunkData = NULL;
 
    // user32.dll 内の MessageBoxA 関数のアドレスを取得します。(フックのための準備です)

    FARPROC pfnMessageBox = GetProcAddress ( GetModuleHandle ("user32.dll"), "MessageBoxA" );

    printf ("MessageBoxA: %p\n", pfnMessageBox );
    printf ("MyPrintFunc: %p\n", MyPrintFunc );
    printf ("\n");
 
    // インポートセクションの先頭アドレスを取得します。
 
    pImageImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)
        ImageDirectoryEntryToData (
            (HMODULE) Base,
            TRUE,
            IMAGE_DIRECTORY_ENTRY_IMPORT,
            &cbSize);

    ...
   
    // ここでは、インポートセクション内の IMAGE_IMPORT_DESCRIPTOR 配列を最後の要素まで
    // ループしています。最後の要素は中身が 0 ですから、ここでは Name がゼロかどうかを、
    // ループの終了判定に利用しています。
  
    for ( ; pImageImportDescriptor->Name; pImageImportDescriptor++ ) {

        // Name フィールドには名前が格納されている場所への RVA が保存されます。
        // ですから、名前を出力するためには Base アドレスを足し算しています。

        char* pModuleName = (char*) ( Base + pImageImportDescriptor->Name );

        // このモジュールのロードアドレス。ハンドルはロードアドレスに等しくなります。

        DWORD ModuleBase = (DWORD) GetModuleHandle ( pModuleName );

        printf ("%p %s\n", ModuleBase, pModuleName ); 

        // IAT の先頭要素を取得します。ここも Base を足しています。

        pImageThunkData = (PIMAGE_THUNK_DATA) ( Base + pImageImportDescriptor->FirstThunk ) ;

        // こちらは INT について同様な操作をしています。

        pOrgImageThunkData = (PIMAGE_THUNK_DATA) ( Base + pImageImportDescriptor->OriginalFirstThunk );
  
        // IAT 内のそれぞれの要素の情報をダンプします。もし、関数アドレスが MessageBoxA のそれと等しければ、
        // MyPrintFunc へアドレスを書き換えます。

        for ( ; pImageThunkData->u1.Function; pImageThunkData++, pOrgImageThunkData++ ) {

            // IAT 内に記録された実際の関数アドレスです。これは RVA ではありませんから、そのまま使います。
   
            FARPROC pfnImportedFunc = (FARPROC) ( pImageThunkData->u1.Function);
            printf ("  %p ", pfnImportedFunc );

            // INT をダンプします。先頭ビットが立っている場合は序数、そうでないときは IMAGE_IMPORT_BY_NAME
            // 構造体への RVA です。

            if ( 0x80000000 & (DWORD) pOrgImageThunkData ) {
                DWORD dwOrdinal = (0x7fffffff) & (DWORD) pOrgImageThunkData;
                printf ("%u\n", dwOrdinal );
            }
            else {
                PIMAGE_IMPORT_BY_NAME pImageImportByName = 
                    (PIMAGE_IMPORT_BY_NAME) ( Base + pOrgImageThunkData->u1.AddressOfData);

                // IMAGE_IMPORT_BY_NAME を取得できると、関数の名前がわかります。
                printf ( "%-28s (%x)\n", &pImageImportByName->Name, pImageImportByName->Hint ); 
            }
   
            // もし関数アドレスが MessageBox のそれと一致したら、IAT 内のアドレスを書き換えます (フック)

            if ( pfnImportedFunc == pfnMessageBox ) {
    
                DWORD flOldProtect;
                PVOID pfnNewFunc = MyPrintFunc;

                // Windows XP SP2 で試していますが、デフォルトの状態では IAT は読み取り専用のページに
                // ありました。ですから、ここでは関数アドレスを書き込めるように VirtualProtect API を利用して
                // PAGE_READWRITE 属性に変更します。
                VirtualProtect ( pImageThunkData, sizeof(DWORD), PAGE_READWRITE, &flOldProtect );
                WriteProcessMemory ( 
                    GetCurrentProcess(), pImageThunkData, &pfnNewFunc, sizeof (DWORD), NULL);
            }
  
        }
  
    }

    ::MessageBoxA ( NULL, "Hello", "Message", MB_OK );
 
    return TRUE;
}

以上、今回はインポートセクションの情報をダンプし、さらに IAT の情報を書き換えることによって、API をフックするコードを解説しました。

補足: __stdcall について

__stdcall の呼び出し規則 (calling convention) について簡単に補足します。

MessageBoxA を呼び出す場合、MessageBoxA は __stdcall 呼び出し規則に従いますから、それを呼び出すコードは次のような呼び出しになります。

(引数はスタッ ク経由で渡される)
00401226 6a00             push    0x0
00401228 6860d24000       push    0x40d260
0040122d 6868d24000       push    0x40d268
00401232 6a00             push    0x0
(引数をプッシュすることによってスタックが伸張した。)
00401234 ff1534d14000     call   dword ptr [iat!_imp__MessageBoxA (0040d134)] 
(この後スタックを巻き戻さない。MessageBoxA 内部で巻き戻すから)

__stdcall を指定しない場合には、(デフォルトで __cdecl になるので) 次のように、スタックを巻き戻しません。

0:000> u iat!MyPrintFunc iat!MyPrintFunc+50
iat!MyPrintFunc [d:\src\test\iat\iat_test1\iat.cpp @ 6]:
00401000 55               push    ebp
00401001 8bec             mov     ebp,esp
...
00401044 e807020000       call    iat!printf (00401250)
00401049 83c404           add     esp,0x4
0040104c 33c0             xor     eax,eax
0040104e 5d               pop     ebp
0040104f c3               ret

一方、__stdcall を指定すると、MyPrintFunc は次のようにスタックを巻き戻しています。

0:000> u iat!MyPrintFunc iat!MyPrintFunc+0x50
00401000 55               push    ebp
00401001 8bec             mov     ebp,esp
...
00401044 e817020000       call    iat!printf (00401260)
00401049 83c404           add     esp,0x4
0040104c 33c0             xor     eax,eax
0040104e 5d               pop     ebp
0040104f c21000           ret     0x10

ret (関数から呼び出し元に戻る) ときに、0x10 だけスタックを巻き戻しています。

このように呼び出し規則をそろえないとスタックが壊れてしまい、プログラムがクラッシュする可能性が出てきます。このため、フックを行う場合には、フック する前の関数とフックする関数では呼び出し規則や引数の数 (どれだけ巻き戻すかをコンパイラに指示する) をそろえる必要があります。

ここまでお読みいただき、誠にありがとうございます。SNS 等でこの記事をシェアしていただけますと、大変励みになります。どうぞよろしくお願いします。

© 2024 Web/DB プログラミング徹底解説