japan.internet.comThe Internet & IT Network
RSS
  • ニュース
  • コラム
  • リサーチ
  • ヘッドライン
  • 特集
  • ブログ
  • プレスリリース
  • 専門チャンネル
  • イベント
  • ランキング
  • ニュースメール
2008年9月6日
文字サイズ文字サイズ小文字サイズ中文字サイズ大
デベロッパー コラム2005年8月30日 10:00
CodeGuru
CodeGuru japan.internet.com 編集部メールホームrss
米国 Jupitermedia が運営する、プログラムコードに関する専門サイト。
多数の記事、多数のコードを掲載し、ソースコードをダウンロードすることもできる。

別のプロセスにコードを割り込ませる3つの方法

海外海外internet.com発の記事
完成図
完成図

目次

はじめに

 CodeGuruのサイトにはパスワードスパイのチュートリアルがいくつか投稿されていますが、それらはいずれもWindowsフックを利用しています。このようなユーティリティを作成するには、他に方法はないのでしょうか? 実はあります。しかし別の方法を説明する前に、この問題について簡単に復習しておきましょう。

 コントロールのコンテンツを読み取るには、それが自作アプリケーション内にある場合でもない場合でも、コントロールに対してWM_GETTEXTメッセージを送信します。エディットコントロールに対しても同じことが言えますが、1つ例外があります。そのエディットコントロールが別のプロセスに属していて、ES_PASSWORDスタイルが設定されている場合には、この方法は失敗します。そのパスワードコントロールを所有しているプロセスだけが、WM_GETTEXTを通じてコントロールのコンテンツを取得できます。したがって、この問題を避けるためには、

::SendMessage( hPwdEdit, WM_GETTEXT, nMaxChars, psBuffer );

 という関数を別のプロセスのアドレス空間内で実行する必要があります。

 一般的には、この問題を解決するには次の3つの方法が考えられます。

  1. コードをDLL内に組み込み、そのDLLをWindowsフックを使ってリモートプロセスにマッピングする
  2. コードをDLL内に組み込み、そのDLLをCreateRemoteThreadLoadLibraryのテクニックを使ってリモートプロセスにマッピングする
  3. 独立したDLLを記述するのではなく、WriteProcessMemoryを通じてコードをリモートプロセスに直接コピーし、CreateRemoteThreadを使ってそのコードの実行を開始する(このテクニックの詳細についてはこちらを参照)

1. Windowsフックのテクニック

 デモアプリケーション:HookSpy、HookInjEx

 Windowsフックの主な役割は、あるスレッドのメッセージトラフィックを監視することです。一般的には、次の種類があります。

  1. ローカルフック − このプロセスに属する任意のスレッドのメッセージトラフィックを監視します。
  2. リモートフック − 次の2種類があります。
    1. スレッド固有 − 別のプロセスに属するスレッドのメッセージトラフィックを監視します。
    2. システムワイド − システム上で現在実行しているすべてのスレッドのメッセージトラフィックを監視します。

 フックされる側のスレッドが別のプロセスに属している場合(上記2-1および2-2の場合)は、フックする側のプロシージャはダイナミックリンクライブラリ(DLL)内になければなりません。フックプロシージャがDLL内にあるときは、そのDLLが、フック対象スレッドのアドレス空間へとマッピングされます。このときWindowsは、フックプロシージャだけでなくDLL全体をマッピングします。この機能により、Windowsフックを利用して別のプロセスのアドレス空間にコードを割り込ませることができます。

 本稿では、フックについてこれ以上詳しく説明しません(詳しく知りたい方は、MSDNのSetWindowHookEx API関数を参照してください)。その代わりに、マニュアルには書かれていない有益なヒントを2つ紹介しておきます。

1. DLLはいつマッピングされるか

 SetWindowsHookExの呼び出しが正常に完了すると、Windowsは指定のDLLをフック対象スレッドのアドレス空間に自動的にマッピングしますが、マッピングが即座に行われるとは限りません。Windowsフックはメッセージに関するものなので、適切なイベントが発生しない限り、DLLは実際にはマッピングされません。次に例を示します。

 あるスレッドのキュー化されていないすべてのメッセージを監視するフック(WH_CALLWNDPROC)をインストールした場合は、フック対象スレッド(のいずれかのウィンドウ)に実際にメッセージが送信されるまでは、DLLはリモートプロセスにマッピングされません。言い換えると、フック対象スレッドにメッセージが送信される前にUnhookWindowsHookを呼び出した場合は、SetWindowsHookExの呼び出しが成功したとしても、DLLがリモートプロセスにまったくマッピングされないことになります。即座にマッピングを行うためには、SetWindowsHookExを呼び出した直後に、対象スレッドに対して適切なイベントを送信します。

 UnhookWindowsHookの呼び出し後にDLLのマッピングを解除するときにも同じことが言えます。適切なイベントが起きるまでは、DLLは実際にはマッピング解除されません。

2. パフォーマンスの低下を防ぐには

 フックをインストールすると、システム全体のパフォーマンスに影響が出ることがあります(特にシステムワイドフックの場合)。しかし、DLLマッピングメカニズムとして主にスレッド固有フックを使用しており、メッセージのトラップを行っていない場合には、この弱点を簡単に克服できます。次のコード例を見てください。

BOOL APIENTRY DllMain( HANDLE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved )
{
    if( ul_reason_for_call == DLL_PROCESS_ATTACH )
    {
       // Increase reference count via LoadLibrary
       char lib_name[MAX_PATH];
       ::GetModuleFileName( hDll, lib_name, MAX_PATH );
       ::LoadLibrary( lib_name );

       // Safely remove hook
       ::UnhookWindowsHookEx( g_hHook );
    }
   return TRUE;
}

 さて、これで何が起きるでしょうか。

 まず、Windowsフックを通じてこのDLLをリモートプロセスにマッピングします。その後、DLLが実際にマッピングされた直後に、UnhookWindowsHookExによってフックが解除されます。通常ならば、最初のメッセージがフック対象スレッドに到達するとすぐに、DLLのマッピングも解除されるはずです。しかしこの例では、LoadLibraryを呼び出してDLLの参照カウントを増やすという「ずる」をして、このマッピング解除を防いでいます。

 ここで1つ疑問が浮かんできます。終了時にはどうやってDLLをアンロードすればよいのでしょうか?このスレッドは既にフック解除されているので、UnhookWindowsHookExを使うことはできません。そこで、次のような方法を使用します。

  • DLLをマップ解除する直前に別のフックをインストールする
  • リモートスレッドに「特殊な」メッセージを送信する
  • このメッセージをフックプロシージャ内でキャッチし、それへの応答としてFreeLibraryUnhookWindowsHookExを呼び出す

 こうすると、リモートプロセスに対してDLLをマッピング/マッピング解除する間だけフックを使用することになるので、とりあえずフック対象スレッドのパフォーマンスに影響が出ることはありません。言い換えると、これは次に説明するLoadLibraryテクニック(CreateRemoteThread+LoadLibraryのテクニックを参照)よりもターゲットプロセスの邪魔をしないDLLマッピングメカニズムです。また、LoadLibraryテクニックとは異なり、このソリューションはWinNTとWin9xの両方で使用できます。

 では、この方法はいつ使用すればよいのでしょうか。

 たとえば、DLLを長期間リモートプロセスにマッピングしておく必要があるが(別のプロセスに属するコントロールをサブクラス化するときなど)、ターゲットプロセスの邪魔はできるだけしたくない場合は、この方法を使用します。サンプルのHookSpyアプリケーションではこの方法を使用しませんでした。このアプリケーションではDLLの使用期間が短く、パスワードを取得する間しか使用しないからです。この方法の使用例を示すためには、HookInjExという別のサンプルを用意しました。HookInjExでは、explorer.exeに対してDLLをマッピング/マッピング解除し、Windowsの[スタート]ボタンをサブクラス化しています。具体的には、[スタート]ボタンの左クリックと右クリックを入れ替えます。

 本稿の最初で紹介しているダウンロードパッケージに、HookSpyおよびHookInjExの実行可能ファイルとソースファイルが含まれています。

