SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

japan.internet.com翻訳記事

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

プロセス割り込みのためのチュートリアル

  • X ポスト
  • このエントリーをはてなブックマークに追加

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を使用することもできます。
  2. 同様に、ThreadFunc内から独自のサブルーチンを呼び出したい場合は、各ルーチンを個別にリモートプロセスにコピーし、それぞれのアドレスをINJDATA経由でThreadFuncに提供します。
  3. 静的な文字列を使用しないでください。すべての文字列はINJDATA経由でThreadFuncに渡すようにします。
  4. これには次のような理由があります。コンパイラはすべての静的な文字列を実行可能ファイルの.dataセクションに配置し、コード内には参照(=ポインタ)だけを残します。そのため、リモートプロセス内のThreadFuncは、存在しない(少なくとも自分のアドレス空間には存在しない)文字列を参照することになってしまいます。
  5. /GZコンパイラスイッチを削除してください。デバッグビルドでは、このスイッチが既定で設定されています(付録Bを参照)。
  6. ThreadFuncAfterThreadFuncstaticとして宣言するか、インクリメンタルリンクを無効にします(付録Cを参照)。
  7. ThreadFunc内のローカル変数はページサイズ(4Kb)より小さくする必要があります(付録D を参照)。デバッグビルドでは、使用可能な4Kbのうち数十バイトが内部変数に使用されるという点に注意してください。
  8. 4つ以上のcaseステートメントを含むswitchブロックがある場合は、次のように分割するか、
  9. 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の実アドレスを持つようにします。
  3. たとえば、ターゲットプロセス内でのINJDATAのアドレス(VirtualAllocExの戻り値)が0x008a0000であるとします。この場合は、NewProcのコードを次のように修正します。
    558BECC745FCD0C0B0A0......8BE55DC21000 ← 元のNewProc【1】
    558BECC745FC00008A00......8BE55DC21000INJDATAの実アドレスで修正した後のNewProc
    つまり、A0B0C0D0というダミー値をINJDATAの実アドレスで置き換えます【2】。
  4. リモートの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の実行可能ファイルとソースファイルが含まれています。

次のページ
おわりに

この記事は参考になりましたか?

  • X ポスト
  • このエントリーをはてなブックマークに追加
japan.internet.com翻訳記事連載記事一覧

もっと読む

この記事の著者

japan.internet.com(ジャパンインターネットコム)

japan.internet.com は、1999年9月にオープンした、日本初のネットビジネス専門ニュースサイト。月間2億以上のページビューを誇る米国 Jupitermedia Corporation (Nasdaq: JUPM) のニュースサイト internet.comEarthWeb.com からの最新記事を日本語に翻訳して掲載するとともに、日本独自のネットビジネス関連記事やレポートを配信。

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

Robert Kuster(Robert Kuster)

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

  • X ポスト
  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/82 2005/11/18 18:08

おすすめ

アクセスランキング

アクセスランキング

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング