22
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

実行中のプロセスをフックして任意のコードを実行する (Non-invasive instrumentation)

Last updated at Posted at 2018-07-23

はじめに

掲題の方法を非破壊的、すなわちソースコードや実行可能モジュールのファイルを変更せずに実現するツールを作りました。より具体的には、「実行中のプロセスの任意の箇所をフックして、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 でテキスト データに変換するスクリプトを書きました。

clove.py
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))

hist2.png

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))

hist3.png

面白いことに、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)

funcfreq.png

このグラフからは特に何も読み取れなさそうです。では最後に、頻度が多かった呼び出し箇所はどこだったのかを特定してみます。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 は書くと思います。

このプロジェクトをベースにいろいろと他のアイディアも思い浮かんできたので、しばらく開発意欲が継続しそうです。

22
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?