Windows API Hooking Tutorial

Last modified: 2005/03/25 22:26:59

はじめに

Windowsアプリケーションは、通常APIを利用することによって実現されています。たとえ.NETやMFCなどを利用して作成されたプログラムであったとしても、内部的にはすべてWindowsAPIが呼び出され処理されているというのは周知の事実です。プログラムがWindows上で動作している限り、何かしらのカタチでAPIが使われていることは確かなのです。では、今回はそのAPIをフックすることを考えてみます。「Wizard Bible vol.15」の「リバースエンジニアリング」にて、私はAPIフックについて少しだけ触れましたが、今回はそのAPIフックについてのさらに深い話題となります。あらかじめ必要な知識は、Windowsプログラミングに多少の知識があることと、特にDLL関連に詳しいことです。あと「Wizard Bible vol.10」の「常駐プログラム隠蔽テクニック」も読んでおいた方がよいかもしれません。この記事の対象環境はWindows2000/XP以降です。残念ながらWindows9x系では動作しません。今回実験を行った私の環境はWindowsXPで、コンパイラはVC++.NETです。

DLLを仲介にしてAPIをフック

DLLを仲介にしてAPIをフックする方法は「Wizard Bible vol.15」の「リバースエンジニアリング」で使っていたテクニックです。詳しい内容はそちらをみてください。この仕組みはいたってシンプルで、例えば、kernel32.dllとまったく同じエクスポート関数、そしてまったく同じ挙動を持った偽物のDLLを作成し、そのDLLをあたかも本物のkernel32.dllのようにexeファイルに認識させることで、APIの呼び出しをフックしていました。つまり、我々が用意したDLLを仲介させることによりAPIのフックを実現していたわけです。この方法はメンドクサイですが、シンプルで分かりやすく、簡単にAPIをフックする方法として利用できます。ただし、kernel32.dllやuser32.dllのようなWindowsの中枢をつかさどるDLL内の関数をフックする場合、exeファイル自体の内容を1バイトだけ変更しなければならないなど、意外にも手間のかかるテクニックです。

ちなみにこのテクニックを「DLL Injection(DLLインジェクション)」と呼ぶことがあるようですが、私の感覚では「Wizard Bible vol.10」の「常駐プログラム隠蔽テクニック」で扱っていた「実行中のプロセスにDLLを注入する」ことを「DLL Injection(DLLインジェクション)」と呼んでいたので、便宜上こちらのテクニックは「DLL仲介によるAPIフック」と呼ぶことにします。まぁ呼び方なんて何でもいいんですが、どちらも同じ呼び方だと不便でしょうがないので(^^;

インポートセクションを操作してAPIをフック

「リバースエンジニアリング」で扱ったような場合だと仲介DLLを作成した方が実用的かもしれませんが、単純にデバッグ目的でAPIの内容をフックしたい場合は適切ではありません。たったひとつの関数をフックしたいだけなのに、わざわざ仲介DLLを作成するのは少しメンドクサイですし、それに基本的に1つの実行ファイルにしか対応できないのも不親切です。どうせならば、起動中の全プロセスのAPIをフックしたいです。

ということで、もっとエレガントにAPIをフックできないかを考えてみます。DLL自体を置きかえるのではなく、特定の関数だけを置きかえることはできないだろうか。そこで関数アドレスを管理しているモジュールのインポートセクションに目を向けます。モジュールのインポートセクションにはそのモジュールが実行するために必要なDLLや、そのDLLからインポートしている関数のアドレスが保存されています。つまり、どのDLLのどの関数を使うのかは、インポートセクションにあるアドレスに依存していることになります。つまり、この部分を書き換えることによって、関数のみを置きかえることが可能になるということです。

例えば、user32.dllで定義されているMessageBox関数をフックしたい場合、まずはHook_MessageBoxAとHook_MessageBoxWの2つの置き換え関数を作成します。どこに作成してもよいのですが、ターゲットのプロセスに注入しないことにはフックできませんから、DLLとして作成しておきます。そのDLLをターゲットプロセスへ注入すると、DLLは最初にCreateToolhelp32Snapshotを使ってすべてのモジュールを探索します。そして、そのモジュールひとつひとつに対してインポートセクションを持っているかどうかを調べ、持っていたらMessageBoxAのアドレスをHook_MessageBoxAへ、MessageBoxWのアドレスをHook_MessageBoxWへ書きかえます。これでMessageBox関数が実行されたら、注入されたDLL内にあるHook_MessageBoxAとHook_MessageBoxWが呼び出されることになり、見事APIをフックすることができると考えられます。

考え方はこのような感じでよいですが、このままでは机上の空論ですので、実際にプログラムを書いて試していくことにします。

モジュールの列挙

任意のプロセスで使用されているモジュールを列挙するためには、CreateToolhelp32Snapshot関数を使います。これは、現在実行中のプロセスの列挙にも使えますが、今回はモジュールの列挙にて使用します。使い方は以下のようになります。

./ex1.cpp
-----  ex1.cpp
#define WIN32_LEAN_AND_MEAN
#define STRICT

#include <windows.h>
#include <windowsx.h>
#include <tchar.h>
#include <tlhelp32.h>

int APIENTRY _tWinMain(HINSTANCE hInstance,
                       HINSTANCE hPrevInstance,
                       LPTSTR    lpCmdLine,
                       int       nCmdShow)
{
    TCHAR szBuff[8192] = _T("MODULE LIST:\r\n");

    HANDLE hModuleSnap = CreateToolhelp32Snapshot(
        TH32CS_SNAPMODULE, GetCurrentProcessId());
    if(hModuleSnap == INVALID_HANDLE_VALUE)
        return -1;

    MODULEENTRY32 me;
    me.dwSize = sizeof(me);
    BOOL bModuleResult = Module32First(hModuleSnap, &me);
    while(bModuleResult) {
        lstrcat(szBuff, me.szModule);
        lstrcat(szBuff, _T("\r\n"));
        bModuleResult = Module32Next(hModuleSnap, &me);
    }
    CloseHandle(hModuleSnap);
    MessageBox(GetActiveWindow(), szBuff, _T("MODULE LIST"), MB_OK);
    return 0;
}
-----

ex1.exeの実行例

kernel32.dllやuser32.dllを始めとして、さまざまなモジュールを列挙します。もちろん実行ファイル自体もモジュールとして列挙されます。そして、今回のポイントは、「APIはこれらモジュールによって提供されている」ということです。事実、MessageBoxA(W)はuser32.dllによって提供されていますし、CreateToolhelp32SnapshotやCloseHandleはkernel32.dllによって提供されています。外部のDLLによって提供されているAPIと、そのアドレスを管理するインポートセクション、これら2つの要素を利用することで仲介DLLとは別のアプローチによってAPIフックを行ってみることにします。

自プロセスのAPIをフック

他プロセスのAPIをフックするためには、置き換える関数を他のプロセスへ注入しなければならないので少しややこしいです。とりあえず、自プロセスのAPIをフックできるかを試し、それから実用的なAPIフックDLLを作成していくことにします。以下のプログラムをみてください。

./ex2.cpp
-----  ex2.cpp
int APIENTRY _tWinMain(HINSTANCE hInstance,
                       HINSTANCE hPrevInstance,
                       LPTSTR    lpCmdLine,
                       int       nCmdShow)
{
    PROC pfnOrig;
    pfnOrig = ::GetProcAddress(
        GetModuleHandleA("user32.dll"), "MessageBoxA");
    ReplaceIATEntryInAllMods(
        "user32.dll", pfnOrig, (PROC)Hook_MessageBoxA);

    pfnOrig = ::GetProcAddress(
        GetModuleHandleA("user32.dll"), "MessageBoxW");
    ReplaceIATEntryInAllMods(
        "user32.dll", pfnOrig, (PROC)Hook_MessageBoxW);

    MessageBox(GetActiveWindow(), 
        _T("メッセージボックス表示のテスト"), _T("テスト"), MB_OK);
    return 0;
}
-----

WinMain関数は上記のようになっており、最後にMessageBoxが呼び出されています。MessageBoxの表示は、本文が「メッセージボックス表示のテスト」、タイトルが「テスト」となるはずです。しかし、ex2.cppをコンパイルし実行してみると分かりますが、実際にはこうはなりません。なぜなら、このプログラムのMessageBox関数はすでにフックされているからです。つまり、ここでMessageBox関数が呼び出されていますが、これはすでにuser32.dllのMessageBox関数ではなく、このプログラム中にあるHook_MessageBoxA(W)関数に置きかわられているのです。Hook_MessageBoxA関数はタイトルに「I am Hook_MessageBoxA」と表示させますので、このプログラムを実行すると、本文が「メッセージボックス表示のテスト」、タイトルが「I am Hook_MessageBoxA」となっているメッセージボックスがポップアップされることになります。

ex2.exeの実行例

ex2.cppでモジュールを列挙する役割を担っているのが、ReplaceIATEntryInAllMods関数であり、実際にインポートセクションの操作を行っているのが、ReplaceIATEntryInOneMod関数です。以下、ReplaceIATEntryInOneMod関数の動作を追っていくことにします。

-----
void ReplaceIATEntryInOneMod(
                             PCSTR pszModuleName,
                             PROC pfnCurrent,
                             PROC pfnNew,
                             HMODULE hmodCaller) 
{
    ULONG ulSize;
    PIMAGE_IMPORT_DESCRIPTOR pImportDesc;
    pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)ImageDirectoryEntryToData(
        hmodCaller, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &ulSize);

    if (pImportDesc == NULL)
        return;
-----

まずは、ImageDirectoryEntryToDataを使ってインポートセクションのアドレスを取得しています。この関数を利用するためにex2.cppの冒頭でDbghelp.hを定義し、Dbghelp.libをリンクしています。

-----
    while(pImportDesc->Name) {
        PSTR pszModName = (PSTR) ((PBYTE) hmodCaller + pImportDesc->Name);
        if (lstrcmpiA(pszModName, pszModuleName) == 0) 
            break;
        pImportDesc++;
    }

    if (pImportDesc->Name == 0)
        return;
-----

インポートセクションを持っていることが分かると、次はインポートディスクリプタを検索します。MessageBoxをフックするならば、その関数を保持しているuser32.dllを探さなければなりませんので、lstrcmpiAを使って検索しています。ちなみにモジュール名にUNICODE文字が使われることはないので、ASCII文字限定のlstrcmpiAを使用しています。

-----
    PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA) 
        ((PBYTE) hmodCaller + pImportDesc->FirstThunk);
