はじめに
掲題の方法を非破壊的、すなわちソースコードや実行可能モジュールのファイルを変更せずに実現するツールを作りました。より具体的には、「実行中のプロセスの任意の箇所をフックして、DLL に実装した任意のコードにジャンプして実行してから、元の処理に戻って動作を継続できる」ツールです。手前味噌ですがけっこう便利だと思います。現実的な使用場面として、パフォーマンス測定やカスタム ロギング、スレッドの競合を意図的に引き起こすためのタイミングの調整、などが考えられます。
msmania/procjack: Not Another Code Injection Toolkit
https://github.com/msmania/procjack
というわけでこの記事では、以下 2 つの実装例を紹介して Non-invasive instrumentation の便利さを伝えようと思います。残念ながら Windows の x86 と x64 限定です。あとユーザー モード限定です。なお Non-invasive instrumentation というキーワードは、私が勝手に思いついたものです。ググると、医療業界で使われている例が見つかります。
実装例 1: Google Chrome の関数のボトルネック箇所を特定する
実装例 2: Google Chrome と Microsoft Edge とでヒープ メモリ確保のパフォーマンスを比較する
Reflective DLL Injection meets Microsoft Detours
実装例紹介の前にキーワードを先に紹介すると、Non-invasive instrumentation は、Reflective DLL Injection を利用して Microsoft Detours をターゲット プロセス内で動的に実行するテクニックです。具体的には以下のようなコマンドを実行します。
> pj.exe 1248 clove.dll?200 00007FF8`02BCBED0
このコマンドは、「PID:1248 のプロセス内で新しいスレッドを作って clove.dll をロードし、序数 200 番の関数を実行せよ」という命令です。ロードされた clove.dll の関数が、コマンドで指定されているアドレス 00007FF8`02BCBED0 をフックし、DLL 内で予め定義されたカスタム コードを差し込みます。clove.dll における序数 200 番の関数はサンプルとして実装済の関数で、内容は後述します。新たな instrumentation を実装する場合は、Clove のコードをコピーして新しい DLL を作るか、Clove 内に新たなエクスポート関数を追加することができます。
この例から分かるように、instrumentation は、DLL をプロセスにインジェクトするためのツール ProcJack (pj.exe) と、インジェクトされた後にコードのフックを担当する Clove (clove.dll) の 2 つのモジュールから構成されています。以下、それぞれのモジュールを簡単に紹介します。
ProcJack
ProcJack は、動作中のプロセスに対して外部から DLL をロードさせ、任意のエクスポート関数を実行するためのコード インジェクション ツールです。過去にブログに書きました。
Reflective DLL Injection - すなのかたまり
https://msmania.github.io/2015/04/26/reflective-dll-injection.html
基本原理は、あるハッカーが Reflective DLL Injection と名付けた有名な方法です。オリジナルのReflective DLL Injection では、VirtualAllocEx と WriteProcessMemory で対象プロセスにデータを送り込み、CreateRemoteThread で LoadLibrary を起点としたスレッドを開始する方法です。DLL のエントリ ポイントである DllMain に実行したいコードを書いておくことで、ターゲット内での実行を可能にします。
ProcJack では、DllMain の代わりにエクスポート関数を呼び出せるようにし、さらにコマンドから任意の文字列をエクスポート関数に渡せるようにしています。これによって、インジェクトしたい複数の処理を 1 つの DLL に入れて使い分けることができます。
Clove
Clove は、Microsoft Detours を使ってプロセス内の任意の箇所をフックする処理を、ProcJack がインジェクトできるように DLL として実装したものです。Detours についても過去にブログを書きました。
Microsoft Research Detours Express 3.0 - すなのかたまり
https://msmania.github.io/2014/11/29/microsoft-research-detours-express-3-0.html
Detours の当時のバージョンは 3.0 で、ソースコードこそ公開されていたものの、そのままだとコンパイル エラーが出たり、無償版は 64bit で使えなかったりしました。久々にググってみると、GitHub にバージョン 4.01 が MIT ライセンスで、しかも x64 や ARM でも使えるようになっていました。時代の変遷を感じます。
Detours - Microsoft Research
https://www.microsoft.com/en-us/research/project/detours/
Detours の動作原理は、インポートテーブルの書き換えなどという生ぬるい手段ではなく、フックする箇所の命令を逆アセンブルし、トランポリン関数として別の場所にうまいこと退避させ、元の箇所にフック先への jmp 命令を埋め込むという巧妙な方法です。Detours Wiki に図があります。
OverviewInterception · Microsoft/Detours Wiki
https://github.com/microsoft/detours/wiki/OverviewInterception
Detours の基本的な使い方は、関数全体をフックすることです。例えば Detours の simple というサンプルで実現しているのは、SleepEx の呼び出しをフックして TimedSleepEx という関数を実行し、その中でオリジナルの SleepEx と追加の処理が実行するというものです。
Clove で実現できるようにしたのは、関数の先頭をフックして処理を置き換えるのではなく、関数の途中も含めた任意の箇所で新しいコードを実行し、その後でオリジナルの処理を継続することです。これはフックするコードを ret で終えるのではなく、トランポリン関数への jmp を埋め込むことで実現できるようにしました。Clove が使うための API を Detours に追加する PullRequest を投げたのですが、今のところ音沙汰がありません。
Add DetourAllocateRegionWithinJumpBounds API by msmania · Pull Request #27 · Microsoft/Detours
https://github.com/Microsoft/Detours/pull/27
ケーススタディ
冒頭で挙げた 2 つの実装例を紹介します。どちらの実装例も GitHub のリポジトリに含まれているので、手元ですぐに試すことができます。
実装例 1: Google Chrome の関数のボトルネック箇所を特定する
実装例 2: Google Chrome と Microsoft Edge とでヒープ メモリ確保のパフォーマンスを比較する
実装例 1: Google Chrome の関数のボトルネック箇所を特定する
新たに実装した関数が特定の状況下で妙に遅くなって、それをデバッグする場面は少なからずあると思います。そんなとき、Non-invasive instrumentation を使うとアドレス単位でボトルネックを調べることができます。また、複数の箇所における実行時間を定量的に比較することにも使えます。
従来の方法
Windows でパフォーマンスに関するトラブルがあった場合、WPA (= Windows Performance Analyzer) を試すことが多いと思います。
Exercise 3 - Understand Critical Path and Wait Analysis | Microsoft Docs
https://docs.microsoft.com/en-us/windows-hardware/test/wpt/optimizing-performance-and-responsiveness-exercise-3
上記ページに説明がある通り、WPA はコンテキスト スイッチのタイミングで ETW を記録し、前回のコンテキスト スイッチからの CPU 消費時間をプロセス、スレッド、コールスタック毎に分析できるという優れたツールです。ただこのツール、OS 全体から怪しいスレッドや関数の候補を絞り込むのには便利なのですが、いかんせんゴールがコールスタックなので、関数のどの部分がどれだけ遅いかまでは分析できません。例えば関数にループが複数あって、そのどれがが極端に遅い場合、そのループを特定することはできません。
あと、WPA がシンボルをロードする機能が原因不明で動かないことが多々あり、さらに動いても妙に遅いのでけっこうストレスが溜まるという理不尽な理由もあります。序盤は強いけど、終盤は弱いのです。
最終手段は、対象プログラムのソース コードを変更 (= instrument) し、計測したい箇所の前後の tick の差分を取ってそれをログに出力させるようなコードを追加する方法です。これは確実ですが手軽さが皆無なので、できれば避けたいです。例えば対象が Chromium のように巨大だったら、(潤沢なビルド環境を持たない私のような一般人は) 試行錯誤のために何回かビルドするだけで一日が終わりそうですし、そもそも対象プログラムのソースコードが無かったり、ビルド環境が無い場合もあります。また、コンパイラーの最適化の動作によって、オリジナルのプログラムと結果が一致しない可能性も考えられます。
Non-invasive instrumentation
ProcJack + Clove を使うと、プログラムを再ビルドしなくても instrumentation を動的に実現できます。発想は、以下のような 2 つの関数をボトルネック候補の開始地点と終了地点にそれぞれ埋め込んで、任意の範囲の実行時間を計測しようというものです。実際に Clove が埋め込むコードは C/C++ 関数のように ret で終わるのではなく、トランポリン関数へ jmp する必要があり、さらに rax など C/C++ の呼び出し規約では必要のないレジスターの復元が必要になるため、少なくとも処理の一部はアセンブリで書かないといけません。あれ、そこまで手軽じゃない・・・?
#include <cstdint>
#include <intrin.h>
uint64_t Counter_Local = 0;
uint64_t Counter_Total = 0;
void Measurement_Start() {
Counter_Local = __rdtsc();
}
void Measurement_End() {
Counter_Total += (__rdtsc() - Counter_Local);
}
実際に試してみましょう。パフォーマンスの計測は、Clove.dll の 100 番の関数です。ターゲットは何でもいいのですが、notepad.exe なんかだとあまりにもサンプル臭くて現実味に欠けるので、あえて大物の Google Chrome を使うことにしました。Chrome.exe はサンドボックス環境で動いているので、インジェクションを成功させるためには後述する方法で CIG という OS の保護機能を解除する必要があります。
パフォーマンスの測定対象は、条件分岐が少ない方が結果が明確なので、call が分岐なしで連続する chrome_child!blink::Document::UpdateStyleAndLayoutTree を使うことにします。この関数はレイアウト ツリーを更新する関数で、例えば JavaScript で scrollLeft や clientHeight といったプロパティを取得しようとしたときにレイアウトを確定するために呼ばれます。
ソースコードはここにあります。
https://chromium.googlesource.com/chromium/src.git/+/70.0.3503.0/third_party/blink/renderer/core/dom/document.cc#2123
測定は、関数を適当な複数の範囲に区切り、それぞれの範囲で消費される CPU サイクルを計測することで行います。まずはプロローグからエピローグの範囲で、関数呼び出しを中心に以下の 10 個の範囲に区切ることにします。このへんのアドレスの洗い出し作業はけっこう面倒かもしれません。
Address | Instruction | Comment |
---|---|---|
00007ffd`f81e2520 | push rbp | Function prologue |
00007ffd`f81e2672 | call chrome_child!blink::DocumentAnimations::UpdateAnimationTimingIfNeeded | |
00007ffd`f81e2677 | cmp byte ptr [rsi+201h],0 | Immediately after the call to UpdateAnimationTimingIfNeeded |
00007ffd`f81e269b | call chrome_child!blink::Document::UpdateUseShadowTreesIfNeeded | |
00007ffd`f81e26a3 | call chrome_child!blink::Node::UpdateDistributionInternal | |
00007ffd`f81e26ab | call chrome_child!blink::Document::UpdateActiveStyle | |
00007ffd`f81e26b3 | call chrome_child!blink::Document::UpdateStyleInvalidationIfNeeded | |
00007ffd`f81e26bb | call chrome_child!blink::Document::UpdateStyle | |
00007ffd`f81e26c0 | mov rax,qword ptr [rsi+7A8h] | Immediately after the call to UpdateStyle |
00007ffd`f81e2848 | add rsp,0F0h | Function epilogue |
以下のコマンドを実行することで、上記のように決めた範囲のアドレスを一気にフックできます。6848 はレンダー プロセスの PID です。
D:\pj\amd64\pj.exe 6848 C:\Windows\fonts\clove.dll?100 00007ffd`f81e2520-00007ffd`f81e2672-00007ffd`f81e2677-00007ffd`f81e269b-00007ffd`f81e26a3-00007ffd`f81e26ab-00007ffd`f81e26b3-00007ffd`f81e26bb-00007ffd`f81e26c0-00007ffd`f81e2848
後述しますが、chrome.exe プロセスは Untrusted レベルで動作しているため、アクセスできるファイルの場所が大きく制限されています。その対策として、数少ない読み取り可能な場所である C:\windows\fonts に clove.dll をコピーして pj.exe を実行しています。フックがうまくいくと、デバッガー コンソールに以下のようなログが出力されます。
ModLoad: 00007ffe`140a0000 00007ffe`14148000 C:\Windows\fonts\clove.dll
Detouring: 00007FFDF81E2520 --> 00007FFDB81E0000 (trampoline:00007FFDB81D0120)
Detouring: 00007FFDF81E2672 --> 00007FFDB81E002C (trampoline:00007FFDB81D0180)
Detouring: 00007FFDF81E2677 --> 00007FFDB81E0084 (trampoline:00007FFDB81D01E0)
Detouring: 00007FFDF81E269B --> 00007FFDB81E00DC (trampoline:00007FFDB81D0240)
Detouring: 00007FFDF81E26A3 --> 00007FFDB81E0134 (trampoline:00007FFDB81D02A0)
Detouring: 00007FFDF81E26AB --> 00007FFDB81E018C (trampoline:00007FFDB81D0300)
Detouring: 00007FFDF81E26B3 --> 00007FFDB81E01E4 (trampoline:00007FFDB81D0360)
Detouring: 00007FFDF81E26BB --> 00007FFDB81E023C (trampoline:00007FFDB81D03C0)
Detouring: 00007FFDF81E26C0 --> 00007FFDB81E0294 (trampoline:00007FFDB81D0420)
Detouring: 00007FFDF81E2848 --> 00007FFDB81E02EC (trampoline:00007FFDB81D0480)
[102c] Waiting..
結果の出力は、clove.dll の序数 3 の関数を呼び出すことで行います。
D:\MSWORK>D:\pj\amd64\pj.exe 6848 C:\Windows\fonts\clove.dll?3
Hijacking: PID=1ac0 (WIN64) TID=1540 VM=00000218DE3C0000
結果はデバッガーのコンソールに以下のように表示されます。フックしたアドレスの各範囲と、その右の数値がその範囲を実行するのにかかった CPU のクロック数、callcount は範囲の開始アドレスと終了アドレスが実行された回数です。total ticks は、各範囲のクロック数の合計です。まだブラウザーで何もしていないのでクロック数や呼び出し回数は全て 0 になっています。
[MeasurementChain] total ticks: 0
00007FFDF81E2520-00007FFDF81E2672: 0 (callcount: 0 0)
00007FFDF81E2672-00007FFDF81E2677: 0 (callcount: 0 0)
00007FFDF81E2677-00007FFDF81E269B: 0 (callcount: 0 0)
00007FFDF81E269B-00007FFDF81E26A3: 0 (callcount: 0 0)
00007FFDF81E26A3-00007FFDF81E26AB: 0 (callcount: 0 0)
00007FFDF81E26AB-00007FFDF81E26B3: 0 (callcount: 0 0)
00007FFDF81E26B3-00007FFDF81E26BB: 0 (callcount: 0 0)
00007FFDF81E26BB-00007FFDF81E26C0: 0 (callcount: 0 0)
00007FFDF81E26C0-00007FFDF81E2848: 0 (callcount: 0 0)
ここまでで準備ができたので、レイアウトの更新が発生するような操作を実際にブラウザー上で行ない、再度結果を表示させます。レイアウトの更新は、マウス カーソルをページ上で適当に動かすだけでもいいですし、ハイパーリンクをクリックしたり F5 更新をしてもいいです。今度は結果は以下のようになりました。
[MeasurementChain] total ticks: 49720220
00007FFDF81E2520-00007FFDF81E2672: 211704 (callcount: 250 29)
00007FFDF81E2672-00007FFDF81E2677: 696406 (callcount: 29 29)
00007FFDF81E2677-00007FFDF81E269B: 9168 (callcount: 29 29)
00007FFDF81E269B-00007FFDF81E26A3: 11122 (callcount: 29 29)
00007FFDF81E26A3-00007FFDF81E26AB: 28517 (callcount: 29 29)
00007FFDF81E26AB-00007FFDF81E26B3: 7787139 (callcount: 29 29)
00007FFDF81E26B3-00007FFDF81E26BB: 26144 (callcount: 29 29)
00007FFDF81E26BB-00007FFDF81E26C0: 39574513 (callcount: 29 29)
00007FFDF81E26C0-00007FFDF81E2848: 1375507 (callcount: 29 250)
callcount の数値が一致していない範囲があります。これは、開始アドレスは実行されたものの終了アドレスが実行されなかったことを意味しており、実行時間を正しく計測できていません。上の例では、関数自体は 250 回実行されたものの、00007FFDF81E2672-00007FFDF81E2677 の範囲である UpdateAnimationTimingIfNeeded 関数の呼び出しを実行せずにエピローグまで early return するコードパスがあることを示唆しています。
このままだと結果が分析しづらいため、プロローグからエピローグまでの関数全体の範囲と、関数呼び出しが行われる関数中盤の範囲との 2 つに計測範囲を分割することにします。つまり、コマンドの実行を 2 回に分けます。
新しい計測を始める前に、現在のフックを解除する必要があります。これは序数 2 番の関数を実行します。
D:\MSWORK>D:\pj\amd64\pj.exe 6848 C:\Windows\fonts\clove.dll?2
Hijacking: PID=1ac0 (WIN64) TID=0af8 VM=00000218DE3B0000
デバッガーに以下のような出力が表示され、clove.dll がプロセスからアンロードされます。
Sending a signal to terminate shimming..
Cleanup is done. Goodbye!
では新しい計測を、以下の 2 つのコマンドを続けて実行して開始します。
D:\MSWORK>D:\pj\amd64\pj.exe 6848 C:\Windows\fonts\clove.dll?100 00007ffd`f81e2520-00007ffd`f81e2848
Hijacking: PID=1ac0 (WIN64) TID=0558 VM=00000218DE270000
D:\MSWORK>D:\pj\amd64\pj.exe 6848 C:\Windows\fonts\clove.dll?100 00007ffd`f81e2672-00007ffd`f81e2677-00007ffd`f81e269b-00007ffd`f81e26a3-00007ffd`f81e26ab-00007ffd`f81e26b3-00007ffd`f81e26bb-00007ffd`f81e26c0
Hijacking: PID=1ac0 (WIN64) TID=1514 VM=00000218E0F90000
デバッガーの出力は以下の通りです。
ModLoad: 00007ffe`13fe0000 00007ffe`14088000 C:\Windows\fonts\clove.dll
Detouring: 00007FFDF81E2672 --> 00007FFDB81E0000 (trampoline:00007FFDB81D0120)
Detouring: 00007FFDF81E2677 --> 00007FFDB81E002C (trampoline:00007FFDB81D0180)
Detouring: 00007FFDF81E269B --> 00007FFDB81E0084 (trampoline:00007FFDB81D01E0)
Detouring: 00007FFDF81E26A3 --> 00007FFDB81E00DC (trampoline:00007FFDB81D0240)
Detouring: 00007FFDF81E26AB --> 00007FFDB81E0134 (trampoline:00007FFDB81D02A0)
Detouring: 00007FFDF81E26B3 --> 00007FFDB81E018C (trampoline:00007FFDB81D0300)
Detouring: 00007FFDF81E26BB --> 00007FFDB81E01E4 (trampoline:00007FFDB81D0360)
Detouring: 00007FFDF81E26C0 --> 00007FFDB81E023C (trampoline:00007FFDB81D03C0)
[1514] Waiting..
Detouring: 00007FFDF81E2520 --> 00007FFDB81E0294 (trampoline:00007FFDB81D0420)
Detouring: 00007FFDF81E2848 --> 00007FFDB81E02C0 (trampoline:00007FFDB81D0480)
前回と同様、レイアウト更新が発生する操作を行ない、序数 3 番の関数を実行して結果を見ます。今度は以下のような結果が得られました。
[MeasurementChain] total ticks: 44568167
00007FFDF81E2672-00007FFDF81E2677: 2787346 (callcount: 75 75)
00007FFDF81E2677-00007FFDF81E269B: 24438 (callcount: 75 75)
00007FFDF81E269B-00007FFDF81E26A3: 29281 (callcount: 75 75)
00007FFDF81E26A3-00007FFDF81E26AB: 60680 (callcount: 75 75)
00007FFDF81E26AB-00007FFDF81E26B3: 1961842 (callcount: 75 75)
00007FFDF81E26B3-00007FFDF81E26BB: 64567 (callcount: 75 75)
00007FFDF81E26BB-00007FFDF81E26C0: 39640013 (callcount: 75 75)
[MeasurementChain] total ticks: 46541365
00007FFDF81E2520-00007FFDF81E2848: 46541365 (callcount: 246 246)
callcount の数値を見ると、関数全体は 246 回実行されたものの、UpdateAnimationTimingIfNeeded から始まる関数の呼び出しは 75 回しか実行されていません。したがって、残りの 171 回はその前の return 文で early return したことが分かります。total ticks の数値を見ると、ともに約 45M クロックぐらいを示しているので、early return したときの実行時間は誤差の範囲で、関数中盤を 75 回実行するのにかかったクロック数が、early return した分も含めた 246 回の関数全体の実行クロック数とほぼ同じであることが分かります。
中盤の関数呼び出しの内訳をみると、明らかに 00007FFDF81E26BB-00007FFDF81E26C0 のコード範囲がボトルネックであり、関数全体の実行時間の 89% (=39640013 / 44568167) を占めています。前述の表から、このアドレス範囲は chrome_child!blink::Document::UpdateStyle 呼び出しです。
よって、chrome_child!blink::Document::UpdateStyleAndLayoutTree の実行のうち、89% はchrome_child!blink::Document::UpdateStyle が占めていることが分かりました。もしこの関数のパフォーマンス向上が求められる場合、UpdateStyle の処理を改善することが最優先になります。これが結論です。
実装例 2: Google Chrome と Microsoft Edge とでヒープ メモリ確保のパフォーマンスを比較する
ある操作を行ったときに、ある関数がどのスレッドからどんなパラメーターで何回ぐらい呼ばれるのかを知りたい時があります。定番の方法は、ソースコードにトレース関数を書いておき、限定された状況でのみトレースを取るようにすることです。これまでの経験では、リリース前の開発時に書かれたトレースは、リリース後のトラブルシューティングではポイントがずれていてあまり有益でなかったり、詳細過ぎて解釈が難しい、もしくはログの量が多すぎてそのせいでプログラムがまともなスピードで動かなかったりなどの理由で、結局使わないことが多いです。デバッグ ビルドのときだけ有効というトレースも多いですが、ビルドし直すのはけっこう手間ですし、そもそもデバッグ ビルドはリリース ビルドとの違いが多すぎて使い物にならなかったりします。
Non-invasive instrumentation の 2 つめの使用例として、Chrome と Edge とでヒープからメモリを確保している場所をフックし、そのときのスレッド ID、確保するサイズ、リターン アドレス、そして確保にかかった CPU のクロック数を記録し、そのパターンの比較を行いました。
ログを仕込むアドレスは以下の 2 箇所を使います。
フックする関数 | 確保サイズの場所 | |
---|---|---|
Chrome | chrome_child!blink::Node::AllocateObject | 第一引数 |
Edge | edgehtml!MemoryProtection::HeapAllocClear<1> | 第一引数 |
トレースを取るための clove.dll の関数は 200 番で、対象のアドレスを一つパラメーターとして指定します。パフォーマンス計測の実装を簡単にするため、関数の先頭でのみ動くようになっています。したがって、以下のコマンド例で指定されているアドレスは、それぞれのヒープ関数の先頭アドレスです。途中のアドレスにフックすると、おそらくプロセスがクラッシュします。
先に Chrome からデータを取ります。Chrome を起動して、適当なページを開き、以下のコマンドを実行してフックを行ないます。処理が重い cnn.com をテストページとして使っています。
D:\MSWORK> D:\pj\amd64\pj.exe 6024 C:\Windows\fonts\clove.dll?200 00007ffd`f817c390
Hijacking: PID=1788 (WIN64) TID=04a0 VM=000001EA8C120000
適当な操作を行ってデータを取ります。F5 更新やスクロール、リンクのクリックなど何でもいいです。操作が終わったら、前回のケースと同様 3 番の関数をパラメーターなしで実行し、デバッガー上に結果を表示させます。以下のような表示が得られました。
[FunctionTracePack 00007FFDF817C390] 7992 calls (7992 records stored at 000001EA92B49060 000001EA92BA6AE0-1)
このログは、chrome_child!blink::Node::AllocateObject 関数が 7992 回呼ばれ、プロセス内の 000001EA92B49060 のアドレスから始まる領域に 7992 回分のトレースデータが連続して保存されていることを意味します。
デバッガー コンソールには件数とレコードの保存先アドレスが表示されるだけで、データの分析は生のデータをファイルに保存してから自力でやる必要があります。データをファイルに保存するぐらいの機能は clove.dll に実装してもいいのですが、Edge のようにローカル ファイル システムへのアクセスが制限されているプロセスがあり汎用的なものが作れないため、今のところ実装していません。そこで、デバッガーの .writemem コマンドを使ってファイルを保存します。先ほどのデバッガー コンソールに表示されている場所のメモリを .writemem コマンドの保存範囲として指定します。
0:003> .writemem D:\MSWORK\chrome-oilpan-cnn 000001EA92B49060 000001EA92BA6AE0-1
Writing 5da80 bytes.....................................................................
0:003> .dump /ma D:\MSWORK\chrome-cnn.dmp
Creating D:\MSWORK\chrome-cnn.dmp - mini user dump
QuerySystemMemoryInformation failed, 0x80004001
QueryProcessVmCounters failed, 0x80004001
Dump successfully written
後からリターン アドレスなどを分析できるように、ついでにプロセスダンプも取得しておきます。
Chrome はこれで終わりです。次に Edge からデータを取得します。Edge でも同様に cnn.com を開き、コンテンツプロセスに対して 200 番の関数を実行します。その後、適当な操作を行ってから 3 番の関数を実行して結果をデバッガー コンソールに表示させます。結果はこうなりました。まさかの 10 万件越え。現在の clove.dll の実装では、100 万件分のレコードの保存領域を予め確保するようになっているので、ツール的には 10 万程度なら余裕です。
[FunctionTracePack 00007FFE0835BED0] 114016 calls (114016 records stored at 0000027CF3F00020 0000027CF4438220-1)
Chrome のときと同様、表示されているアドレスの範囲を .writemem でファイルに保存し、かつプロセス ダンプを取得します。
0:032> .writemem D:\MSWORK\edge-heap-cnn 0000027CF3F00020 0000027CF4438220-1
Writing 538200 bytes..
0:032> .dump /ma D:\MSWORK\edge-cnn.dmp
Creating D:\MSWORK\edge-cnn.dmp - mini user dump
QuerySystemMemoryInformation failed, 0x80004001
QueryProcessVmCounters failed, 0x80004001
Dump successfully written
保存したデータはバイナリ データで、フォーマットは clove!FunctionTracePack::Record 構造体の std::vector になっています。例えば上記 Edge の一件目のデータは、デバッガーから以下のように確認できます。
0:032> dt clove!FunctionTracePack::Record 0000027CF3F00020
+0x000 tid_ : 0x1144
+0x008 ticks_ : 0x16f0
+0x010 ret_ : 0x00007ffe`080f5aa6 Void
+0x018 args_ : [3] 0x00000000`000000b8 Void
データは取れたので、加工していろいろ遊んでみましょう。R だとバイナリ ファイルを読み取るのが面倒なので、Python でテキスト データに変換するスクリプトを書きました。
import struct
import sys
import numpy as np
def read32(bstr, offset):
return struct.unpack('I', bstr[offset:offset+4])[0]
def read64(bstr, offset):
return struct.unpack('Q', bstr[offset:offset+8])[0]
def main():
filename = sys.argv[1] if len(sys.argv) >= 2 else 'NOFILE'
linesize = 0x30
offset_tid = 0x00
offset_clocks = 0x08
offset_return = 0x10
offset_size = 0x18
with open(filename, 'rb') as f:
data = f.read()
n = int(len(data) / linesize)
threads = np.ndarray(n, dtype=int)
sizes = np.ndarray(n, dtype=int)
clocks = np.ndarray(n, dtype=int)
returns = []
for i in range(0, n):
threads[i] = read32(data, i * linesize + offset_tid)
clocks[i] = read64(data, i * linesize + offset_clocks)
sizes[i] = read64(data, i * linesize + offset_size)
returns.append(hex(read64(data, i * linesize + offset_return))[2:])
with open(filename + '.txt', 'w') as f:
_ = f.write('TID,AllocSize,Clocks,Return\n')
for i in range(0, n):
_ = f.write('%d,%d,%d,%s\n'
% (threads[i], sizes[i], clocks[i], returns[i]))
if __name__ == '__main__':
main()
デバッガーから保存した 2 つのバイナリ ファイルをそれぞれ変換します。元のファイル名に .txt をつけたテキストファイルが生成されます。
$ python clove.py chrome-oilpan-cnn
$ python clove.py edge-heap-cnn
$ head edge-heap-cnn.txt
TID,AllocSize,Clocks
4420,184,5872,7ffe080f5aa6
4420,112,5364,7ffe080f650e
4420,184,6426,7ffe080f5aa6
4420,112,4130,7ffe080f650e
4420,184,4310,7ffe080f5aa6
4420,112,2584,7ffe080f650e
4420,184,5270,7ffe080f5aa6
4420,112,2796,7ffe080f650e
4420,184,14916,7ffe080f5aa6
これでデータが扱いやすくなったので、分析は R で行うことにします。とりあえずデータフレームにロードします。
setwd('D:\\MSWORK')
chrome <- read.table('chrome-oilpan-cnn.txt', header = TRUE, sep = ',')
edge <- read.table('edge-heap-cnn.txt', header = TRUE, sep = ',')
まずはパフォーマンスの傾向を見るため、Clocks の部分をヒストグラムにしてみます。極端にクロック数が大きいデータは除外して、5000 クロック以内のデータに絞ります。
par(mfrow=c(2,1))
hist(subset(chrome, Clocks < 5000)$Clocks, breaks=seq(0,5000,200))
hist(subset(edge, Clocks < 5000)$Clocks, breaks=seq(0,5000,200))
Chrome の関数は、パフォーマンスが速くなるにつれ頻度が上がっています。おそらくほとんどの場合で O(1) の Fast Path が実行されているからだと考えられます。イレギュラーなケースは、もしかすると VirtualAlloc で Region を OS から確保してきたか、スイープ処理が走ったからかもしれません。
Edge は、Chrome ほど綺麗なパターンになっていません。ただし、一方的に Edge の設計が悪いと決めつけることはできません。というのも、今回トレースを取得した関数の性質に違いがあるからです。
Chrome で使った chrome_child!blink::Node::AllocateObject では、確保するオブジェクトは DOM ノードに限られており、Oilpan の特定の Arena だけを使います。したがって、DOM ノード以外のオブジェクト確保/解放の影響は一切受けません。一方 Edge の edgehtml!MemoryProtection::HeapAllocClear<1> は、その名前から DOM ノードの要素に限らず、汎用的なオブジェクトの領域を確保するのに使われているはずです。このことは、同じような操作をしたのに Chrome の関数の実行回数は7992、Edge は 114016 というように大きな違いがあったことからも推測できます。
試しに、各レコードが実行されたスレッドの ID を見てみます。
> unique(chrome$TID)
[1] 4696
> unique(edge$TID)
[1] 4420 2832 5900 5168 4512 4548 800 312 4048 6580 6652 5500 5968 5780 6648
[16] 6864 5872 5252 5848 4820 4680 3648 5476 2012 4472 3500 1960 1972 1772 6284
[31] 32 5904 5076
Chrome の DOM ノード生成は全て 1 スレッドで処理されているのに対し、Edge は 33 スレッドが関与しており、スレッドの排他制御などで待ち時間が発生してパフォーマンスが低下している可能性が考えられます。詳細は分かりませんが、Chrome の場合は Site Isolation が有効であり、今回トレースを取得したプロセスが cnn.com だけを処理していたのに対し、Edge のプロセスは複数のサイトを複数スレッドで同時に処理していた可能性もあります。その点でも Edge は不利です。
次に、確保したサイズの傾向を見てみます。サイズでも極端に大きな値が含まれているので、それらを除外し、500 バイト以下の領域だけをグラフにしてみました。
par(mfrow=c(2,1))
hist(subset(chrome, AllocSize < 500)$AllocSize, breaks=seq(0,500,20))
hist(subset(edge, AllocSize < 500)$AllocSize, breaks=seq(0,500,20))
面白いことに、Chrome の方は DOM ノード限定で Edge は汎用であるにも関わらず、同じようなパターンが現れました。100 バイトのあたりにピークがあり、200 のあたりにもう一つのピークがあります。リターンアドレスを使って、それぞれ何のオブジェクトを作っているのかを推測することもできそうです。
リターン アドレスを見ると、どこからの関数呼び出しがあったのかをアセンブリ言語レベルで特定できます。まずは頻度分析をして棒グラフを作ってみます。
freq_table.chrome <- sort(table(chrome$Return), decreasing=TRUE)
freq_table.edge <- sort(table(edge$Return), decreasing=TRUE)
par(mfrow=c(2,1))
barplot(freq_table.chrome)
barplot(freq_table.edge)
このグラフからは特に何も読み取れなさそうです。では最後に、頻度が多かった呼び出し箇所はどこだったのかを特定してみます。Top 2 を見ます。
> head(freq_table.chrome / sum(freq_table.chrome), n = 2)
7ffdf8405a29 7ffdf844bd05
0.1705455 0.1338839
> head(freq_table.edge / sum(freq_table.edge), n = 2)
7ffe0847260b 7ffe080f630e
0.1063710 0.0932413
Top 2 で Chrome は全体の 30%、Edge は 20% を占めています。アドレスからシンボル名を見るには、先ほど取得しておいたプロセス ダンプを使います。まずは Chrome から。
0:003> ub 7ffdf8405a29 l1
chrome_child!blink::Text::Create+0x14:
00007ffd`f8405a24 e86769d7ff call chrome_child!blink::Node::AllocateObject (00007ffd`f817c390)
0:003> ub 7ffdf844bd05 l1
chrome_child!blink::HTMLDivElement::Create+0x10:
00007ffd`f844bd00 e88b06d3ff call chrome_child!blink::Node::AllocateObject (00007ffd`f817c390)
1 位はテキスト ノード、2 位が DIV エレメントでした。これはとても納得のいく結果です。CNN のページの 17% はテキスト ノード、13% は DIV エレメントで構成されていると言えるかもしれません。
次に Edge を見ます。
0:032> ub 7ffe0847260b l1
edgehtml!Tree::ElementNode::GetComputedStyle+0x42:
00007ffe`08472606 e8c598eeff call edgehtml!MemoryProtection::HeapAllocClear<1> (00007ffe`0835bed0)
0:032> ub 7ffe080f630e l1
edgehtml!CSCASerializationContext::CloneStreamFromVar+0x39:
00007ffe`080f6309 e8c25b2600 call edgehtml!MemoryProtection::HeapAllocClear<1> (00007ffe`0835bed0)
これは名前からは判断がつきません。GetComputedStyle の方は CSS 関連な感じはしますが、CSCASerializationContext はさっぱり見当がつきません。さらに調べるとすれば、ライブデバッグしてコールスタックを見る、などの方法が思いつきます。
以上、Non-invasive instrumentation を使って関数のトレースを取得する例について紹介しました。今回の例よりも、より比較しやすい関数をチョイスすれば、もう少し遊べるデータが取れると思います。Chrome で PartitionAlloc と Oilpan とを比較してみても面白そうです。
(余談) パブリック シンボル を使った edgehtml.dll のデバッグ
ところで、そもそもソースコードが公開されていない edgehtml のヒープってどこにあるんだ、って話ですが、edgehtml!CElement::CElement みたいなコンストラクターで適当に止めて、その前の処理を見ると分かります。こんな感じ。
0:018> g
Breakpoint 0 hit
edgehtml!CElement::CElement:
00007ffe`28532bb0 48895c2408 mov qword ptr [rsp+8],rbx ss:000000e9`ad9fcac0=000001fd78d13040
0:018> kL4
Child-SP RetAddr Call Site
000000e9`ad9fcab8 00007ffe`285ea56c edgehtml!CElement::CElement
000000e9`ad9fcac0 00007ffe`284a035a edgehtml!CHeadElement::CreateElement+0x3c
000000e9`ad9fcaf0 00007ffe`284a026f edgehtml!CreateElement+0x7a
000000e9`ad9fcb40 00007ffe`285e4686 edgehtml!CHtml5TreeConstructor::InsertAnHTMLElement+0x5f
0:018> ub 00007ffe`285ea56c l10
edgehtml!CHeadElement::CreateElement+0x5:
00007ffe`285ea535 57 push rdi
00007ffe`285ea536 4883ec20 sub rsp,20h
00007ffe`285ea53a bbd8000000 mov ebx,0D8h
00007ffe`285ea53f 488bfa mov rdi,rdx
00007ffe`285ea542 8bcb mov ecx,ebx
00007ffe`285ea544 e88719e0ff call edgehtml!MemoryProtection::HeapAllocClear<1> (00007ffe`283ebed0)
00007ffe`285ea549 8bd3 mov edx,ebx
00007ffe`285ea54b 488bc8 mov rcx,rax
00007ffe`285ea54e e80d10fcff call edgehtml!Abandonment::CheckAllocationUntyped (00007ffe`285ab560)
00007ffe`285ea553 488bd8 mov rbx,rax
00007ffe`285ea556 4885c0 test rax,rax
00007ffe`285ea559 7429 je edgehtml!CHeadElement::CreateElement+0x54 (00007ffe`285ea584)
00007ffe`285ea55b 41b835000000 mov r8d,35h
00007ffe`285ea561 488bd7 mov rdx,rdi
00007ffe`285ea564 488bc8 mov rcx,rax
00007ffe`285ea567 e84486f4ff call edgehtml!CElement::CElement (00007ffe`28532bb0)
edgehtml!MemoryProtection::HeapAllocClear<1> とかいうのがそれっぽいですね。その関数の呼び出し前のコードを見ると、0D8h という即値が ebx を経由して ecx に渡されており、おそらくこれが edgehtml!CHeadElement というクラスのサイズでしょう。C++ でオブジェクトを new するコードは、アセンブリで見るとヒープから固定サイズを確保してコンストラクターを呼び出すコードにコンパイルされるからです。
念のため、edgehtml!MemoryProtection::HeapAllocClear<1> にブレークポイントを設定してステップ オーバーを繰り返してみます。
0:018> g
Breakpoint 0 hit
edgehtml!MemoryProtection::HeapAllocClear<1>:
00007ffe`283ebed0 4053 push rbx
0:018> r
rax=00007ffe284dce90 rbx=0000020579930020 rcx=0000000000000160
rdx=0000020579930020 rsi=0000020579930020 rdi=00007ffe291631a0
rip=00007ffe283ebed0 rsp=000000e9ad9fca88 rbp=000000e9ad9fcb50
r8=0000020579930020 r9=000001fd27f2aae0 r10=00000fffc509b9d2
r11=0000000000040000 r12=000000e9ad9fcb58 r13=000001fd28a43800
r14=000001fd201b06b8 r15=000001fd27f42a00
iopl=0 nv up ei pl nz na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
edgehtml!MemoryProtection::HeapAllocClear<1>:
00007ffe`283ebed0 4053 push rbx
0:018> p
edgehtml!MemoryProtection::HeapAllocClear<1>+0x2:
00007ffe`283ebed2 4883ec20 sub rsp,20h
0:018>
edgehtml!MemoryProtection::HeapAllocClear<1>+0x6:
00007ffe`283ebed6 488bd9 mov rbx,rcx
...ひたすらステップオーバー...
0:018>
edgehtml!MemoryProtection::HeapAllocClear<1>+0x33:
00007ffe`283ebf03 5b pop rbx
0:018>
edgehtml!MemoryProtection::HeapAllocClear<1>+0x34:
00007ffe`283ebf04 48ff25e5590b01 jmp qword ptr [edgehtml!_imp_?MemProtectHeapRootAllocYAPEAXPEAX_KZ (00007ffe`294a1
8f0)] ds:00007ffe`294a18f0={chakra!MemProtectHeapRootAlloc (00007ffe`27bdccf0)}
0:018>
chakra!MemProtectHeapRootAlloc:
00007ffe`27bdccf0 4c8bdc mov r11,rsp
0:018> r
rax=0000000000000003 rbx=0000020579930020 rcx=000001fd78d13040
rdx=0000000000000160 rsi=0000020579930020 rdi=00007ffe291631a0
rip=00007ffe27bdccf0 rsp=000000e9ad9fca88 rbp=000000e9ad9fcb50
r8=0000020579930020 r9=000001fd27f2aae0 r10=00000fffc509b9d2
r11=0000000000040000 r12=000000e9ad9fcb58 r13=000001fd28a43800
r14=000001fd201b06b8 r15=000001fd27f42a00
iopl=0 nv up ei pl nz na pe nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202
chakra!MemProtectHeapRootAlloc:
00007ffe`27bdccf0 4c8bdc mov r11,rsp
0:018>
突然 jmp してchakra!MemProtectHeapRootAlloc に来ました。この後も実行を続けるとchakra!Memory::Recycler::RealAlloc<8,1> とかいう関数にたどり着くのですが、そこまでしなくても MemProtectHeapRootAlloc でググると、GitHub にある ChakraCore のコードがヒットして、第一引数が memProtectHeapHandle というポインター、第二引数が要求サイズだと分かります。第二引数は rdx なので上記例だと 0x160 で、同じ値が edgehtml!MemoryProtection::HeapAllocClear<1> のときには rcx にあり、確かに第一引数として渡されているらしいことが分かります。というわけで、edgehtml!MemoryProtection::HeapAllocClear<1> が edgehtml におけるヒープの関数で、第一引数が確保したいサイズだと推定できました。
制約など
2 つの事例から、ツールの動かし方や結果の見方を紹介しました。ここでは、事例では記載しきれなかった技術的な制約について紹介します。
フックできないコードのレイアウトについて
近接しすぎた箇所同士
Detours は、指定したアドレスのコードを jmp +rel32 命令に置き換えるため、フックできない範囲が存在します。例えば以下のコードのうち、00007ff6`82fc3e12 の rbp 命令のところをフックするとします。
notepad!SetTitle:
00007ff6`82fc3e08 48895c2410 mov qword ptr [rsp+10h],rbx
00007ff6`82fc3e0d 48897c2418 mov qword ptr [rsp+18h],rdi
00007ff6`82fc3e12 55 push rbp
00007ff6`82fc3e13 488dac24d0f8ffff lea rbp,[rsp-730h]
00007ff6`82fc3e1b 4881ec30080000 sub rsp,830h
00007ff6`82fc3e22 488b05bfe40100 mov rax,qword ptr [notepad!_security_cookie (00007ff6`82fe22e8)]
フックした後のコードはこうなります。
notepad!SetTitle:
00007ff6`82fc3e08 48895c2410 mov qword ptr [rsp+10h],rbx
00007ff6`82fc3e0d 48897c2418 mov qword ptr [rsp+18h],rdi
00007ff6`82fc3e12 e961c3febf jmp 00007ff6`42fb0178
00007ff6`82fc3e17 cc int 3
00007ff6`82fc3e18 cc int 3
00007ff6`82fc3e19 cc int 3
00007ff6`82fc3e1a cc int 3
00007ff6`82fc3e1b 4881ec30080000 sub rsp,830h
00007ff6`82fc3e12 に jmp 命令が置かれ、その次の lea 命令の置かれていた場所は全て int 3 である 0xcc で埋められています。この0xcc の部分すると、そもそも実行されない上、今度は 00007ff6`82fc3e1b の命令が書き換わってしまうのでフックしてはいけません。
この動作から、フックする箇所から 5 バイト先までの範囲がカバーする命令が埋められている場所はフックができません。
ret 命令
上記と同様の理由で、ret 命令を直接フックするのは危険です。例えば、notepad!SetTitle の ret 近傍のコードを見てみましょう。
0:001> u 00007ff6`82fc3f69 l10
notepad!SetTitle+0x161:
00007ff6`82fc3f69 e882540100 call notepad!_security_check_cookie (00007ff6`82fd93f0)
00007ff6`82fc3f6e 4c8d9c2430080000 lea r11,[rsp+830h]
00007ff6`82fc3f76 498b5b18 mov rbx,qword ptr [r11+18h]
00007ff6`82fc3f7a 498b7b20 mov rdi,qword ptr [r11+20h]
00007ff6`82fc3f7e 498be3 mov rsp,r11
00007ff6`82fc3f81 5d pop rbp
00007ff6`82fc3f82 c3 ret
00007ff6`82fc3f83 cc int 3
notepad!NpResetMenu:
00007ff6`82fc3f84 48895c2408 mov qword ptr [rsp+8],rbx
00007ff6`82fc3f89 55 push rbp
00007ff6`82fc3f8a 56 push rsi
00007ff6`82fc3f8b 57 push rdi
00007ff6`82fc3f8c 4156 push r14
00007ff6`82fc3f8e 4157 push r15
00007ff6`82fc3f90 4883ec20 sub rsp,20h
00007ff6`82fc3f94 4c8bf1 mov r14,rcx
ret の後 1 バイト 0xcc が置かれていて、そのすぐ次は別の関数の開始アドレスになっています。もし ret をフックすると、次の関数 notepad!NpResetMenu の先頭アドレスが書き換えられてしまうので、notepad!NpResetMenu が呼ばれたときに変な命令を実行しようとしてクラッシュします。ただし、そもそも Detours API はフックしようとする部分に int 3 があるとエラーを返すようになっているのでフックそのものが失敗します。
したがって、関数の最後の部分をフックしたい場合には ret から少なくとも 5 バイト上流の場所を選ぶ必要があります。特に 64bit コンパイラーの場合は関数が密に充填されているので、ほとんどの場合 ret 命令を直接フックすることはできません。
フック先にジャンプするコードがある場合
少し見つけづらい例ですが、以下のコードで 00007fff`4ce8e838 をフックしてはいけません。理由は、00007fff`4ce8e836 の位置にある jmp 命令のジャンプ先が 00007fff`4ce8e83b になっているからです。
0:001> u 00007fff`4ce8e82e
COMDLG32!GetFileTitleW+0x5e:
00007fff`4ce8e82e 488bc8 mov rcx,rax
00007fff`4ce8e831 e8d63b0000 call COMDLG32!StringCchCopyW (00007fff`4ce9240c)
00007fff`4ce8e836 eb03 jmp COMDLG32!GetFileTitleW+0x6b (00007fff`4ce8e83b)
00007fff`4ce8e838 498bdf mov rbx,r15
00007fff`4ce8e83b 440fb7c5 movzx r8d,bp
00007fff`4ce8e83f 498bd6 mov rdx,r14
00007fff`4ce8e842 488bcb mov rcx,rbx
00007fff`4ce8e845 e82e000000 call COMDLG32!GetFileTitleX (00007fff`4ce8e878)
もしフックするとコードは以下のようになり、00007fff`4ce8e83b は命令の境界からずれたアドレスになります。このときもし 00007fff`4ce8e836 からのジャンプが発生するとプロセスがクラッシュします。
0:002> u 00007fff`4ce8e82e
COMDLG32!GetFileTitleW+0x5e:
00007fff`4ce8e82e 488bc8 mov rcx,rax
00007fff`4ce8e831 e8d63b0000 call COMDLG32!StringCchCopyW (00007fff`4ce9240c)
00007fff`4ce8e836 eb03 jmp COMDLG32!GetFileTitleW+0x6b (00007fff`4ce8e83b)
00007fff`4ce8e838 e93b19febf jmp 00007fff`0ce70178
00007fff`4ce8e83d cc int 3
00007fff`4ce8e83e cc int 3
00007fff`4ce8e83f 498bd6 mov rdx,r14
00007fff`4ce8e842 488bcb mov rcx,rbx
フックするアドレスを選ぶときは、jmp に置き換わる部分が別のところからのジャンプ先になっていないことも確認する必要があります。
Windows のプロセス保護
ブラウザーなど、インターネット上のデータをメモリにロードするようなプロセスは、そのデータが悪意あるコードで未知の RCE などを引き起こす可能性があるため、万が一プロセスが乗っ取られても被害が最小限に抑えられるように通常のプロセスよりも権限が制限されていることがあります。インジェクトされたコードも、当然ターゲットのプロセスと同じ権限で動作するため、その制限の影響を受けて意図した通りに動かない場合があります。また、そもそもインジェクトが失敗するプロセスもあります。その回避方法について説明します。
Windows の各プロセスには Integrity Level という権限レベルが割り当てられています。Integrity Level は Process Explorer や Process Hacker で見ることができます。
- System: SYSTEM ユーザーで動くプロセス。主に Windows サービス。
- High: Administrator ユーザーで動くプロセス。UAC プロンプト経由でユーザーの承認が必要。
- Medium: 既定のレベル。
- Low: Protected Mode が有効のときの Internet Explorer や、Firefox のコンテント プロセス。
- Untrusted: Chrome のレンダー プロセス。
- AppContainer: UWP アプリケーション (いわゆる Windows Store アプリ)。Microsoft Edge のコンテント プロセス。
Non-invasive instrumentation で問題になるのは、ファイルへのアクセス権です。Medium 以上のレベルでは、ローカル ファイル システムへのアクセスはプロセスの実行ユーザーに依存するのみで、ユーザーにアクセス権があればファイルへのアクセスが可能です。しかし Low 以下では、ユーザーにアクセス権があったとしても、明示的に Low Integrity Level や各 AppContainer に対してアクセス権が設定されていないファイルにはアクセスできません。
Integrity Level によるファイル アクセスの制限は、インジェクト対象である clove.dll にも適用されます。もしプロセスが clove.dll にアクセスできない場合は、そもそも DLL をロードできないため、インジェクションが失敗します。これを Clove.dll の権限変更で対応することもできますが、けっこう面倒な作業なので、どんなプロセスからも見える場所に DLL をコピーしておくのが簡単な方法です。万能な場所が、ケーススタディのところで使った C:\Windows\Fonts フォルダーです。コピーしたときにフォルダーの権限が自動的に継承されるので、ファイルをコピーするだけで済みます。System32 フォルダーもほとんどの場合に使えますが、Untrusted プロセスである Chrome からは System32 配下のファイルが見えないので Chrome には使えません。
Code Integrity Guard (例: Google Chrome, Microsoft Edge)
最近の Windows には CIG という保護機能が導入され、ユーザーモードのプロセスでもロードするモジュールにデジタル署名を要求することができるようになりました。そのようなプロセスは、インジェクト対象の clove.dll にも署名を要求し、署名がなければロードが失敗します。CIG のレベルは EPROCESS の SignatureLevel と SectionSignatureLevel から確認できます。Google Chrome と Microsoft Edge の値を見てみましょう。
lkd> !process 0n5148 0
Searching for Process with Cid == 141c
PROCESS ffffd48098cf5080
SessionId: 1 Cid: 141c Peb: aa91b42000 ParentCid: 033c
DirBase: 11000000 ObjectTable: ffffbd81025f39c0 HandleCount: 1119.
Image: MicrosoftEdgeCP.exe
lkd> dt nt!_EPROCESS ffffd48098cf5080 SectionSignatureLevel SignatureLevel
+0x6c8 SignatureLevel : 0x8 ''
+0x6c9 SectionSignatureLevel : 0x6 ''
lkd> !process 0n5480 0
Searching for Process with Cid == 1568
PROCESS ffffd480956b0080
SessionId: 1 Cid: 1568 Peb: 19903aa000 ParentCid: 18cc
DirBase: 5c840000 ObjectTable: ffffbd8103328640 HandleCount: 296.
Image: chrome.exe
lkd> dt nt!_EPROCESS ffffd480956b0080 SectionSignatureLevel SignatureLevel
+0x6c8 SignatureLevel : 0x8 ''
+0x6c9 SectionSignatureLevel : 0x8 ''
Clove で問題になるのは SectionSignatureLevel の方で、これは実行権限を持つページにマップされるモジュールに要求する署名レベルを表しています。8 や 6 といった数値の意味は、Alex Ionescu 氏のブログの以下の記事に書かれており、8=Microsoft, 6=Store です。
Protected Processes Part 3 : Windows PKI Internals (Signing Levels, Scenarios, Root Keys, EKUs & Runtime Signers) « Alex Ionescu’s Blog
http://www.alex-ionescu.com/?p=146
上記結果から、Chrome も Edge も、インジェクトする clove.dll にデジタル署名を要求します。しかし Edge の場合、bcdedit /set testsigning on
を実行して testsigning モードを有効にするとなぜか SectionSignatureLevel が 0 になり、DLL への署名は不要になります。
Chrome は Edge よりも要求レベルが厳しく、testsigning モードが有効でも SectionSignatureLevel は 8 のままです。これをユーザー プログラムから何とかする方法は見つけられなかったので、Chrome へのインジェクトを成功させるには、カーネル デバッガーで SectionSignatureLevel を 0 に設定して CIG を強引に解除する必要があります。作業としてはデバッガーで eb コマンドを実行するだけです。Windows 10 だとローカル カーネル デバッグが使えるので、難しくはありません。
lkd> !process 0n7516 0
Searching for Process with Cid == 1d5c
PROCESS ffff808c7f9c6080
SessionId: 1 Cid: 1d5c Peb: c25854f000 ParentCid: 1c18
DirBase: 2ddb00000 ObjectTable: ffffd6839e1e3640 HandleCount: 415.
Image: chrome.exe
lkd> dt nt!_EPROCESS ffff808c7e5b0080 SignatureLevel SectionSignatureLevel
+0x6c8 SignatureLevel : 0x8 ''
+0x6c9 SectionSignatureLevel : 0 ''
lkd> eb ffff808c7e5b0080+6c9 0
lkd> dt nt!_EPROCESS ffff808c7e5b0080 SignatureLevel SectionSignatureLevel
+0x6c8 SignatureLevel : 0x8 ''
+0x6c9 SectionSignatureLevel : 0 ''
少し前に書いた記事で、PPLKiller というドライバーを紹介しました。このドライバーは SectionSignatureLevel の値も 0 に書き換えるので、デバッガーを使わず PPLKiller で解除する方法も有効です。
Windows 10 の保護プロセスを jailbreak してデバッガーをアタッチする方法 - Qiita
https://qiita.com/msmania/items/19547606b9c197c64d70
Arbitrary Code Guard (例: Microsoft Edge)
CIG に加え、Edge では ACG という保護機能も有効になっています。ACG とは、実行可能なメモリ領域が動的に作成/変更されることを防ぐ機能です。例えば Detours では、トランポリン関数を作るために VirtualAlloc で PAGE_EXECUTE_READWRITE 権限のページを確保します。ACG が有効な場合、このような要求は ERROR_DYNAMIC_CODE_BLOCKED というエラーコードで失敗します。したがって clove.dll のインジェクトは成功しても、フックが失敗します。
Edge の場合、testsigning モードを有効にすると、CIG とともに ACG も無効になります。あっけない解除方法ですが、それだけです。
ACG は、GetProcessMitigationPolicy におけるProcessDynamicCodePolicy というポリシーです。そしてこのポリシーは SetProcessMitigationPolicy で変更できることになっています。が、この API はポリシーを有効にすることはできても無効にすることはできないようです。とはいうものの、Process Hacker のコードを見ると、この Mitigation Policy は結局 NtSetInformationProcess というシステムコールを直接実行して変更できるみたいなので、pj.exe に ACG を解除するロジックを実装して -d オプションで実行できるようにしてあります。何らかの理由で testsigning モードが使えない場合は試してみてください。
Edge の保護機能や ACG の参考資料はこちらです。
Mitigating arbitrary native code execution in Microsoft Edge
https://blogs.windows.com/msedgedev/2017/02/23/mitigating-arbitrary-native-code-execution/
Microsoft's strategy and technology improvements for mitigating native remote code execution
https://cansecwest.com/slides/2017/CSW2017_Weston-Miller_Mitigating_Native_Remote_Code_Execution.pdf
おわりに
以上、Non-invasive instrumentation を行なうツールの紹介でした。いまいち記事のポイントを絞り切れず、非常に読みづらいものになってしまった気がします。もともとはケース スタディ 1 のような仕事の依頼が来たことがきっかけでアイディアを思いついて、Detours がオープンソースになったことを知って実装に着手しました。
本記事は事例の紹介を中心にして、新たなフック用の関数を実装する方法について触れていません。本来は GitHub にちゃんとした README を書くべきなのでしょうが、ProcJack の README をそれなりに真面目に書いたところで力尽きました。繰り返し手前味噌ですが、応用範囲は広く、多くの人に使ってもらいたいので、そのうち Clove の README は書くと思います。
このプロジェクトをベースにいろいろと他のアイディアも思い浮かんできたので、しばらく開発意欲が継続しそうです。