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
パラメータが追加されています。このパラメータは、スレッドが作成されるプロセスへのハンドルを表します。CreateRemoteThread
のlpStartAddress
パラメータは、リモートプロセスのアドレス空間内のスレッドの開始アドレスを表します。この関数はリモートプロセス内にあるはずなので、ローカルなThreadFunc
へのポインタを単純に渡すことはできません。まずリモートプロセスにコードをコピーする必要があります。- 同様に、
lpParameter
によって参照されるデータはリモートプロセス内にあるはずなので、このデータもリモートプロセスにコピーする必要があります。
このテクニックの手順をまとめると次のようになります。
- リモートプロセスへのハンドルを取得します(
OpenProcess
)。 - リモートプロセスのアドレス空間内で、割り込みデータのためのメモリを割り当てます(
VirtualAllocEx
)。 - 割り当てたメモリに、初期化した
INJDATA
構造体のコピーを書き込みます(WriteProcessMemory
)。 - リモートプロセスのアドレス空間内で、割り込みコードのためのメモリを割り当てます。
- 割り当てたメモリに、
ThreadFunc
のコピーを書き込みます。 CreateRemoteThread
を使用して、リモートプロセス内のThreadFunc
を開始します。- リモートスレッドが終了するまで待機します(
WaitForSingleObject
)。 - リモートプロセスから結果を取得します(
ReadProcessMemory
またはGetExitCodeThread
)。 - 手順2と手順4で割り当てたメモリを解放します(
VirtualFreeEx
)。 - 手順6と手順1で取得したハンドルをクローズします(
CloseHandle
)。
ThreadFunc
に関しては次の規則に従ってください。
ThreadFunc
が呼び出せる関数は、kernel32.dllおよびuser32.dllに含まれているものだけです。ローカルプロセスとターゲットプロセスの両方で同じロードアドレスにあることが保証されているのは、kernel32とuser32だけだからです(付録Aを参照。なお、user32はすべてのWin32プロセスにマッピングされるとは限りません)。他のライブラリ内の関数が必要な場合は、LoadLibrary
とGetProcAddress
のアドレスを割り込みコードに渡し、残りの処理は割り込みコードで行います。何らかの理由でデータベースDLLが既にターゲットプロセスにマッピングされている場合は、LoadLibrary
の代わりにGetModuleHandle
を使用することもできます。- 静的な文字列を使用しないでください。すべての文字列は
INJDATA
経由でThreadFunc
に渡すようにします。 /GZ
コンパイラスイッチを削除してください。デバッグビルドでは、このスイッチが既定で設定されています(付録Bを参照)。ThreadFunc
とAfterThreadFunc
をstatic
として宣言するか、インクリメンタルリンクを無効にします(付録Cを参照)。ThreadFunc
内のローカル変数はページサイズ(4Kb)より小さくする必要があります(付録D を参照)。デバッグビルドでは、使用可能な4Kbのうち数十バイトが内部変数に使用されるという点に注意してください。- 4つ以上の
case
ステートメントを含むswitch
ブロックがある場合は、次のように分割するか、
ThreadFunc
内から独自のサブルーチンを呼び出したい場合は、各ルーチンを個別にリモートプロセスにコピーし、それぞれのアドレスをINJDATA
経由でThreadFunc
に提供します。ThreadFunc
は、存在しない(少なくとも自分のアドレス空間には存在しない)文字列を参照することになってしまいます。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つの関数をリモートプロセスにコピーする必要があります。
ThreadFunc
-SetWindowLong
を通じて、リモートプロセス内のコントロールを実際にサブクラス化します。NewProc
- サブクラス化されたコントロールの新しいウィンドウプロシージャです。
問題は、リモートのNewProc
にデータを渡すにはどうするか、という点です。NewProc
はコールバック関数であり、特定のガイドラインに従う必要があるので、INJDATA
へのポインタを単純に引数として渡すことができません。この問題を解決するには2とおりの方法がありますが、どちらもアセンブリ言語を使用する必要があります。ここまではできるだけアセンブラ関連の話を付録に回すよう努力してきたのですが、ここではどうしても避けられないので、しばらくお付き合いください。
ソリューション1
次の図を見てください。
リモートプロセス内ではINJDATA
がNewProc
の直前に置かれていることに注目してください。これにより、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
は特殊目的のレジスタなので、EAX
、EBX
などの汎用レジスタとは異なりプログラム的にアクセスできません。言い換えると、EIP
を参照したり、その内容を明示的に読み書きしたりするためのOpCodeがありません。しかしそれでも、JMP
、CALL
、RET
などの命令を使用してEIP
を暗黙的に変更することができます(実際、絶えず変更されています)。たとえば、IntelおよびAMDの32ビットプロセッサでサブルーチンのCALL
/RET
メカニズムがどのように機能しているかを考えてみましょう。
CALL
を通じてサブルーチンを呼び出すと、そのサブルーチンのアドレスがEIP
にロードされます。ただし、EIP
が書き換えられる前に、元の値が自動的にスタックにプッシュされます(後でリターン命令ポインタとして使用します)。サブルーチンの終了時に、RET
命令がスタックの一番上の値を自動的にEIP
にポップします。
これでCALL
とRET
によってEIP
が書き換えられることはわかりましたが、EIP
の現在値を取得するにはどうすればいいでしょうか?
ここで、CALL
がEIP
をスタックにプッシュするということを思い出してください。したがって、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
- ダミー関数の呼び出しです。この呼び出しは次の命令にジャンプし、
EIP
をスタックにプッシュするだけです。 - スタックを
ECX
にポップします。これにより、ECX
がEIP
を格納することになります。これはpop ECX
命令のアドレスを表します。 NewProc
のエントリポイントからpop ECX
命令までの「距離」が9バイトであることに注目してください。したがって、ECX
から9を引けば、NewProc
のアドレスが得られます。
こうすれば、NewProc
がどのロケーションに移動しても、常にNewProc
のアドレスを計算することができます。ただし、NewProc
のエントリポイントからpop ECX
命令までの距離は、コンパイラ/リンカオプションを変更すると変化することがあるので注意してください。したがって、この距離はリリースビルドとデバッグビルドでも異なります。それでも、ここで重要なのは、コンパイル時に正確な値を把握できるという点です。実際には次の手順を行います。
- まず関数をコンパイルします。
- 逆アセンブラを行い、正しい距離を調べます。
- 最後に、正しい距離を使用して再コンパイルします。
これが、本稿のサンプル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
となります。
ここで、次の作業を行います。
INJDATA
、ThreadFunc
、NewProc
をターゲットプロセスにコピーします。NewProc
のコードを修正し、pData
がINJDATA
の実アドレスを持つようにします。- リモートの
ThreadFunc
の実行を開始し、そのコードを通じてリモートプロセス内のコントロールをサブクラス化します。
INJDATA
のアドレス(VirtualAllocEx
の戻り値)が0x008a0000
であるとします。この場合は、NewProc
のコードを次のように修正します。558BECC745FCD0C0B0A0......8BE55DC21000
← 元のNewProc
【1】558BECC745FC00008A00......8BE55DC21000
← INJDATA
の実アドレスで修正した後のNewProc
A0B0C0D0
というダミー値をINJDATA
の実アドレスで置き換えます【2】。 【1】コンパイル済みコード内でA0B0C0D0
や008a0000
というアドレスが逆の順序で現れることを不思議に思った人もいるのではないでしょうか。これは、IntelやAMDのプロセッサがマルチバイトデータを表すときにリトルエンディアン方式を使用しているからです。リトルエンディアンとは、数値の下位バイトを下位アドレスに格納し、上位バイトを上位アドレスに格納するという方式です。
たとえばUNIXという単語を4バイトに格納する場合を考えてみましょう。ビッグエンディアン方式では、これを「UNIX」として格納します。リトルエンディアン方式では、これを「XINU」として格納します。
【2】悪意のある人物は同様の方法で実行可能ファイルのコードを書き換えます。しかし、一度メモリにロードしてしまえば、プログラムが自身のコード(実行可能ファイルの.text
セクションにあるコード)を変更することはできません(このコードは書き込み不可です)。それに対して、リモートのNewProc
はPAGE_EXECUTE_READWRITE
パーミッションを使用してメモリの一部にコピーしておいたものなので、コードを修正できます。
CreateRemoteThread+WriteProcessMemoryのテクニックはどこで使用すべきか
CreateRemoteThread
とWriteProcessMemory
を使ってコード割り込みを実現するというテクニックは、追加のDLLが必要ないため、他の方法に比べて柔軟性が高いと言えます。しかし同時に、他の方法よりも複雑で、リスクが高いという側面もあります。ThreadFunc
で何か下手なことをすると、リモートプロセスが簡単にクラッシュしてしまいます(付録Fを参照)。また、リモートのThreadFunc
をデバッグするには大変な手間がかかります。このテクニックは、限られた数の命令を割り込ませるときにのみ使用してください。ある程度長いコードを割り込ませるときは、1.または2.のテクニックを使用することをお勧めします。
本稿の最初で紹介しているダウンロードパッケージに、WinSpyおよびInjectExの実行可能ファイルとソースファイルが含まれています。