2. CreateRemoteThread+LoadLibraryのテクニック

 デモアプリケーション:LibSpy

 一般的には、LoadLibrary API関数を使用すればどんなプロセスでもDLLを動的にロードできます。しかし、外部プロセスからこの関数を呼び出すにはどうしたらいいでしょうか。答えは、CreateRemoteThreadを使用することです。

 まず、LoadLibraryFreeLibraryというAPI関数の宣言を見てみましょう。

’’HINSTANCE LoadLibrary(
  LPCTSTR’’ lpLibFileName   // address of filename of library module
); 

’’BOOL FreeLibrary(
  HMODULE’’ hLibModule      // handle to loaded library module
); 

 これらの宣言を、CreateRemoteThreadに渡すスレッドルーチンThreadProcの宣言と比べてみましょう。

’’DWORD WINAPI ThreadProc(
  LPVOID’’ lpParameter   // thread data
); 

 見てのとおり、どの関数も同じ呼び出し規則を使用しており、いずれも32ビットパラメータを受け取ります。また、戻り値のサイズも同じです。したがって、LoadLibrary/FreeLibraryへのポインタを、CreateRemoteThreadにスレッドルーチンとして渡すことが可能です。

 しかし、これには2つの問題があります(詳しくは下記CreateRemoteThreadの説明を参照)。

  1. CreateRemoteThreadlpStartAddressパラメータは、リモートプロセス内のスレッドルーチンの開始アドレスを表さなければなりません。
  2. lpParameterパラメータ(ThreadFuncに渡されるパラメータ)が通常の32ビット値として解釈される場合は(FreeLibraryはこれをHMODULEとして解釈します)、何も問題はありません。しかし、lpParameterパラメータがポインタとして解釈される場合は(LoadLibraryAはこれをchar文字列へのポインタとして解釈します)、このパラメータはリモートプロセス内の何らかのデータを指さなければなりません。

 1番目の問題は、実はあらかじめ解決されています。LoadLibraryFreeLibrayはどちらもkernel32.dll内の関数です。kernel32.dllは必ず存在し、すべてのノーマルプロセス(付録Aを参照)内で同じロードアドレスにあることが保証されているので、LoadLibrary/FreeLibrayのアドレスもすべてのプロセス内で同じになります。したがって、必ず有効なポインタがリモートプロセスに渡されます。

 2番目の問題も、簡単に解決することができます。WriteProcessMemoryを使用して、LoadLibraryに必要なDLLモジュール名をリモートプロセスにコピーするだけでよいのです。

 したがって、CreateRemoteThreadLoadLibraryのテクニックを使用するには、次の手順を行います。

  1. リモートプロセスへのハンドルを取得します(OpenProcess)。
  2. リモートプロセス内でDLL名のためのメモリを割り当てます(VirtualAllocEx)。
  3. 割り当てたメモリにDLL名を完全パスで書き込みます(WriteProcessMemory)。
  4. CreateRemoteThreadLoadLibraryを使用して、DLLをリモートプロセスにマッピングします。
  5. リモートスレッドが終了するまで、つまりLoadLibraryの呼び出しが返ってくるまで待機します(WaitForSingleObject)。言い換えると、DLL_PROCESS_ATTACHによって呼び出されたDllMainが返ってきたときに、スレッドが終了します。
  6. リモートスレッドの終了コードを取得します(GetExitCodeThread)。これはLoadLibraryの戻り値なので、マッピングされたDLLのベースアドレス(HMODULE)を表します。
  7. 手順2で割り当てたメモリを解放します(VirtualFreeEx)。
  8. CreateRemoteThreadFreeLibraryを使用して、リモートプロセスからDLLをアンロードします。手順6で取得したHMODULEハンドルをFreeLibraryに渡します(CreateRemoteThreadlpParameterパラメータを使用)。
    注:割り込んだDLLが新しいスレッドを生成した場合は、DLLをアンロードする前に、すべてのスレッドが終了していることを確認してください。
  9. スレッドが終了するまで待機します(WaitForSingleObject)。

 また、使用し終わったらすべてのハンドルをクローズすることも忘れないでください。手順4と手順8で作成したスレッドへのハンドルと、手順1で取得したリモートプロセスへのハンドルをクローズします。

 LibSpyのソースの一部を次に示します。上記の手順が実際にどのように実装されているかを見てみてください。コードを単純化するために、エラー処理とUnicodeサポートの部分は省いています。

HANDLE hThread;
char  szLibPath[_MAX_PATH];  // The name of our "LibSpy.dll"
                             // module (including full path!);
void* pLibRemote;   // The address (in the remote process)
                    // where szLibPath will be copied to;
DWORD hLibModule;   // Base address of loaded module (==HMODULE);

// initialize szLibPath
//...

// 1. Allocate memory in the remote process for szLibPath
// 2. Write szLibPath to the allocated memory
pLibRemote = ::VirtualAllocEx( hProcess, NULL, sizeof(szLibPath),
                               MEM_COMMIT, PAGE_READWRITE );
::WriteProcessMemory( hProcess, pLibRemote, (void*)szLibPath,
                      sizeof(szLibPath),NULL );


// Load "LibSpy.dll" into the remote process
// (via CreateRemoteThread & LoadLibrary)
hThread = ::CreateRemoteThread( hProcess, NULL, 0,
            (LPTHREAD_START_ROUTINE )::GetProcAddress(
             ::GetModuleHandle("Kernel32"), "LoadLibraryA"),
             pLibRemote, 0, NULL );
::WaitForSingleObject( hThread, INFINITE );

// Get handle of the loaded module
::GetExitCodeThread( hThread, &hLibModule );

// Clean up
::CloseHandle( hThread );
::VirtualFreeEx( hProcess, pLibRemote,
                 sizeof(szLibPath),MEM_RELEASE );

 実際に割り込ませるコード(SendMessage)は、DllMain (DLL_PROCESS_ATTACH)内に書かれているものとします。したがって、この時点で既に実行されています。次は、ターゲットプロセスからDLLをアンロードします。

