脅威の調査

Emotet が用いる難読化手法「制御フロー平坦化」を解き明かす

悪名高い一連のマルウェアに使われている難読化手法「制御フロー平坦化」の仕組みを解明します。

** 本記事は、Attacking Emotet’s Control Flow Flattening の翻訳です。最新の情報は英語記事をご覧ください。**

Emotet は、数ある脅威の中でも、最も専門的で長期にわたって活動しているサイバー犯罪サービスおよびマルウェアの一種です。2014 年の初出現直後から悪名を轟かせているこのボットネットは、2021 年 1 月に多国籍の法執行活動によって一度壊滅し、その活動はほぼ一年間沈静化していました。しかし、2021 年 11 月に再度出現すると、再びソフォスのレーダーに現れるようになりました。

SophosLabs では、お客様を保護するため、Emotet の配布や配信に利用されている最も重要な戦術、技術、手順 (TTP) を常に調査しています。本記事では、Emotet の開発者がマルウェアのペイロードの検出とリバースエンジニアリング対策に使用している難読化手法の 1 つである制御フロー平坦化 (CFF) について分析します。以下では、単純な hello-world プログラムに CFF を適用した場合の簡単な例を示し、その後ソフォスの研究者が Emotet コードに施された CFF にどのように対処したかを説明します。最後に、調査中に遭遇した課題と問題点をまとめます。

Emotet の内部構造は多くの研究者が研究していますが、Emotet が制御フロー平坦化を利用する仕組みを解明しようとする研究は現状ありません。

Emotetの復活と執念深さ

図 1 は、2022 年第 1 四半期にソフォスのサンドボックスシステムで検出された Emotet のペイロードの量を示しています。このグラフが示すように、ソフォスの元には Emotet のコピーが毎日いくつも届いています。いくつかの理由から、図に繰り返し出現している大きな山は、マルウェアの配信者による大規模な攻撃によるものだと考えられます。Emotet は主にスパムメールを介して配布されており、悪意のあるメールが増加すれば、当然サンドボックスへの送信も増えるからです。

Timeline showing Emotet sandbox trends during the first quarter of 2022

1: 2022 年に SophosLabs のサンドボックスシステムに出現した Emotet の検出回数

ソフォスでは Emotet の配信メカニズムや感染拡大手法に加え、最終的なペイロードも詳細に分析しました。その結果、解凍された Emotet のサンプルに制御フロー平坦化が用いられていることが判明しました。 制御フロー平坦化は、すべての関数ブロックを並列に配置することでプログラムのフローをわかりづらくする、有名な難読化手法です。この手法は、ソフトウェアの目的を隠すために用いられます。平坦化されたバイナリから元のコードを抽出するのは構造上困難ですが、いくつかの既存のツールセットを最適化し、Emotet ペイロードの大部分の難読化を解除することに成功しました。

制御フロー平坦化とは

制御フロー平坦化とは、プログラムのフローを難解にするための手法です。プログラムのブロックをループ内にいくつも配置し、switch 文 1 つでプログラム全体のフローを制御することで、プログラムの構造を煩雑にします。

まず、関数の本体を基本ブロックに分割し、同じ層に並列に配置します。この操作を視覚化したものが図 2 です。制御フロー平坦化は、API のハッシュ化や文字列の暗号化など、他の難読化手法と組み合わせることができます。関数を平坦化するために使われる難読化ツールとしては OLLVMTigress などが有名です。

Comparing a flattened and non-flattened control flow graph (CFG)

図 2: 平坦化されたプログラムと平坦化されていないプログラムの制御フローグラフ (CFG) の比較

それでは、CFF の簡単な例を見てみましょう。

Hello World の平坦化

実演のため、C で書かれた簡単なプログラムをコンパイルしました。図 3 の左側は、バイナリの注釈付き制御フローグラフ (CFG) です。右側は、Hex-Rays デコンパイラを用いてデコンパイルした結果です。

この図では、難読化手法は使われていません。したがって、Hex-Rays デコンパイラで簡単に、可読性の高い高水準言語による表現に変換できます。経験豊富なリバースエンジニアであれば、デコンパイラを使わずとも制御フローグラフを追うだけでプログラムの目的を理解できます。

Control Flow Graph and decompiled output of sample program

図 3: サンプルプログラムの制御フローグラフとデコンパイル結果

