おわりに
最後に、ここまで触れずにいたいくつかの点をまとめておきます。
種類 | OS | プロセス |
1. フック | Win9xおよびWinNT | USER32.DLLにリンクしているプロセスのみ【1】 |
2. CreateRemoteThread +LoadLibrary | WinNTのみ【2】 | すべてのプロセス【3】(システムサービスを含む【4】) |
3. CreateRemoteThread +WriteProcessMemory | WinNTのみ | すべてのプロセス(システムサービスを含む) |
- 当然ながら、メッセージキューを持たないスレッドにはフックできません。また、
SetWindowsHookEx
はシステムサービスに対しては機能しません(USER32.DLLにリンクしている場合も同様)。 - Win9xには
CreateRemoteThread
とVirtualAllocEx
がありません(実際にはWin9xでもこれらの関数をエミュレートできますが、詳細は割愛します)。 - すべてのプロセス=Win32の全プロセス+csrss.exe
- システムサービス(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
命令が問題を引き起こします。これにより、ThreadFunc
とAfterThreadFunc
が実コードではなくJMP
命令を指すようになるからです。したがって、次の方法でThreadFunc
のサイズを計算すると、
const int cbCodeSize = ((LPBYTE) AfterThreadFunc - (LPBYTE) ThreadFunc);
実際には、それぞれThreadFunc
とAfterThreadFunc
を指す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
として宣言した関数は、インクリメンタルリンクを行った場合でも直接呼び出されるのです。これが、規則4でThreadFunc
とAfterThreadFunc
をstatic
として宣言するか、インクリメンタルリンクを無効にするよう指示している理由です(インクリメンタルリンクのその他の側面については、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
指令で切り替えることができると書かれています。しかし、このプラグマはスタックプローブに何も影響を与えないようです(マニュアルが間違っているのか、それとも私が何か別の要素を見逃しているのでしょうか?)。ともかく、CreateRemoteThread
+WriteProcessMemory
のテクニックは、短いコードを割り込ませるときにのみ使用することをお勧めします。そうすれば、ローカル変数で何バイトも消費することはまずなくなり、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)のアルゴリズムで置き換えたことになります。これは次のことを意味しています。
- Oは、最悪ケースの時間計算量を表します。
- オフセットを計算し、テーブルルックアップを行い、最終的に適切なアドレスにジャンプするためには、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
ステートメントには定数式しか指定できないのか疑問に思ったことがある人もいるでしょうが、これでその理由がわかったと思います。上記のようなアドレステーブルを作成するためには、この値がコンパイル時に確定していなければならないのです。
では、本題に戻りましょう。
アドレス0040100C
のJMP
命令に注目してください。Intelのマニュアルでは、16進のOpCode FF
について次のように説明されています。
Opcode Instruction Description FF /4 JMP r/m32 Jump near, absolute indirect, address given in r/m32
なんと、データベースJMP
は何らかの絶対アドレス指定を使用するというのです。つまり、この命令のオペランドの1つ(今回の例では0040102C
)は絶対アドレスを表すということです。これ以上言う必要があるでしょうか? リモートのThreadFunc
はswitch
ブロックのアドレステーブルが0040102C
にあると盲目的に信じ込み、間違ったロケーションにジャンプするため、リモートプロセスがクラッシュしてしまいます。
F)そもそもリモートプロセスがクラッシュするのはなぜか
リモートプロセスがクラッシュするときは、必ず次のいずれかの理由によります。
ThreadFunc
内の存在しない文字列を参照した。ThreadFunc
内の命令が絶対アドレスを使用している(具体例については付録Eを参照)。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
の先頭か終わり近くに配置される。 どの場合でも、CreateRemoteThread
+WriteProcessMemory
のテクニックを使用するときには十分な注意が必要です。特にコンパイラ/リンカーオプションには注意してください。これらのオプションのおかげでThreadFunc
に呼び出しが追加されることはよくあります。
参考資料
- Load Your 32-bit DLL into Another Process's Address Space Using INJLIB by Jeffrey Richter.MSJ May, 1994
- HOWTO: Subclass a Window in Windows 95; Microsoft Knowledge Base Article - 125680
- Tutorial 24: Windows Hooks by Iczelion
- CreateRemoteThread by Felix Kasza
- API hooking revealed by Ivo Ivanov
- Peering Inside the PE: A Tour of the Win32 Portable Executable File Format by Matt Pietrek, March 1994
- Intel Architecture Software Developer's Manual, Volume 2:Instruction Set Reference