// Unload "LibSpy.dll" from the target process
// (via CreateRemoteThread & FreeLibrary)
hThread = ::CreateRemoteThread( hProcess, NULL, 0,
                     (LPTHREAD_START_ROUTINE )::GetProcAddress(
                     ::GetModuleHandle("Kernel32"), "FreeLibrary"),
                     (void*)hLibModule,
                      0, NULL );
::WaitForSingleObject( hThread, INFINITE );

// Clean up
::CloseHandle( hThread );

プロセス間通信

 ここまでは、リモートプロセスにDLLを割り込ませる方法についてのみ説明してきました。しかし、たいていの場合は、割り込んだDLLが元のアプリケーションと何らかの方法で通信を行う必要があります(ローカルアプリケーションではなくリモートプロセスにDLLをマッピングしているのだということを思い出してください!)。本稿のサンプルのパスワードスパイについて考えてみましょう。このDLLは、実際にパスワードを含んでいるコントロールへのハンドルを知らなければなりません。当然ながら、この値をコンパイル時にハードコーディングすることはできません。同様に、このDLLはパスワードを取得した後、それを表示するためにアプリケーションに送信しなければなりません。

 そのためにはさまざまな方法があり、たとえばファイルマッピング、WM_COPYDATA、クリップボード、#pragma data_segなどを使用する方法が考えられます。これらのテクニックについてはMSDNや他のチュートリアルでよく説明されているので、ここでは触れません(プロセス間通信のトピックとして説明されています)。本稿のLibSpyサンプルでは#pragma data_segを使用しています。

 本稿の最初で紹介しているダウンロードパッケージに、LibSpyの実行可能ファイルとソースファイルが含まれています。

3. CreateRemoteThread+WriteProcessMemoryのテクニック

 デモアプリケーション:WinSpy

 コードを別のプロセスのアドレス空間にコピーし、そのプロセスのコンテキスト内で実行するには、リモートスレッドとWriteProcessMemory API関数を使用するという方法もあります。独立したDLLを記述するのではなく、WriteProcessMemoryを通じてコードをリモートプロセスに直接コピーし、CreateRemoteThreadを使ってそのコードの実行を開始します。

 まず、CreateRemoteThreadの宣言を見てみましょう。

HANDLE CreateRemoteThread(
  HANDLE hProcess,        // handle to process to create thread in
  LPSECURITY_ATTRIBUTES lpThreadAttributes,  // pointer to security
                                             // attributes
  DWORD dwStackSize,      // initial thread stack size, in bytes
  LPTHREAD_START_ROUTINE lpStartAddress,     // pointer to thread
                                             // function
  LPVOID lpParameter,     // argument for new thread
  DWORD dwCreationFlags,  // creation flags
  LPDWORD lpThreadId      // pointer to returned thread identifier
); 

 MSDNに記載されているCreateThreadの宣言と比較してみると、次の相違点があることに気付きます。

  • CreateRemoteThreadにはhProcessパラメータが追加されています。このパラメータは、スレッドが作成されるプロセスへのハンドルを表します。
  • CreateRemoteThreadlpStartAddressパラメータは、リモートプロセスのアドレス空間内のスレッドの開始アドレスを表します。この関数はリモートプロセス内にあるはずなので、ローカルなThreadFuncへのポインタを単純に渡すことはできません。まずリモートプロセスにコードをコピーする必要があります。
  • 同様に、lpParameterによって参照されるデータはリモートプロセス内にあるはずなので、このデータもリモートプロセスにコピーする必要があります。

 このテクニックの手順をまとめると次のようになります。

  1. リモートプロセスへのハンドルを取得します(OpenProcess)。
  2. リモートプロセスのアドレス空間内で、割り込みデータのためのメモリを割り当てます(VirtualAllocEx)。
  3. 割り当てたメモリに、初期化したINJDATA構造体のコピーを書き込みます(WriteProcessMemory)。
  4. リモートプロセスのアドレス空間内で、割り込みコードのためのメモリを割り当てます。
  5. 割り当てたメモリに、ThreadFuncのコピーを書き込みます。
  6. CreateRemoteThreadを使用して、リモートプロセス内のThreadFuncを開始します。
  7. リモートスレッドが終了するまで待機します(WaitForSingleObject)。
  8. リモートプロセスから結果を取得します(ReadProcessMemoryまたはGetExitCodeThread)。
  9. 手順2と手順4で割り当てたメモリを解放します(VirtualFreeEx)。
  10. 手順6と手順1で取得したハンドルをクローズします(CloseHandle)。

 ThreadFuncに関しては次の規則に従ってください。

  1. ThreadFuncが呼び出せる関数は、kernel32.dllおよびuser32.dllに含まれているものだけです。ローカルプロセスとターゲットプロセスの両方で同じロードアドレスにあることが保証されているのは、kernel32とuser32だけだからです(付録Aを参照。なお、user32はすべてのWin32プロセスにマッピングされるとは限りません)。他のライブラリ内の関数が必要な場合は、LoadLibraryGetProcAddressのアドレスを割り込みコードに渡し、残りの処理は割り込みコードで行います。何らかの理由でデータベースDLLが既にターゲットプロセスにマッピングされている場合は、LoadLibraryの代わりにGetModuleHandleを使用することもできます。
    同様に、ThreadFunc内から独自のサブルーチンを呼び出したい場合は、各ルーチンを個別にリモートプロセスにコピーし、それぞれのアドレスをINJDATA経由でThreadFuncに提供します。
  2. 静的な文字列を使用しないでください。すべての文字列はINJDATA経由でThreadFuncに渡すようにします。
    これには次のような理由があります。コンパイラはすべての静的な文字列を実行可能ファイルの.dataセクションに配置し、コード内には参照(=ポインタ)だけを残します。そのため、リモートプロセス内のThreadFuncは、存在しない(少なくとも自分のアドレス空間には存在しない)文字列を参照することになってしまいます。
  3. /GZコンパイラスイッチを削除してください。デバッグビルドでは、このスイッチが既定で設定されています(付録Bを参照)。
  4. ThreadFuncAfterThreadFuncstaticとして宣言するか、インクリメンタルリンクを無効にします(付録Cを参照)。
  5. ThreadFunc内のローカル変数はページサイズ(4Kb)より小さくする必要があります(付録D を参照)。デバッグビルドでは、使用可能な4Kbのうち数十バイトが内部変数に使用されるという点に注意してください。
  6. 4つ以上のcaseステートメントを含むswitchブロックがある場合は、次のように分割するか、
    switch( expression ) {
        case constant1: statement1; goto END;
        case constant2: statement2; goto END;
        case constant3: statement2; goto END;
    }
    switch( expression ) {
        case constant4: statement4; goto END;
        case constant5: statement5; goto END;
        case constant6: statement6; goto END;
    }
    END:
    
    一連のif-else ifステートメントに書き換えます(付録Eを参照)。

 以上の規則に従わないと、ほぼ必ずターゲットプロセスがクラッシュします。ターゲットプロセスとローカルプロセスで同じアドレスが使用されている保証はない、という点だけはよく覚えておいてください(付録Fを参照)。

