リエントランシーの悪用によるEDRの検知回避
サイバー犯罪者は、脆弱性を発見し、既存のソフトウェアの機能を再利用して、サイバー防御を突破する入口を見つけるために、多様なツールセットを開発してきました。このブログでは、EDRや従来のアンチウイルス製品の行動分析を回避するために使用される、再帰性を悪用する新しい方法を紹介します。
今回の手法は、単一のフックを持つAPIに焦点を当てていますが、この回避方法は、バイパスを可能にするためにウイルス対策製品をリバースエンジニアリングし、バイパス方法をカスタマイズすることで、ほぼすべてのウイルス対策ツールのフックに対して使用することができます。
多くのウイルス対策製品やEDR製品の多くは、スキャンと検出に重点を置いていますが、中にはファイルスキャンの仕組みに加えて、悪意のある活動を検出するための追加機能を活用しているものもあります。
このような機能の1つに、行動分析やヒューリスティックな手法を用いてメモリ内のプロセスを追跡するものがあります。つまり、ウイルス対策ソリューションは、メモリからの認証情報のダンプや他のプロセスへのコードの注入など、悪意があると判断される特定の動作を検出または防止します。このような悪意のある行動を見つけることで、ウイルス対策ソフトが、ファイルスキャン機能では捕捉できない脅威を検出することができます。また、ファイルハッシュがブラックリストに載っていない場合や、攻撃がファイルレスの場合にも有効です。
プロセス中の悪意ある動作を発見するために、ウイルス対策製品は通常、プロセス起動時に署名付きカーネルドライバを介して独自のDLLをすべてのプロセスにロードします。ロードされると、このDLLは追跡が必要なAPIにフックを設置します。
コードインジェクションには、kernel32.dll の「CreateRemoteThread」、「VirtualAllocEx」、「WriteProcessMemory」などの関数が最も頻繁に使用されます。ほとんどの場合、セキュリティベンダーは、「WriteProcessMemory」ではなく、ntdll.dll内の「NtWriteVirtualMemory」をフックするなど、可能な限り低レベルのAPIをフックすることを好みます。これは、上位のAPIを呼び出さないプログラムのために行われるもので、ヒューリスティックが悪意のある動作をキャッチする能力を制限することになります。
インラインフッキングとは?
ユーザーランドフックは、ウイルス対策ツールがプロセスの動作を検査するための非常に一般的な方法です。フッキングとは、関数の呼び出しをインターセプトするプロセスのことです。エンドポイントの保護者として、さまざまなAPIへの呼び出しをインターセプトすることで、ウイルス対策製品は、不要な活動や疑わしい活動を検出するだけでなく、防止することもできます。これは、インライン·フッキング(デターリングと呼ばれることもある)によって最もよく行われます。
フッキングとリエントランシー
Windows APIをフックする際に発生する非常に一般的な問題に、リエントランシーがあります。これは、スレッドがフックされたAPIを呼び出し、そのフックが別のフックされたAPIを呼び出したり、同じフックされたAPIを(直接または間接的に)呼び出したりする場合に発生します。このプロセスは、不必要なオーバーヘッドにつながり、また、無限の再帰につながる可能性もあります。
再帰性の問題は、ウイルス対策ツールにとって大きな課題です。なぜなら、すべてのプロセスでフックを使用すると、深刻な安定性の問題やフリーズなどのパフォーマンスの問題が発生するからです。この問題に対する一般的なアプローチは、フック自体にどのようなコードを記述するかに注意し、他のAPIを(直接または間接的に)呼び出さないようにすることです。しかし、よりエレガントな解決策は、単純に再帰性をチェックすることです。この方法では、再帰性が検出された場合、フックのトランポリンが直接呼び出され、フックが通常のプロセスで通過するチェックやロジックをスキップします。
下の図は、リエントランスに配慮したフックの流れを示したものです:
技術解説
フックの配置
この次のセクションでは、攻撃者の立場になって、1行のコードでアンチウイルスを回避するための手順を説明します。
フックを見つけるための最初のステップは、どのAPIがフックされているかを判断することです。HookSharkは、インライン·フッキングを検出するための素晴らしいツールです。このツールは、セキュリティ·ベンダーがどのAPIをフックしているかを素早く見つける方法を提供します。
今回の例では、メモ帳を起動した状態で「このプロセスをスキャン」を押すと、以下のような結果が表示されます:
私たちが注目したAPIはNtWriteVirtualMemoryです。これはプロセスインジェクション技術に使用されます。攻撃者として、ウイルス対策ソフトがプロセスの空洞化の試みを検出するかどうかを判断します(ここではプロジェクトを使用しています: https://github.com/m0n0ph1/Process-Hollowing)。見ての通り、検出されました:
第2のステップは、フックがどこにあるかを確認することです。幸運なことに、フックはアンチウイルスが注入したDLL - aswhook.dllの中にあります。
フックの逆アセンブル
逆アセンブルするファイルがわかったので、IDAを開いてフックを探すのはとても簡単です。
これはIDAで見た機能です:
これは「NtWriteVirtualMemory」という関数のフックであることがわかっているので、APIはこのようになっているはずです。
NTSTATUSNtWriteVirtualMemory(
INHANDLEProcessHandle,
INPVOIDBaseAddress,
INPVOID Buffer,
INULONGNumberOfBytesToWrite,
INOUTPULONGNumberOfBytesWritten);
あとは、関数の定義と名前を変更するだけです
これは、すべての引数の型定義がわかっているので、分解しやすくなります。
フックは次のようになっています:
ここでは、逆コンパイルされたバージョンをご紹介します:
見ての通り、フックロジックが始まる前に、4つの条件付きジャンプが行われています。そのうちの少なくとも1つは再帰性チェックでなければなりません。いずれかの条件が満たされていれば、フックのロジックをスキップして、トランポリンが直接呼び出されます。最初のスクリーンショットにあるように、最初の条件付きジャンプでは、ProcessHandleが現在のプロセスの疑似ハンドル(-1)であるかどうかをチェックします。これではあまり参考にならないので、他の3つの条件を見てみましょう。
このスクリーンショットでわかるように、var_4 は整数へのポインタです。もし sub_10006820 が 0x0 以外の値を返したり、var_4 が NULL だったり、var_4 内部の値が 0x1 だったりすると、条件付きジャンプが発生します。
sub_10006820 は var_4 の値を、おそらく dword_10008060 に格納されている値に従って設定していると推測できます。分解してみましょう:
まず、fs:18hの内部がTEB(Thread Environment Block)であることがわかっているので、IDAに「_TEB」構造体の定義を追加したところ、非常に興味深いことがわかりました。
0x10006837では、ecxがTEBのTlsSlotsメンバーのインデックスを使用していることがわかります。これはTLS - Thread Local Storageのことです(注1参照)。
TEB.TlsSlotsの配列サイズは64です。しかし、プログラムが65番目のインデックスにTLSスロットを割り当てたい場合はどうでしょうか?0x1000682Fでは、ecxが64という値と比較されているのがわかります。つまり、大まかには以下のようなC言語のコードになります。:
if (*dword_10008060 <= 64)
*arg_4 = TEB.TlsSlots[dword_10008060];
0x10006844でecxは1088と比較されています。IDAは、この一見任意の値に対する既知の定数を提供していません。しかし、逆アセンブルを続けてみると、これはより意味のあるものになります:
0x10006853の命令では、再びedxを参照していますが、これはTEBへのポインタであることがわかっています。つまり、[edx+0F94h]はTEB.TlsExpansionSlotsに変換されます。
前の質問に戻りますが、TEB.TlsSlots配列のスロットがすべて割り当てられた後にプログラムがTlsAlloc()を呼び出した場合、TlsAlloc()はRtlAllocateHeap()を介してヒープ上にメモリを内部的に割り当て、TEB.TlsExpansionsSlotsメンバにその割り当てられたメモリのアドレスを設定します。これにより、スレッドが使用できる TLS スロットが 1024 個追加されます。インデックスが64以上のTLSスロットに書き込もうとすると、TEB.TlsSlots配列ではなく、ヒープ上の割り当てメモリに書き込まれます。
つまり、1088という数字は、1024(ヒープ上に格納されているTlsExpansionSlotsの利用可能なスロットの数)+64(TEB内に直接格納されているTlsSlotsの利用可能なスロットの数)の結果に過ぎないのです。つまり、dword_10008060に格納されている値が1088以上であれば、不正なインデックスであると考えられます。
このサブルーチンがSTATUS_INVALID_PARAMETERを返すように、悪意のあるプログラムが1088個のTLSスロットをすべて割り当てるという解決策を提案したくなるかもしれませんが、ベンダーのDLLはプロセスにロードされた時点でインデックスを割り当てており、私たちがインターセプトするには早すぎるため、この解決策は不可能です。
コードの話に戻りますが、0x1000685Bでの条件付きジャンプが発生した場合、arg_4の値は0x0に設定されます。これは大まかに言うと以下のCコードになります:
if (TEB.TlsExpansionSlots == NULL)
*arg_4 = 0x0;
これで、値arg_4がどのように設定されているかがわかったので、ここから先に進みます:
var_4 が TLS スロットに格納されている値であることがわかったので、これを TlsValue と改名します。また、dword_10008060 は TLS インデックスへのポインタであることがわかりましたので、g_TlsIndex と改名します。
これはおおよそ以下のCコードに変換されます:
PDWORD TlsValue;
if (sub_10006820(g_TlsIndex, TlsValue) == 0 || TlsValue == NULL || *TlsValue == TRUE)
// Skip the hook’s logic
0x10002A81の命令では、*TlsValueに格納されている値が0x1に設定されていますが、その後、この値が0x0に戻されていることがわかります(0x10002ABF):
要約すると、セキュリティ·ベンダーはグローバル変数を介して TLS スロットにアクセスし、ブール値を指すアドレスを格納します。このブール値が FALSE に設定されている場合、コードはフックのロジックを実行し、TRUE の場合はフックをスキップしてトランポリンに直行します。フックのロジックを実行し終わったら、ブール値をFALSEに戻します。
リエントランシー機構の活用
g_TlsIndexが64以上の場合は、TlsExpansionSlotsをNULLにして、TlsValueもNULLになるようにしなければなりません。g_TlsIndexが64以下の場合。TlsValueはNULLにするか、その中に格納されているアドレスのブール値をTRUEに設定する必要があります。
g_TlsIndex の値がわからないと、どの TLS スロットを操作すればいいのかわからないので、どうすればいいのでしょうか?
NtWriteVirtualMemory を呼び出す前に、すべての TLS スロットと TlsExpansionSlots を一時的に NULL に設定し、呼び出しから戻ると、すべての TLS スロットを以前の状態に戻すことができます。これは、どんな悪意のあるコードにも組み込める簡単なソリューションです。使用したい攻撃ツールのソースコードを少し変更するだけです。
もっとエレガントな解決策は、スタック上に割り当てられるC++オブジェクトを使用することです。コンストラクタでは、すべての TLS スロットと TlsExpansionSlots のポインタの値をバックアップしてから、それらをすべて NULL に設定し、デストラクタが呼び出されると、すべての TLS スロットと TlsExpansionSlots のポインタの値を元に戻します。
この動作は次のようになります:
フックされていることがわかっているAPIを呼び出したいときは、そのAPIへの呼び出しを囲むようにコードのブロックを作るだけです。コードのブロックを作成することで、フックされたAPIが終了すると同時にTlsKillerのデストラクタが呼び出されることが保証されます。今回のケースでは、WriteProcessMemoryがNtWriteVirtualMemoryを呼び出すことがわかっているので、WriteProcessMemoryと同じブロックにTlsKillerを入れなければなりません。簡潔にするために、1つの例を示します:
実行ファイルを再コンパイルして実行すると、このようになります:
アンチウイルスからの苦情なし!
たった一行のコードでアンチウイルスを回避
フックされたAPIの呼び出しの前に1行のコードを追加するだけで、ウイルス対策ツールの行動分析を完全に回避することができたようです。
ウイルス対策ツールがメモリヒューリスティックでどのような攻撃を防ぐことを目的としているかにもよりますが、フックの再帰性をチェックする方法が同じであれば、ウイルス対策ツールがどのような防御策を講じようとも、それを回避することは可能です。
ウイルス対策製品の中には、再帰性を回避するために独自の方法を考案しているものや、TLSインデックスを使用しているものもあり、それらもこの攻撃の影響を受けることになります。これらの製品は、異なる方法(例えば、ヒープを全く使用せず、TLS スロットをブール値または整数値として設定するなど)を採用しているかもしれませんが、フックを回避できるかどうかを確認するには、ほとんど手間がかかりません。
他のアンチウイルスソリューションは、完全に別のメカニズムを考案するかもしれません。また、ウイルス対策ソフトが配置するフックのすべてが、再帰性を回避する仕組みを持っているわけではないことにも注意が必要です(例えば、NtProtectVirtualMemoryはフックされていますが、再帰性のチェックは行われていません)。
まとめ:
アンチウイルス製品は、既知のマルウェアに関しては高い検出率を誇りますが、安定性を第一に考え、エッジケースや全体的なパフォーマンスとの互換性を求められることが多いです。これは、セキュリティの姿勢を弱め、攻撃者に無数の可能性を与えることになります。そのため、安定性を目的とした機能がバイパス手段として再利用され、侵入や侵害の道を開いてしまうのです。さらなる研究により、アンチウイルスのどの機能が悪用される可能性があるかが明らかになるでしょう。
300万ドルの保証が付いた、マルウェアを阻止するためのディープインスティンクトの業界最先端のアプローチについての詳細をお知りになりたい方は、当社の新しいeBook「ランサムウェア:事後対応より予防が大事」(https://info.deepinstinct.com/ja/tof/ransomware-prevention)をダウンロードしてください。
注1:TLSのスロット
TLSは「Thread Local Storage」の略で、研究者の中には、PEのエントリーポイントの前でコードを実行するための既知のメカニズム(TLSコールバック)として名前を認識している人もいるかもしれません。しかし、これは別のもので、関係ありません。
Thread Local Storageは、その名の通り、スレッドが自分のローカル情報をTEB.TlsSlots配列(TLSスロットと呼ばれるボイドポインタの配列)に格納する場所です。基本的には、すべてのスレッドが独自の配列を持ち、それに適切な値を入れることができるということです。
その仕組みは、単に配列にアクセスするだけではなく、もう少し複雑なので、TLSに使用できるAPIは4つあります。
TlsAlloc() - TLS のインデックスを割り当てます。このインデックスは予約済みとみなされ、任意のスレッドがTEB.TlsSlotsのローカル値を取得したり設定したりするのに使用することができます。
TlsFree(DWORD dwTlsIndex) - TlsAlloc()で割り当てたインデックスを解放します。
TlsGetValue(DWORD dwTlsIndex) - スレッドのTEB.TlsSlots[dwTlsIndex]に格納されている値を返します。
TlsSetValue(DWORD dwTlsIndex, LPVOID lpTlsValue) - lpTlsValueをTEB.TlsSlots[dwTlsIndex]に格納されている値として設定します。
参考資料:
https://docs.microsoft.com/en-us/windows/win32/procthread/thread-local-storage
https://github.com/microsoft/detours/wiki/OverviewInterception