-----

インポートアドレステーブルを取得しています。前回の「リバースエンジニアリング」では、IAT(インポートアドレステーブル)の再構築なんてことを手動でやりましたが、それと同じです。でも、同じIATでも、前回のはPEフォーマットとして構成されたバイナリデータファイルのIATを変更したわけですが、今回のは、すでにメモリに読み込まれた状態でのIATのアドレスを取得しているので、同じインポートアドレステーブルであることに変わりはありませんが、微妙に違うかもしれないです。いや、インポートアドレステーブルであることに変わりはないですけどね、でもメモリに読み込まれた状態とじゃちょっと違う気がしなくもないような...あー、なんかややこしくなってきたので、この辺で止めときます(^^;。

-----
    while(pThunk->u1.Function) {
        PROC *ppfn = (PROC*) &pThunk->u1.Function;
        BOOL fFound = (*ppfn == pfnCurrent);
        if (fFound) {
            DWORD dwDummy;
            VirtualProtect(ppfn, sizeof(ppfn), PAGE_EXECUTE_READWRITE, &dwDummy);
            WriteProcessMemory(
                GetCurrentProcess(), ppfn, &pfnNew, sizeof(pfnNew), NULL);
            return;
        }
        pThunk++;
    }
-----

この部分で置きかえるべき関数を検索します。もし見つかったならばWriteProcessMemoryを用いて新しい関数アドレスに書きかえます。これで、以後このプログラムはAPIがフックされた状態になるわけです。

再びWinMain関数に戻りましょう。ReplaceIATEntryInAllModsを使ってフックされたMessageBoxA(W)関数は、インポートセクションを書きかえられ、以後Hook_MessageBoxA(W)に置きかわられたことになります。これによって、最後にMessageBoxを呼び出したとき、置きかえられたHook_MessageBoxA(W)が実行されたというわけです。

全プロセスのAPIをフック

考え方は同じですが、全プロセスのAPIフックは多少ややこしいです。まず問題点のひとつとして、ターゲットのプロセスへHook_MessageBoxA(W)といったフック関数を注入しなければなりません。これにはいくつかの方法が考えられますが、一番シンプルかつ強力な方法はSetWindowsHookExを使うことです。全プロセスへ任意のDLLをマッピングさせるためには、これほど有効な手段はありません。ただし、自分以外の単一のプロセスだけにDLLをマッピングさせたい場合は、「Wizard Bible vol.10」の「常駐プログラム隠蔽テクニック」で扱った「DLL Injection(DLLインジェクション)」を使ってもよいかもしれません。このあたりは用途によって変更すべきでしょう。今回は、全プロセスのAPIをフックしたいのでSetWindowsHookExを使用することにします。

さて、これから全プロセスAPIフックに関しての動作を説明しますが、これまでの知識ではいくつかの問題点があります。それを列挙しながら説明していきたいと思います。まず、目的はMessageBoxA(W)をフックすることにします。最初にHook_MessageBoxA(W)を持ったHook.dll(仮称)を作成します。そのDLLをリンクさせた実行ファイル(便宜上Main.exe(仮称))を実行します。これにより起動したプロセスをプロセスAとします。もちろんすでにHook.dllがマッピングされています。そして、早速プロセスAからSetWindowsHookExを実行しますが、全プロセスへHook.dllをマッピングさせたいので、SetWindowsHookEx関数はMain.exeではなくHook.dllに置いておきます(SetWindowsHookEx関数はDLL側に置いておかないとシステムフックが発動しません)。SetWindowsHookExを実行させて全プロセスにHook.dllが行き渡ったら、それぞれにマッピングされているHook.dllは、CreateToolhelp32Snapshotを使用して、そのプロセス内でリンクされているモジュールを検索します。

ここで1つ目の問題がでてきます。Hook.dllは全プロセスにマッピングされるので、SetWindowsHookEx関数の呼び出し元であるプロセスA自体も例外なくフックされることになり、さらには、Hook.dllさえもAPIフックの標的になります。よって、SetWindowsHookEx関数の呼び出し元プロセスである「プロセスA」と「Hook.dll」にはAPIフックを仕掛けないようにしなければなりません。つまり、「プロセスA内のモジュールすべて」と、「全プロセスにマッピングされているHook.dll」の2つを聖域とし、その他すべてのプロセス、すべてのモジュールにフックを仕掛けます。

実際にフックを仕掛けたあとに2つ目の問題は発生します。APIフックによってMessageBoxA(W)のアドレスをHook_MessageBoxA(W)に置きかえたので、以後MessageBoxA(W)が呼び出されたら、Hook_MessageBoxA(W)が実行されるのは分かります。しかし、LoadLibraryやGetProcAddressなどを考えていません。これらを使って動的にMessageBoxA(W)のアドレスを調べられたら、本当のMessageBoxA(W)のアドレスは簡単にバレてしまいます。つまり、LoadLibrary関連とGetProcAddressは、デフォルトでフックし、常に監視しておかなければなりません。

フックの解除にはUnhookWindowsHookExを実行します。これによりプロセスA以外のすべてのプロセスからHook.dllのマッピングが解除されますので、そのとき、同時にフックも解除させます。

以下がサンプルプログラムです。APIフックの基本的な仕組みは「自プロセスのAPIをフック」にて解説したので、それを踏まえた上でソースを読めば簡単に理解できると思います。クセでコメントも結構つけてたりします(笑)。

./API_Hook.zip

API_Hook.exeの実行例

上記のプログラムはMessageBox関数をフックするプログラムです。メモ帳は、起動して適当な文字を書き込み、保存せずに終了しようとするとMessageBoxを呼び出します。またペイントブラシやワードパッドも同じ方法でMessageBoxを呼び出します。画像では、これらをフックした瞬間をキャプチャーしています。API_Hook.exeを実行し、フックをスタートして他のプロセスでMessageBoxを呼び出すと、API_Hook.exeのエディットボックスにMessageBox関数を呼び出したプロセスの実行ファイルが表示されます。なかなか面白いプログラムだと思います。

このテクニックを使えば、他のプロセスのあらゆる情報が取得できます。どのようなファイルにアクセスしたか、どのようなパケットを送受信したか、はたまた、どのようなレジストリにアクセスしたかまで、事細かに詳細な情報を取得できます。「詳細な情報を取得できる」ということはデバッガとしては有用かもしれませんが、同時にそれほど詳細な情報を簡単に盗み見ることができるという意味でもあります。例えば、全プロセスのsend、recv関数をフックすれば、そのコンピュータのネットワークパケットすべてを監視することができます。キーロガーよりもよっぽどたちの悪いツールが作れることになります。本当に詳細な内部データ(たとえばMACフレームレベルでのパケットなど)を取得したい場合は、フィルタドライバを書いた方が速いかもしれません。しかし、そこまでの情報を必要とすることなど、ほとんど皆無でしょう。なんといってもアプリケーションレベルのプログラムでAPIを監視できるというのは魅力だと思います。

さいごに

さて、いかがだったでしょうか。今回のネタは「Advanced Windows」(Jeffrey Richter 著)のAPIフックの章を大いに参考にさせていただきました。特にソースコードなどはそのままもらった部分などもあったりします(^^;。それでお世話になった恩返しにちょっとバグの指摘をしたいと思います(ぉぃ)。「Advanced Windows 第4版」のサンプルプログラム「22-LastMsgBoxInfoLib」についてです。持っている人は見てもらえると助かりますが、このプログラムは「22-LastMsgBoxInfo」と1セットになっており、実行すると、全プロセスのMessageBox関数をフックし、フックした内容をメインプログラムであるLastMsgBoxInfo.exeに送ります。それで、実はこのLastMsgBoxInfo.exeというプログラム、WindowsXP環境で実行した場合、スタックオーバーフローで見事に落ちます。必ずしも落ちるかどうかは分かりませんが、私の環境では、ほぼ再現性100%で落ちますし、海外フォーラムでもそういう話題があったのでおそらくバグだと思われます(笑)。ただし、著者のJeffrey Richter氏はWindows2000環境で試しているので(というか執筆時にはまだXPは出てなかった?)XP環境での動作保証ができないわけあり、決して著者が悪いわけじゃないです。ただ、だとしても、SetWindowsHookEx関数を呼び出したプロセスのモジュールまでフックするのはどうかと思います。自分が書いたLastMsgBoxInfo.exeのAPIもフックしちゃってるから、その内部でうかつにMessageBox呼べないし、ってか自分で書いたプログラムが実行時いきなりフックされてるとか、デバッグしにくすぎです。といっても、2000で動作していればバグとは言い難いのですが、私の環境はXPですので、その部分は少し改良し、自プロセスのAPIはフックしないようにしています。

でも、実際こういう内部部分をイジるプログラムを組むと、環境の違いに大きく影響されてしまいますね。もしかしたら、この記事に載ったプログラムもWindows2003 Serverでは動作しないかもしれません。Jeffrey Richter氏もWindows98とWindows2000に対応させるために少々ソースコードを変更していましたし。いやはや、互換性を保つのは意外とメンドクサイものです。さて、「さいごに」がやたら長くなってしまいましたが、今回はこの辺で終わりです。

では、また会う日まで...

参考文献


Copyright (C) 2003-2005 Kenji Aiko All Rights Reserved