GetWindowTextRemote(A/W)

 リモートのエディットコントロールからパスワードを取得するために必要な機能はすべてGetWindowTextRemote(A/W)にカプセル化されています。

int GetWindowTextRemoteA ( HANDLE hProcess, HWND hWnd, LPSTR lpString );
int GetWindowTextRemoteW ( HANDLE hProcess, HWND hWnd, LPWSTR lpString );

パラメータ

hProcessエディットコントロールが属しているプロセスへのハンドル
hWndパスワードを含んでいるエディットコントロールへのハンドル
lpStringテキストを受け取るバッファへのポインタ

戻り値

 コピーした文字数が返されます。

 それでは、GetWindowTextRemoteの動作を理解するために、ソースの一部を実際に見てみましょう。割り込ませるデータとコードの部分に注目します。ここでも、単純化するためにUnicodeサポートのコードは省いています。

INJDATA

typedef LRESULT    (WINAPI *SENDMESSAGE)(HWND,UINT,WPARAM,LPARAM);

typedef struct {
   HWND hwnd;                    // handle to edit control
   SENDMESSAGE  fnSendMessage;   // pointer to user32!SendMessageA

   char psText[128];    // buffer that is to receive the password
} INJDATA;

 INJDATAは、リモートプロセスに割り込ませるデータ構造体です。ただし、実際に割り込ませる前に、この構造体内のSendMessageAへのポインタをアプリケーション内で初期化します。ここでのポイントは、user32.dll(ある場合)をすべてのプロセス内で常に同じアドレスにマッピングするということです。これにより、SendMessageAのアドレスも常に同じになります。したがって、必ず有効なポインタがリモートプロセスに渡されます。

ThreadFunc

static DWORD WINAPI ThreadFunc (INJDATA *pData)
{
   pData->fnSendMessage( pData->hwnd, WM_GETTEXT, // Get password
                         sizeof(pData->psText),
                         (LPARAM)pData->psText );
   return 0;
}

// This function marks the memory address after ThreadFunc.
// int cbCodeSize = (PBYTE) AfterThreadFunc - (PBYTE) ThreadFunc.
static void AfterThreadFunc (void)
{
}

 ThreadFuncは、リモートスレッド側で実行されるコードです。次の点に注意してください。

  • ThreadFuncのコードサイズを計算するときのAfterThreadFuncの使われ方に注意してください。一般的には、これはあまり良い方法とは言えません。リンカが関数の順序を自由に変更するからです。つまり、AfterThreadFuncの後にThreadFuncが配置される可能性もあります。しかし、本稿のWinSpyのように小さなプロジェクトでは、関数の順序はほぼ間違いなく維持されます。必要であれば/ORDERリンカオプションを使用することもできますし、より安全を期すならば、逆アセンブラによってThreadFuncのサイズを判定することもできます。

このテクニックを使ってリモートコントロールをサブクラス化する方法

 デモアプリケーション:InjectEx

 今度はもう少し複雑な話をしましょう。別のプロセスに属しているコントロールをこのテクニックでサブクラス化するにはどうすればいいか、という話です。

 これを実現するには、まず次の2つの関数をリモートプロセスにコピーする必要があります。

  1. ThreadFuncSetWindowLongを通じて、リモートプロセス内のコントロールを実際にサブクラス化します。
  2. NewProc − サブクラス化されたコントロールの新しいウィンドウプロシージャです。

 問題は、リモートのNewProcにデータを渡すにはどうするか、という点です。NewProcはコールバック関数であり、特定のガイドラインに従う必要があるので、INJDATAへのポインタを単純に引数として渡すことができません。この問題を解決するには2とおりの方法がありますが、どちらもアセンブリ言語を使用する必要があります。ここまではできるだけアセンブラ関連の話を付録に回すよう努力してきたのですが、ここではどうしても避けられないので、しばらくお付き合いください。

ソリューション1

 次の図を見てください。

仮想アドレス空間
仮想アドレス空間

 リモートプロセス内ではINJDATANewProcの直前に置かれていることに注目してください。これにより、NewProcはリモートプロセスアドレス空間内でのINJDATAのメモリロケーションをコンパイル時に把握することができます。これは、より正確に言えば、自分のロケーションを基準とした場合のINJDATAの相対アドレスです。しかし、ここで必要なのはまさにこの情報です。そこで、NewProcは次のようになります。

static LRESULT CALLBACK NewProc(
  HWND hwnd,       // handle to window
  UINT uMsg,       // message identifier
  WPARAM wParam,   // first message parameter
  LPARAM lParam )  // second message parameter
{
    INJDATA* pData = (INJDATA*) NewProc;  // pData points to
                                          // NewProc;
    pData--;              // now pData points to INJDATA;
                          // recall that INJDATA in the remote
                          // process is immediately before NewProc;

    //-----------------------------
    // subclassing code goes here
    // ........
    //-----------------------------

    // call original window procedure;
    // fnOldProc (returned by SetWindowLong) was initialised by
    // (the remote) ThreadFunc and stored in (the remote) INJDATA;
    return pData->fnCallWindowProc( pData->fnOldProc,
                                    hwnd,uMsg,wParam,lParam );
}

 しかし、これでもまだ問題があります。1行目を見てください。

INJDATA* pData = (INJDATA*) NewProc;

 こうすると、ハードコーディングされた値(ローカルプロセス内のオリジナルのNewProcのメモリロケーション)が、pDataに割り当てられます。これは我々が本当に望んでいるものではありません。ここで必要なのは、NewProcが実際にどこに移動した場合でも、リモートプロセス内のNewProcの「現在の」コピーのメモリロケーションを取得できるような手段です。言い換えると、一種の「thisポインタ」が必要であるということです。

 この問題をC/C++で解決することはできませんが、インラインでアセンブラを利用すれば解決できます。修正後のNewProcは次のようになります。

static LRESULT CALLBACK NewProc(
  HWND hwnd,         // handle to window
  UINT uMsg,         // message identifier
  WPARAM wParam,     // first message parameter
  LPARAM lParam )    // second message parameter
{
    // calculate location of the INJDATA struct
    // (remember that INJDATA in the remote process
    // was placed immediately before NewProc)
    INJDATA* pData;
    _asm {
        call    dummy
dummy:
        pop     ecx     // <- ECX contains the current EIP
        sub     ecx, 9  // <- ECX contains the address of NewProc
        mov     pData, ecx
    }
    pData--;

    //-----------------------------
    // subclassing code goes here
    // ........
    //-----------------------------

    // call original window procedure
    return pData->fnCallWindowProc( pData->fnOldProc,
                                    hwnd,uMsg,wParam,lParam );
}

 さて、何がどうなったでしょうか。

 ほとんどのプロセッサには、次に実行する命令のメモリロケーションを指す特殊なレジスタがあります。これがいわゆる命令ポインタで、IntelおよびAMDの32ビットプロセッサでは「EIP」で表されます。EIPは特殊目的のレジスタなので、EAXEBXなどの汎用レジスタとは異なりプログラム的にアクセスできません。言い換えると、EIPを参照したり、その内容を明示的に読み書きしたりするためのOpCodeがありません。しかしそれでも、JMPCALLRETなどの命令を使用してEIPを暗黙的に変更することができます(実際、絶えず変更されています)。たとえば、IntelおよびAMDの32ビットプロセッサでサブルーチンのCALL/RETメカニズムがどのように機能しているかを考えてみましょう。

 CALLを通じてサブルーチンを呼び出すと、そのサブルーチンのアドレスがEIPにロードされます。ただし、EIPが書き換えられる前に、元の値が自動的にスタックにプッシュされます(後でリターン命令ポインタとして使用します)。サブルーチンの終了時に、RET命令がスタックの一番上の値を自動的にEIPにポップします。

 これでCALLRETによってEIPが書き換えられることはわかりましたが、EIPの現在値を取得するにはどうすればいいでしょうか?

 ここで、CALLEIPをスタックにプッシュするということを思い出してください。したがって、EIPの現在値を取得するためには、ダミー関数を呼び出し、その直後にスタックをポップすればよいのです。この仕組みを、コンパイル済みのNewProcで見てみましょう。