次に、この関数を平坦化して、その結果を比較します。図 4 は、制御フロー平坦化を行ったあとの CFG とデコンパイル結果を表示したものです。左側を見ると、基本ブロックの数が 2 倍以上になっており、デコンパイル結果を読むにはかなりの時間をかけて解析しないといけないことがわかります。

Annotated example of a flattened function

図 4: 平坦化された関数の例 (注釈付き)

全体として、CFF は以下のような問題を引き起こし、コードの分析を妨害します。

  • 制御フローを難解にします。制御フローのディスパッチャーブロックが実装され、このブロックが次に実行されるブロックを決定します。そのため、ブロックを直接追うのが困難になります。
  • 上記のデコンパイル結果で stateVar と注釈が付けられた状態変数を、関数全体を通じて情報量の大きい変数で更新します。この状態変数は制御フローのディスパッチャーブロックによって、次にどのブロックを実行するかの決定に使用されます。
  • 上記 2 つの問題により、デコンパイル結果が非常に複雑になります。それでもまだ実行フローを追うことはできますが、関数を理解するために必要な時間と労力は、図 3 のデコンパイル結果と比較すると著しく大きくなっています。

Emotet の「逆平坦化」

制御フロー平坦化による Emotet の難読化を解除するために、まず CFG の難読化解除に関する既存のツールや研究を再確認しました。以下にその一部を紹介します。

CFG の平坦化を解除するためのアルゴリズムについては、上記の記事で詳しく述べられています。

図 5 は、解凍された Emotet のサンプルに存在する関数のデコンパイル結果と CFG です。ここで使用されている制御フロー平坦化を取り除いたとしても、デコンパイル結果は煩雑です。これは Emotet が 2 つ以上の難読化手法を使用しているためです。(CFF 以外の難読化手法に馴染みがない方のために、この記事の最後の付録で簡単に説明しています。)

まず、この関数は OpenSCManagerA を呼び出して、サービスコントロールマネージャーの制御権を取得します。次に、OpenServiceW を呼び出して、既存のサービスを開きます。この操作が成功した場合、開かれたサービスは DeleteService により削除されます。最後に、取得した制御権が放棄されます。サービスの削除に成功した場合、この関数は 1 を返し、そうでない場合は 0 を返します。

Annotated example of a flattened function

図 5: 平坦化された関数の例 (注釈付き)

図 3 と図 4 のデコンパイル結果を比較すると、複数の類似点が確認でき、上記のように CFG ディスパッチャーを特定できます。デコンパイル結果には、stateVar と注釈が付けられた変数が確認できます。図 3 のデコンパイル結果と同様に、stateVar は常に更新される状態変数で、次に実行されるブロックを決定するためにディスパッチャーによって使用されます。

制御フローを高水準言語で復元するためには、以下の手順が必要です。

  1. ディスパッチャーブロックと状態変数を特定する。
  2. 各ブロックについて対応する定数を特定し、ディスパッチャーと状態変数の値に基づいて、次に実行するブロックのアドレスを特定する。
  3. アウトバウンドのディスパッチャーブロックにパッチを適用して、本来次に来るブロックのアドレスにジャンプさせる。

逆アセンブリ自体にパッチを当てたり操作したりする代わりに、Hex-Rays Microcode API を利用しました。Microcode は、Hex-Rays のデコンパイラで使用される中間言語です。デコンパイルの間、デコンパイラのプロセスはいくつかの異なるレベルを通過します。これらのレベルは、以下の図 6 に示されています。この API を利用すると、逆アセンブリに直接パッチを当てなくてもデコンパイルのプロセスを追加し、Microcode 上で操作できます。

IDA Microcode maturity levels

図 6: IDA Microcode のプロセス成熟レベル

ツールの最適化

今回の調査では、Rolf Rolles 氏の HexraysDeob ツールの IDAPython フォークをベースとして利用しました。元のフォークと同様に、今回最適化したツールは MMAT_LOCOPT レベル (上図の 3 段階目) のみで動作します。図 6 で示されているように、MMAT_LOCOPT レベルには、ディスパッチャーブロックを正しく識別するために必要な、インバウンドブロックとアウトバウンドブロックに関する情報が含まれています。さらには、元のコードも MMAT_LOCOPT レイヤを中心に構成されています。レイヤを変更すると、そのまま維持するよりもはるかに多くの調査、検証、および既存のコードの調整が必要になります。以下が、既存のコードベースに施した変更のまとめです。

複数の/関連するディスパッチャーの調査

