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の実アドレスで修正した後のNewProcA0B0C0D0というダミー値を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の実行可能ファイルとソースファイルが含まれています。