Address  OpCode&Params   Decoded instruction
--------------------------------------------------
:00401000 55          push ebp            ; entry point of
                                              ; NewProc
:00401001 8BEC            mov ebp, esp
:00401003 51              push ecx
:00401004 E800000000      call 00401009       ; *1*    call dummy
:00401009 59          pop ecx             ; *2*
:0040100A 83E909          sub ecx, 00000009   ; *3*
:0040100D 894DFC          mov [ebp-04], ecx   ; mov pData, ECX
:00401010 8B45FC          mov eax, [ebp-04]
:00401013 83E814          sub eax, 00000014   ; pData--;
.....
.....
:0040102D 8BE5            mov esp, ebp
:0040102F 5D              pop ebp
:00401030 C21000          ret 0010
  1. ダミー関数の呼び出しです。この呼び出しは次の命令にジャンプし、EIPをスタックにプッシュするだけです。
  2. スタックをECXにポップします。これにより、ECXEIPを格納することになります。これはpop ECX命令のアドレスを表します。
  3. NewProcのエントリポイントからpop ECX命令までの「距離」が9バイトであることに注目してください。したがって、ECXから9を引けば、NewProcのアドレスが得られます。

 こうすれば、NewProcがどのロケーションに移動しても、常にNewProcのアドレスを計算することができます。ただし、NewProcのエントリポイントからpop ECX命令までの距離は、コンパイラ/リンカオプションを変更すると変化することがあるので注意してください。したがって、この距離はリリースビルドとデバッグビルドでも異なります。それでも、ここで重要なのは、コンパイル時に正確な値を把握できるという点です。実際には次の手順を行います。

  1. まず関数をコンパイルします。
  2. 逆アセンブラを行い、正しい距離を調べます。
  3. 最後に、正しい距離を使用して再コンパイルします。

 これが、本稿のサンプルInjectExで使用しているソリューションです。InjectExでは、HookInjExと同様に、[スタート]ボタンの左クリックと右クリックを入れ替えます。

ソリューション2

 この問題の解決策は、リモートプロセスのアドレス空間内でNewProcの直前にINJDATAを配置するという方法だけではありません。次のようなNewProcも考えられます。

static LRESULT CALLBACK NewProc(
  HWND hwnd,         // handle to window
  UINT uMsg,         // message identifier
  WPARAM wParam,     // first message parameter
  LPARAM lParam )    // second message parameter
{
    INJDATA* pData = 0xA0B0C0D0;    // a dummy value

    //-----------------------------
    // subclassing code goes here
    // ........
    //-----------------------------

    // call original window procedure
    return pData->fnCallWindowProc( pData->fnOldProc,
                                    hwnd,uMsg,wParam,lParam );
}

 このコード中の0xA0B0C0D0は、リモートプロセスのアドレス空間内でのINJDATAの実アドレス(絶対アドレス)を表すただのプレースホルダです。このアドレスは、コンパイル時にはわかりません。しかし、INJDATAに対するVirtualAllocExの呼び出しを行った直後に、リモートプロセス内でのINJDATAのロケーションを知ることができます。

 このNewProcをコンパイルすると次のようになります。

Address  OpCode&Params     Decoded instruction
--------------------------------------------------
:00401000 55                push ebp
:00401001 8BEC              mov ebp, esp
:00401003 C745FCD0C0B0A0    mov [ebp-04], A0B0C0D0
:0040100A ...
....
....
:0040102D 8BE5              mov esp, ebp
:0040102F 5D                pop ebp
:00401030 C21000            ret 0010

 したがって、コンパイル済みのコード(16進形式)は558BECC745FCD0C0B0A0......8BE55DC21000となります。

 ここで、次の作業を行います。

  1. INJDATAThreadFuncNewProcをターゲットプロセスにコピーします。
  2. NewProcのコードを修正し、pDataINJDATAの実アドレスを持つようにします。
    たとえば、ターゲットプロセス内でのINJDATAのアドレス(VirtualAllocExの戻り値)が0x008a0000であるとします。この場合は、NewProcのコードを次のように修正します。
    558BECC745FCD0C0B0A0......8BE55DC21000← 元のNewProc【1】
    558BECC745FC00008A00......8BE55DC21000INJDATAの実アドレスで修正した後のNewProc
    つまり、A0B0C0D0というダミー値をINJDATAの実アドレスで置き換えます【2】。
  3. リモートのThreadFuncの実行を開始し、そのコードを通じてリモートプロセス内のコントロールをサブクラス化します。

 【1】コンパイル済みコード内でA0B0C0D0008a0000というアドレスが逆の順序で現れることを不思議に思った人もいるのではないでしょうか。これは、IntelやAMDのプロセッサがマルチバイトデータを表すときにリトルエンディアン方式を使用しているからです。リトルエンディアンとは、数値の下位バイトを下位アドレスに格納し、上位バイトを上位アドレスに格納するという方式です。

 たとえばUNIXという単語を4バイトに格納する場合を考えてみましょう。ビッグエンディアン方式では、これを「UNIX」として格納します。リトルエンディアン方式では、これを「XINU」として格納します。

 【2】悪意のある人物は同様の方法で実行可能ファイルのコードを書き換えます。しかし、一度メモリにロードしてしまえば、プログラムが自身のコード(実行可能ファイルの.textセクションにあるコード)を変更することはできません(このコードは書き込み不可です)。それに対して、リモートのNewProcPAGE_EXECUTE_READWRITEパーミッションを使用してメモリの一部にコピーしておいたものなので、コードを修正できます。

CreateRemoteThread+WriteProcessMemoryのテクニックはどこで使用すべきか

 CreateRemoteThreadWriteProcessMemoryを使ってコード割り込みを実現するというテクニックは、追加のDLLが必要ないため、他の方法に比べて柔軟性が高いと言えます。しかし同時に、他の方法よりも複雑で、リスクが高いという側面もあります。ThreadFuncで何か下手なことをすると、リモートプロセスが簡単にクラッシュしてしまいます(付録Fを参照)。また、リモートのThreadFuncをデバッグするには大変な手間がかかります。このテクニックは、限られた数の命令を割り込ませるときにのみ使用してください。ある程度長いコードを割り込ませるときは、1.または2.のテクニックを使用することをお勧めします。

 本稿の最初で紹介しているダウンロードパッケージに、WinSpyおよびInjectExの実行可能ファイルとソースファイルが含まれています。