複数の関数において、1 つのディスパッチャーで難読化解除アルゴリズムを実行しても、満足のいく出力結果は得られませんでした。解析の結果、より複雑な関数では、1 つではなく複数のディスパッチャーが挿入されている可能性があることがわかりました。そこで、複数のディスパッチャーを識別してアルゴリズムを実行するためのアルゴリズムを追加しました。このアルゴリズムは、RUN_MLTPL_DISPATCHERS フラグを True または False にすることで有効化/無効化できます。下の図 7 では、ディスパッチャーだと思われるブロックを 2 つ持つ関数の例を示しています。

Example of a function of two potential dispatchers

図 7: 2 つのディスパッチャーを持つと思われる関数の例

先頭クラスタの特定

平坦化されたブロックは、複数の Microcode ブロックによって実装されている可能性があります。Rolf Rolles 氏による元のアルゴリズムは、ブロックの終点を見つけるためにドミネーターツリーを生成し、生成された情報を使用してブロックの終点、または先頭クラスタを特定します。このアルゴリズムでは、先頭クラスタを発見できない場合がありました。そこで、先頭クラスタを特定する機能をフォールバックとして追加しました。Rolf Rolles 氏による元のアルゴリズムの方が、より信頼性が高いと考えられますが、検証の結果、追加したフォールバックアルゴリズムでも良い結果が得られ、デコンパイル結果が改善されました。

パターンの追加と小規模なコード調整

また、既存のアルゴリズムでは、すべての平坦化されたブロックにパッチを当てられない場合もありました。そこで、複数の関数を分析した結果、バイナリ全体で繰り返されるさまざまなパターンを特定しました。これらのパターンに従ってブロックを識別し、平坦化を元に戻すアルゴリズムを既存のコードベースに追加しました。最後に、コード全体を少し調整しました。以下が加えた変更の一部です。

  • Rolf Rolles 氏の HeyRaysDeob ツールの IPAPython フォークは Python2.7 をベースにしていました。Python3 の規格に合わせるため、コードのいくつかの部分を更新しました。
  • 元のツールでは、「run」関数が一度起動されるとプラグインが有効になり、アルゴリズムによって関数が平坦化されていると判断された場合に平坦化の解除が試みられていました。しかし、実装とテストの最中、IDAPython の Microcode API を使用すると IDA Pro がクラッシュする現象が発生しました。この現象は、IDB データベースの破損につながる可能性があります。したがって、追加の安全装置として、平坦化を解除する際にはターゲットとなる関数のアドレスを「white_list」配列に追加するようにしました。全体として、ツールを使用する際には保存を頻繁に行い、別の IDB コピーを保持しておくことをおすすめします。

254 の関数のうち、68 の関数が平坦化された関数だと考えられます。この 68 の関数のうち、38 の関数の平坦化の解除に成功しました。また、19 の関数は部分的に平坦化されたままとなり、11 の関数は平坦化の解除に失敗しました。「平坦化の解除に成功」というのは、スクリプトが最大 3 つのステートの平坦化解除に失敗した場合まで含みます。「部分的に平坦化されたまま」とは、関数の大部分は平坦化されたままであるが、いくつかのブロックの平坦化の解除には成功したという意味です。「平坦化の解除に失敗」とは、関数内のすべてのブロックの難読化解除に失敗したことを意味します。

以下の図 8 は、図 5 の関数に上記のスクリプトを適用したものを示しています。

Analyzed function after the CFG unflattening tool was applied

図 8: 分析した関数に CFG 平坦化解除ツールを適用した結果

セキュリティ侵害の痕跡 (IoC)

説明

SHA256

圧縮された Emotet

9a0286ec0a3e7ea346759c9497c8b5c7c212fa2c780a1cabb094134bf492a51b

解凍された Emotet

1bbce395c839c737fdc983534b963a1521ab9693a5b585f15b8a4950adea5973

今回作成した平坦化解除ツールは、現在 SophosLabs の Github で公開されています(このようなツールが必要な方には、Stantinko ボットネットによる制御フロー平坦化に対処するために ESET が数年前にリリースした CFF 解除ツールもおすすめします。攻撃者が戦術、技術、手順を自由に共有している以上、防御側も同様に、対策を共有するのが賢明でしょう。)

結論と限界