おわりに

 最後に、ここまで触れずにいたいくつかの点をまとめておきます。

種類OSプロセス
1. フックWin9xおよびWinNTUSER32.DLLにリンクしているプロセスのみ【1】
2. CreateRemoteThreadLoadLibraryWinNTのみ【2】すべてのプロセス【3】(システムサービスを含む【4】)
3. CreateRemoteThreadWriteProcessMemoryWinNTのみすべてのプロセス(システムサービスを含む)
  1. 当然ながら、メッセージキューを持たないスレッドにはフックできません。また、SetWindowsHookExはシステムサービスに対しては機能しません(USER32.DLLにリンクしている場合も同様)。
  2. Win9xにはCreateRemoteThreadVirtualAllocExがありません(実際にはWin9xでもこれらの関数をエミュレートできますが、詳細は割愛します)。
  3. すべてのプロセス=Win32の全プロセス+csrss.exe
    smss.exe、os2ss.exe、autochk.exeなどのネイティブアプリケーションはWin32 APIを使用せず、kernel32.dllにもリンクしません。唯一の例外は、Win32サブシステム本体であるcsrss.exeです。csrss.exeはネイティブアプリケーションですが、その一部のライブラリ(~winsrv.dll)は、kernel32.dlをはじめとするWin32 DLLを必要とします。
  4. システムサービス(lsass.exe、services.exe、winlogon.exeなど)やcsrss.exeにコードを割り込ませる場合は、OpenProcessを使用してリモートプロセスへのハンドルを開く前に、AdjustTokenPrivilegesを使用してプロセスの権限をSeDebugPrivilegeに設定します。

 これで注意すべき点はほとんど説明しましたが、もう1つ覚えておいてほしいことがあります。それは、割り込ませたコードのせいでターゲットプロセスがダウンする可能性があるということです。特に、そのコードに何か問題があるときは危険です。力にはそれなりの責任が伴う、ということを覚えておいてください。

 本稿で紹介したサンプルの多くはパスワードに関するものだったので、さらに興味のある方はZhefu Zhangの記事「Super Password Spy++」も読んでみてください。Zhefu Zhangの記事では、Internet Explorerのパスワードフィールドからパスワードを取得する方法などを説明しています。さらに、そのような攻撃からパスワードコントロールを保護する方法も紹介しています。

 最後になりましたが、記事を書いたり発表したりする身としては、読者からの反響が何よりの励みになります。おもしろいと思われた方は、ぜひコメントをお寄せください。さらに、本稿の内容に間違いやバグがある場合、こうすればもっと良くなるというアイデアがある場合、よくわからない部分がある場合なども、お気軽にご意見をお寄せください。

謝辞

 まずCodeGuru読者の皆さんに感謝します。最初は1200ワード程度だった記事がこのように6000ワード級の長編になったのは、主に皆さんからの質問のおかげです。しかし、特に誰か1人を挙げるとすればRado Pichaでしょう。本稿のかなりの部分は、彼の提案と解説に基づいています。また、私のつたない英文に手を入れ、読みやすい原稿に仕上げてくれたSusan Mooreにも感謝したいと思います。

付録

A)なぜKERNEL32.DLLとUSER32.DLLは常に同じアドレスにマッピングされるのか

 おそらく、その方が速度を最適化するのに便利だとMicrosoftのプログラマが考えたからです。その理由を考えてみましょう。

 一般的に、実行可能ファイルは.relocセクションなどのいくつかのセクションから成ります。

 リンカがEXEまたはDLLファイルを作成するときは、そのファイルがメモリのどこにマッピングされるかを仮定します。これが、仮定のロード/基底アドレス、望ましいロード/基底アドレスと呼ばれるものです。イメージ内のすべての絶対アドレスは、このリンカーが仮定したロードアドレスに基づきます。何らかの理由でイメージがこのアドレスにロードされなかった場合は、イメージ内のすべての絶対アドレスをPE(portable executable)ローダーが修正しなければなりません。ここで.relocセクションの出番です。このセクションには、リンカーが仮定したロードアドレスと実際のロードアドレスとの違いを考慮する必要があるイメージ内のすべての場所のリストが含まれています(もっとも、コンパイラによって生成される命令の大部分は何らかの相対アドレス指定を使用しているので、再配置が必要な場所はそれほど多くありません)。一方、イメージをリンカーの望ましい基底アドレスにそのままロードできる場合は、.relocセクションは完全に無視されます。

 しかし、kernel32.dll、user32.dllとそのロードアドレスはどういうしくみで常に同じアドレスに配置されるのでしょうか。

 すべてのWin32アプリケーションはkernel32.dllを必要とし、その大部分はさらにuser32.dllも必要とするので、この2つのDLLを常に望ましい基底アドレスにマッピングしておけば、すべての実行可能プログラムのロード時間を短縮できます。そのため、ローダーはkernel32.dllとuser32.dllの(絶対)アドレスを決して修正しないのだと思われます。

 これを具体的な例で考えてみましょう。

 App.exeのイメージ基底アドレスを、KERNEL32(/base:"0x77e80000")またはUSER32(/base:"0x77e10000")の望ましい基底アドレスに設定しました。App.exeがUSER32からのインポートを行わない場合は、単純にLoadLibraryを使用してロードします。その後、App.exeをコンパイルして実行してみました。すると、「Illegal System DLL Relocation」というエラーボックスがポップアップされ、App.exeのロードに失敗しました。

 これはなぜでしょうか? Win 2000、Win XP、およびWin 2003上のローダーがプロセスを作成するときには、kernel32.dllとuser32.dllが望ましい基底アドレスにマッピングされているかどうかを確認し、されていない場合はハードエラーを発生させます(これらのDLL名はローダーにハードコーディングされています)。WinNT 4では、ole32.dllについても確認しました。WinNT 3.51以前は、このようなチェックが存在しなかったため、kernel32.dllとuser32.dllはどこにでもマッピングされる可能性がありました。なお、ntdll.dllだけは必ず基底アドレスに配置されます。ローダーはこのモジュールをチェックしませんが、ntdll.dllが基底アドレスにない場合は、そもそもプロセスを作成できません。

 まとめると、WinNT 4以上では次のようになっています。

  • 必ず基底アドレスにマッピングされるDLL:kernel32.dll、user32.dll、ntdll.dll
  • すべてのWin32アプリケーション(+csrss.exe)で使用されるDLL:kernel32.dll、ntdll.dll
  • すべてのプロセスで(ネイティブアプリケーションでも)使用される唯一のDLL:ntdll.dll

B)/GZコンパイラスイッチ

 デバッグビルドでは、/GZコンパイラスイッチが既定で有効になっています。このスイッチを使用すると、いくつかのエラーをキャッチすることができます(詳しくはマニュアルを参照してください)。ところで、このスイッチは実行可能ファイルに対してどのような影響を与えるのでしょうか。

 /GZを有効にすると、コンパイラは、生成する実行可能ファイル内のすべての関数の末尾に追加コードを書き込みます。この追加コードには、その関数の中でESPスタックポインタが変化していないことを確認するための関数呼び出しなどが含まれます。ここでの問題は、ThreadFuncに関数呼び出しが追加されるという点です。これはエラーにつながります。リモートプロセス内のThreadFuncが、リモートプロセス内には存在しない(少なくとも同じアドレスにはない)関数を呼び出すことになるからです。

C)静的関数とインクリメンタルリンク

 インクリメンタルリンクは、アプリケーションをビルドするときのリンク時間を短縮するために使われます。通常のリンクを行った実行可能ファイルとインクリメンタルリンクを行った実行可能ファイルとの違いは、後者では、個々の関数呼び出しがリンカーから出された追加のJMP命令を通じて実行されるという点です(ただし、staticとして宣言した関数は例外です)。これらのJMP命令により、リンカーはその関数を参照するすべてのCALL命令を更新しなくても、関数をメモリ内のあちこちに移動できるようになります。しかし、このJMP命令が問題を引き起こします。これにより、ThreadFuncAfterThreadFuncが実コードではなくJMP命令を指すようになるからです。したがって、次の方法でThreadFuncのサイズを計算すると、

const int cbCodeSize = ((LPBYTE) AfterThreadFunc
                      - (LPBYTE) ThreadFunc);

 実際には、それぞれThreadFuncAfterThreadFuncを指す2つのJMP命令の間の「距離」を計算することになってしまいます(通常はこれらの命令が並んで配置されますが、ここでは考えません)。たとえば、ThreadFuncがアドレス004014C0にあり、対応するJMP命令が00401020にあるとします。

:00401020   jmp  004014C0
 ...
:004014C0   push EBP          ; real address of ThreadFunc
:004014C1   mov  EBP, ESP
 ...

 このとき、

WriteProcessMemory( .., &ThreadFunc, cbCodeSize, ..);

 という関数は、実際のThreadFuncではなく、JMP 004014C0命令(およびそれ以降のcbCodeSizeの範囲内に含まれるすべての命令)をリモートプロセスにコピーします。したがって、リモートスレッドが最初に実行するのはJMP 004014C0命令になります。これは、そのリモートスレッドにとってだけでなく、プロセス全体にとっても最初の命令になります。

 しかし、このJMP命令の規則には例外があります。staticとして宣言した関数は、インクリメンタルリンクを行った場合でも直接呼び出されるのです。これが、規則4ThreadFuncAfterThreadFuncstaticとして宣言するか、インクリメンタルリンクを無効にするよう指示している理由です(インクリメンタルリンクのその他の側面については、Matt Pietrekの記事「Remove Fatty Deposits from Your Applications Using Our 32-bit Liposuction Tools」を参照のこと)。

D)ThreadFuncでローカル変数を4Kbまでしか使用できないのはなぜか

 ローカル変数は必ずスタックに格納されます。たとえば、ある関数内に256バイトのローカル変数がある場合は、その関数(より正確には関数のプロローグ)に入ると、スタックポインタが256バイト減少します。たとえば次の関数は、

void Dummy(void) {
   BYTE var[256];
   var[0]   = 0;
   var[1]   = 1;
   var[255] = 255;
}

 コンパイルすると次のようになります。

:00401000   push ebp
:00401001   mov  ebp, esp
:00401003   sub  esp, 00000100     ; change ESP as storage for
                                   ; local variables is needed
:00401006   mov  byte ptr [esp], 00      ; var[0] = 0;
:0040100A   mov  byte ptr [esp+01], 01   ; var[1] = 1;
:0040100F   mov  byte ptr [esp+FF], FF   ; var[255] = 255;
:00401017   mov  esp, ebp          ; restore stack pointer
:00401019   pop  ebp
:0040101A   ret

 この例でスタックポインタ(ESP)がどのように変化しているかに注目してください。では、4Kb超のローカル変数を必要とする関数の場合はどうなるでしょうか? その場合は、スタックポインタが直接変更されません。その代わりに、別の関数(スタックプローブ)が呼び出され、その関数によって適切に変更されます。しかし、この追加の関数呼び出しがThreadFuncの動作を妨げます。リモートのThreadFuncが、存在しないものを呼び出そうとしてしまうからです。

 マニュアルでは、スタックプローブと/Gsコンパイラオプションについて次のように書かれています。

/Gssizeオプションは、スタックプローブを制御するための高度な機能です。スタックプローブとは、コンパイラがすべての関数呼び出しに挿入するコード群です。スタックプローブがアクティブになると、該当する関数のローカル変数を保存するために必要なスタック領域のサイズ分だけがメモリに入ります。
関数のローカル変数を保存するために必要なスタック領域がnバイトを超えると、その関数のスタックプローブがアクティブになります。nの既定値は1ページ分(80×86プロセッサでは4Kb)に相当します。この値によってWin32用アプリケーションとWindows NTの仮想メモリマネージャ間の連系動作を調整できるため、実行時にプログラムスタックで使用できるメモリ量を増やすことができます。」

 この説明の「領域の量だけがメモリに入ります」という部分で考え込んだ人も多いのではないでしょうか。こうしたコンパイラオプション(とその解説)は、ときどき非常にわかりにくくてイライラさせられます。何がどうなっているのかを実際に見てみれば、それほど難しいことではないのですが。たとえば、12Kbのローカル変数を必要とする関数の場合は、スタック上のメモリが次のようにして「割り当て」られます(正確には「コミット」されます)。

sub    esp, 0x1000    ; "allocate" first 4 Kb
test  [esp], eax      ; touches memory in order to commit a
                      ; new page (if not already committed)
sub    esp, 0x1000    ; "allocate" second 4 Kb
test  [esp], eax      ; ...
sub    esp, 0x1000
test  [esp], eax

 スタックポインタが4Kbずつ変更され、各ステップの後でスタックの下部がtest命令によって「タッチ」されていることに注目してください。これにより、別のページを割り当て(コミット)する前に、スタックの下部を含んでいるページがコミットされます。

 さらに次の説明を読むと、

「新しいスレッドは、コミット済みメモリと予約済みメモリの両方から成る専用のスタック領域を受け取ります。既定では、各スレッドは1Mbの予約済みメモリと1ページのコミット済みメモリを使用します。システムは、必要に応じて予約済みスタックメモリから1ページブロックをコミットします。」(MSDNの「CreateThread > dwStackSize > Thread Stack Size」を参照)

 /Gsについての説明の中で、アプリケーションとWindows NT仮想メモリマネージャとの連係動作でスタックプローブを使用することに言及している理由がわかります。

 では、ThreadFuncと4Kbの制限に話を戻しましょう。

 /Gsを使用すればスタックプローブルーチンの呼び出しを妨げることができますが、マニュアルでは、そうしないよう推奨されています。さらに、スタックプローブの有効/無効は#pragma check_stack指令で切り替えることができると書かれています。しかし、このプラグマはスタックプローブに何も影響を与えないようです(マニュアルが間違っているのか、それとも私が何か別の要素を見逃しているのでしょうか?)。ともかく、CreateRemoteThreadWriteProcessMemoryのテクニックは、短いコードを割り込ませるときにのみ使用することをお勧めします。そうすれば、ローカル変数で何バイトも消費することはまずなくなり、4Kbの制限に近づくこともないからです。