制御フロー平坦化は一筋縄ではいかないトピックです。本記事の目的は、Emotet の制御フロー平坦化の解除を試みた経験と結果を共有することです。ソフォスでは何度も調整を行い、ある程度の成功を収めましたが、すべての関数の難読化を完全に解除できたわけではありません。 未解決の問題点としては以下のようなものがあります。

  • 挿入されたディスパッチャーを検出するアルゴリズムは単純なものなので、今回のツールにはオンとオフを切り替える機能を追加しています。この機能がオンになっていると、まれに誤った出力が生成されます。
  • 多くの関数で、条件付きステートに対処する必要がありました。たとえば、WINAPI 関数では、出力結果に応じて実行時に状態変数が異なる値に変化します。このような条件付きブロックの平坦化を解除するためには、追加のパッチや Microcode による命令が必要になります。
  • 今回の調査での主なアプローチは、バイナリに繰り返し現れるパターンに対処するアルゴリズムを追加することでした。作業が進むに連れて、Microcode エミュレーターを用いる方が良い選択であり、より多くの平坦化を解除する手段として適切だったことに気づきました。
  • 開発と検証の過程で、幾度かツールがクラッシュしました。人力で開発されたツールである以上、コード内のバグに起因したクラッシュが起こることもあるでしょう。しかし、エラーメッセージから判断するに、Microcode API の Python ポートの方に根深い問題があると考えられます。したがって、こまめにデータを保存し、IDB ファイルのコピーを取っておくことをおすすめします。

全体として、研究者は常に結果を相互に確認し、出力結果を盲目的に信用しないようにしましょう。他の難読化手法と組み合わせて使用される制御フロー平坦化は、Emotet のリバースエンジニアリングのプロセスを明確に複雑化します。しかし、上記の技術は、この有名なマルウェアを調査する研究者たちにも勝算を与えるものになっています。

付録: Emotet とコードの難読化

Emotet のサンプル中の関数のデコンパイル結果を共有する場合、必ず CFF 以外の難読化手法にも遭遇します。本付録では、解凍した Emotet サンプルで確認された、最も一般的な難読化手法を取り上げます。Emotet は通常圧縮された状態で配信されるため、まず解凍する必要があることを心に留めておいてください。

文字列の暗号化

解凍した Emotet には暗号化された文字列が含まれています。実行される前に文字列は復号され、目的を果たした直後に再び圧縮されます。

Cross references of DecryptString function with corresponding decrypted string

図 9: DecryptString 関数と復号される文字列の対応表

API のハッシュ化

Emotet は API 関数の目的を隠すために API をハッシュ化します。Emotet は指定された DLL に対して出力された関数名のハッシュを計算します。計算結果が、メソッド呼び出し時にスタックに出力された定数と一致する場合、出力された関数へのポインタが取得されます。

Disassembly of ApiHash function invocation

図 10: ApiHash 関数を呼び出す部分の逆アセンブリ

ほとんどの場合、API ハッシュ化関数の呼び出しと対応する動的な呼び出しは別々の関数に内包されています。対応関係の分析を自動化し、DYN_ という接尾辞がついた関数は API のハッシュ化を介して実行時に決定される関数であることを突き止めました。

ジャンク命令

Emotet には、リバースエンジニアリングを妨害するためにジャンク命令が埋め込んであります。ジャンク命令とは、分析を複雑にし、手間をかけさせることを目的とした意味のない命令を指します。以下の図 11 にその一例を示します。

Example of junk instructions in unpacked Emotet sample

図 11: 解凍した Emotet のサンプル内のジャンク命令の例

スタックの難読化

IDA デコンパイラを混乱させるもう 1 つの興味深い技術は、Emotet が関数にパラメータを渡す方法です。以下の図 12 は、DYN_BCryptEncrypt 関数がどのように呼び出されるかを表しています。

DYN_BCryptEncrypt 関数は、まず API 関数 BCryptEncrypt を解決して、この関数へのポインタを EAX レジスターに格納します。その後、call EAX という命令を介してポインタが格納された関数が呼び出されます。このメソッドでは、必要なパラメータを出力する代わりに、実際の EAX の呼び出しで使用されていない値をスタックに出力します。この操作により、通常よりも遥かに読みづらい関数シグネチャが生成されます。

Multiple values being pushed onto the stack before DYN_BCryptEncrypt is invoked

図 12: DYN_BCryptEncrypt 関数が実行される前にスタックに出力される複数の値

Corresponding generated function signature

図 13: 対応する生成された関数シグネチャ

コメントを残す

Your email address will not be published. Required fields are marked *