E)4つ以上のcaseステートメントを含むswitchブロックを分割するのはなぜか

 ここでも、例を見ながら説明していきたいと思います。次のような関数があるとします。

int Dummy( int arg1 )
{
   int ret = 0;

   switch( arg1 ) {
   case 1: ret = 1; break;
   case 2: ret = 2; break;
   case 3: ret = 3; break;
   case 4: ret = 0xA0B0; break;
   }
   return ret;
}

 この関数は、コンパイルすると次のようになります。

Address  OpCode&Params    Decoded instruction
--------------------------------------------------
                                            ; arg1 -> ECX
:00401000 8B4C2404         mov ecx, dword ptr [esp+04]
:00401004 33C0             xor eax, eax     ; EAX = 0
:00401006 49               dec ecx          ; ECX --
:00401007 83F903           cmp ecx, 00000003
:0040100A 771E             ja 0040102A

; JMP to one of the addresses in table ***
; note that ECX contains the offset
:0040100C FF248D2C104000   jmp dword ptr [4*ecx+0040102C]

; case 1: eax = 1;
:00401013 B801000000       mov eax, 00000001
:00401018 C3               ret

; case 2: eax = 2;
:00401019 B802000000       mov eax, 00000002
:0040101E C3               ret

; case 3: eax = 3;
:0040101F B803000000       mov eax, 00000003
:00401024 C3               ret

; case 4: eax = 0xA0B0;
:00401025 B8B0A00000       mov eax, 0000A0B0

:0040102A C3               ret
:0040102B 90               nop

; Address table ***
:0040102C 13104000         DWORD 00401013   ; jump to case 1
:00401030 19104000         DWORD 00401019   ; jump to case 2
:00401034 1F104000         DWORD 0040101F   ; jump to case 3
:00401038 25104000         DWORD 00401025   ; jump to case 4

 switch-caseがどのように実装されているかに注目してください。

 1つ1つのcaseステートメントを個別に検討するのではなく、アドレステーブルを作成しています。その上で、アドレステーブルでのオフセットを計算し、適切なcaseステートメントにジャンプします。一見したところ、これは優れた処理方法のように見えます。たとえば50個のcaseステートメントを含むswitchブロックの場合は、上記のトリックを使わないと、最後のcaseステートメントに到達するまでにCMP命令とJMP命令を50回実行しなければなりません。しかし、アドレステーブルを利用すれば、テーブルを1回参照するだけでどのcaseステートメントにもジャンプできます。コンピュータアルゴリズムと時間計算量(time complexity)の用語で言えば、O(2n)のアルゴリズムをO(5)のアルゴリズムで置き換えたことになります。これは次のことを意味しています。

  1. Oは、最悪ケースの時間計算量を表します。
  2. オフセットを計算し、テーブルルックアップを行い、最終的に適切なアドレスにジャンプするためには、5つの命令が必要です。

 この方法はcase定数が1、2、3、4と連続しているから可能だったのではないかと思う人もいるかもしれません。しかしこのソリューションは、オフセット計算を少し複雑にするだけで、たいていの実世界の例に応用することができます。ただし次の2つの例外があります。

  • caseステートメントが3つ以下の場合
  • case定数どうしの関連性がまったくない場合(例:"case 1""case 13""case 50""case 1000"など)

 このような場合は、CMP命令とJMP命令を使って1つ1つのcase定数を個別に検討しなければならないので、結果コードが非常に長くなります。つまり、この場合の結果コードは、普通のif-else ifシーケンスを使ってコーディングしたのと実質的に同じになります。

 これまでに、どうしてcaseステートメントには定数式しか指定できないのか疑問に思ったことがある人もいるでしょうが、これでその理由がわかったと思います。上記のようなアドレステーブルを作成するためには、この値がコンパイル時に確定していなければならないのです。

 では、本題に戻りましょう。

 アドレス0040100CJMP命令に注目してください。Intelのマニュアルでは、16進のOpCode FFについて次のように説明されています。

Opcode    Instruction    Description
FF /4     JMP r/m32      Jump near, absolute indirect,
                         address given in r/m32

 なんと、データベースJMPは何らかの絶対アドレス指定を使用するというのです。つまり、この命令のオペランドの1つ(今回の例では0040102C)は絶対アドレスを表すということです。これ以上言う必要があるでしょうか? リモートのThreadFuncswitchブロックのアドレステーブルが0040102Cにあると盲目的に信じ込み、間違ったロケーションにジャンプするため、リモートプロセスがクラッシュしてしまいます。

F)そもそもリモートプロセスがクラッシュするのはなぜか

 リモートプロセスがクラッシュするときは、必ず次のいずれかの理由によります。

  1. ThreadFunc内の存在しない文字列を参照した。
  2. ThreadFunc内の命令が絶対アドレスを使用している(具体例については付録Eを参照)。
  3. ThreadFuncが存在しない関数を呼び出した(この呼び出しはコンパイラ/リンカーによって追加される場合もある)。この場合、逆アセンブラしたThreadFuncを見てみると、次のようになっている。
    :004014C0    push EBP         ; entry point of ThreadFunc
    :004014C1    mov EBP, ESP
     ...
    :004014C5    call 0041550     ; this will crash the
                                  ; remote process
     ...
    :00401502    ret
    
    /GZなどの禁止されているスイッチを有効にしたことでコンパイラがデータベースCALLを追加した場合、その呼び出し命令は、ThreadFuncの先頭か終わり近くに配置される。

 どの場合でも、CreateRemoteThreadWriteProcessMemoryのテクニックを使用するときには十分な注意が必要です。特にコンパイラ/リンカーオプションには注意してください。これらのオプションのおかげでThreadFuncに呼び出しが追加されることはよくあります。

参考資料

  1. Load Your 32-bit DLL into Another Process’s Address Space Using INJLIB by Jeffrey Richter.MSJ May, 1994
  2. HOWTO: Subclass a Window in Windows 95; Microsoft Knowledge Base Article - 125680
  3. Tutorial 24: Windows Hooks by Iczelion
  4. CreateRemoteThread by Felix Kasza
  5. API hooking revealed by Ivo Ivanov
  6. Peering Inside the PE: A Tour of the Win32 Portable Executable File Format by Matt Pietrek, March 1994
  7. Intel Architecture Software Developer’s Manual, Volume 2:Instruction Set Reference

著者紹介

Robert Kuster(Robert Kuster)
海外のインターネットコムアメリカ韓国ドイツトルコ
Copyright 2008 Jupitermedia Corporation All Rights Reserved.http://www.internet.com/