NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

マルウェア解析は IDAPython にシュッとやらせよう


tl;dr;

  • IDAPython の日本語チートシートできました!!
  • チートシートを参考にするだけで、CTFの問題もシュッと解けます!
  • Emotet の内部で使われている難読化文字列もシュッと戻せます!!
  • Github にハンズオン用の検体とサンプルコード置いたから手を動かしてみたい方はぜひ試してね!!!

はじめに

NFLabs. のソリューション事業部に所属している @strinsert1Na なるものです。普段は脅威インテリジェンスの生成やそれに伴うソフトウェアの開発を行っています。


本稿は NFLaboratories Advent Calendar 2022 の10日目、最終日の記事です。今年のネタ候補としては (1) IDAPython の話, (2) OpenCTI connector の話, (3) Elastic Security の3つがあったため Twitter でアンケートをとってみたところ、IDAPython 関係の話が一番興味ありそうだったのでそちらを取り上げてみることにしました。

なお「全部書け」という圧がいっぱい来ましたが、これをやると筆者の身体がバラバラになってしまいそうだったので Elastic Security の話を7日目OpenCTI connector の話を8日目として、それぞれ現場のプロの方々にお願いしました。自分が書くよりも3倍くらい濃密な内容となっているので、お時間がある方はぜひこちらもチラ見してください。

前置きが少々長くなりましたが、以降本題です。

What is IDAPython ??

マルウェアアナリストが静的解析のために利用するメジャーなツールの1つに、Hex-Raysさんが開発しているIDA Proという逆アセンブルツールがあります。
アナリストはIDA Proを通して逆アセンブルの結果を visible に確認してコメントを入れながら解析進めることができますが、解析を通して発見した復号処理を内部の全てのデータに対して適応したり、インタプリタ言語のようなより可読性の高い形式に変換してチームに知見を蓄えたいといった需要も発生することでしょう。

このような悩みを解決する一つの手段が、IDAPython です。IDAPython はIDA Proから提供されているプラグインであり、python スクリプトをIDA Proで実行させて逆アセンブルコードなどを解釈することができます。つまり、繰り返しの処理やすでに解析済みの検体で行った作業などは、IDAPythonスクリプトを使用すれば自動化させることが可能となり、解析にかかる時間を大幅に短縮することも可能となるのです。

これは使わない手はない......!!と思うのですが、IDA Proが有料であることと日本語の解説記事がおそらくSnoozyCatさんの記事しかないせいか、悲しいことに日本の初心者〜中級者レベルの人がIDAPythonを学ぶ機会がほとんどありません。

しかし、つい先日ChihiroさんがIDAPythonの日本語チートシートを公開してくださりました。私もちょくちょくコントリビュートさせていただいておりますが、このチートシートにIDAPythonの基本機能がほぼ掲載されています。

github.com

これで多少知名度も上がり日本人の方もIDAPythonを書くハードルが大幅に下がったのではないかとは思いますが、現状具体的な使用例まではないので、「基本の型はあるが次に何をしたらいいのかわからないなー」という心情の方がいらっしゃるかもしれません。

そこで本稿では、CTFレベルでの簡単なチャレンジからIDAPythonの実践的な使い方を学び、最終的にはEmotet という本物のマルウェアに対してIDAPythonを用いた自動解析ができるまでを取り扱ってみたいと思います。少々長くなると思いますが、どうぞよろしくお願いします。

環境情報 (と、ちょこっと環境構築のはなし)

IDAPython スクリプトを作成する上で環境情報が違うと予期した動作をしない可能性があるため、私が使用した環境情報を簡単に残しておきます。python やIDA Proのバージョンなどは基本的に最新のものを使用しています。

ソフトウェア名 バージョン情報
IDA Pro 8.2
IDAPython v7.4.0
python 3.11.1

なお、作業環境はVMWare Workstation上に構築した Windows 10 イメージに FlareVM をインストールした後、さらに VSCode をインストールして VSCode 上で作業しています。特に、本物のマルウェアを扱う場合はAVソフトやEDRのアラートを発生させないためにも、VM上で作業するほうが好ましいです。
VSCode については好みですが、VSCode には IDACode というIDAPythonを書きやすくするためのプラグインを導入できるのでこちらを使うことを強く推奨します。図の下線部のように、PYTHONPATH 環境変数にIDA Proのpythonインストールパスを設定するだけで使用できます。

Windows 環境へのIDACode導入


図のように import idc などと入力してImport Errorが発生しなければ準備OKです。これでモジュールから使用できるメソッドの一覧や入出力を簡単に調査できるようになったので、格段に書きやすくなっているでしょう。

VSCode からのIDAPython動作確認

Exercise 1: CTF 形式の問題でIDAPythonを使ってみる

それでは、マルウェアではないCTF形式の問題を使ってIDAPythonがどんなものなのかというのを体験するところからはじめましょう。*1
取り上げる問題は、Flareon-9 challenge の4番目の問題、darn_mice です。

この問題は非常にシンプルで、簡単に書くと「flagが出力されるような正しい入力値を求めろ」という問題です。正解の値は 0x401025 ~ 0x4010B1 までのmov命令で1bytesずつスタックに格納されるようになっていて、『入力値の n bytes目の値 - スタックに格納された n 番目の値 = 0xc3 を満たせばよい』という作りになっています。試しに正解となる1byte目の値を求めてみると、『0xc3 - 0x50 = 0x73 (= s)』となりますね。
さらに詳しい解析を見たい方はハマショーさんが書いたwriteupを参考にしてください。

問題バイナリのスタック格納部分


さて、それではこの問題を解いてみたいのですが、最も直感的にやる方法は、mov 命令の第二オペランドにある即値を [0x50, 0x5E, ...] のような形で配列の形に写経し、for文を回してflagを求める方法ではないかと思います。しかし、写経だと1byteでも値を写し忘れたらflagが正しく取れなくなってしまう可能性がありますし、もっと長い量の写経が必要だった場合虚無作業になります。なんとか自動化したいです。

......このような場面で、IDAPython は非常に効力を発揮します。IDAPython はmov命令を解釈して第二オペランドにある即値を取得することができます。それではこれをどうやって実現していくかというと至ってシンプルで、以下の流れを順番にやっていくだけです。順番にやっていきましょう。

  1. 解析した結果「こうやったら自動化できそうだなー」を言語化する。
  2. IDAPython チートシートからそれを実現できるモジュール, メソッドを探してくる
  3. コードを書いて実行!!

1. 「こうやったら自動化できそうだなー」という処理を言語化する

今回のアセンブリコードは非常にシンプルなので、結構簡単に言語化することができますね。以下のようになると思います。

  1. flagがスタックに格納されるmov命令の開始アドレスを知る (<= 画像から 0x401025 ってわかる!!)
  2. 現在のアドレスから取得した命令(ニーモニック)とオペランドを取得する (IDAPython のメソッドを調べる必要あり)
  3. 「0xc3 - 第2オペランドの値」 を計算して、flagのn bytes目をループして求める(ループのコード書くだけ)
  4. 次の命令の開始アドレスを手に入れる (IDAPython のメソッドを調べる必要あり)
  5. もし次の命令がmovでないか第二オペランドの値が 0 だったらflagに必要な情報ではないのでループ終了!!

2. IDAPython チートシートからそれを実現できるモジュール, メソッドを探してくる

上記のコードを実現するために足りない情報は、以下の3つです。

  • 現在のアドレスにあるニーモニックの情報を取得する方法 (mov 命令かのチェック)
  • 現在のアドレスにあるオペランドの情報を取得する方法 (mov 命令で使われている第2オペランドのチェック)
  • 次の命令のアドレスを取得する方法 (例えば、0x401025 の命令の次の命令は 0x401029 であることが画像からわかると思うが、これは命令の長さによって異なるので固定のオフセットになるとは限らない。)*2

これをチートシートから探してきましょう。おそらく、冒頭から『逆アセンブル』までを探すだけでほぼ全ての情報が手に入るはずです。以下のメソッドを使うことで実現できそうです。

やりたい処理 IDAPythonのメソッド
現在のアドレスにあるニーモニックの情報を取得 idc.print_insn_mnem
現在のアドレスにあるオペランドの情報を取得 idc.get_operand_value
次の命令のアドレスを取得 idc.next_head

一応、何度も例に挙げている 0x401025 のアドレスを使ってデバッグしてみましょう。IDA Pro画面下部にあるOutput Windowの最下部に Python というボタンと入力フォームがあるので、そこにコードを書くことでIDAPythonをさくっと動かせます。それぞれのメソッドが想定通りの動きをしていますね。

IDAPython のデバッグ実行

3. コードを書いて実行する!!

さて、それではpythonでコードを書きましょう。idc.next_head が無限ループに陥らないよう注意しながら1byteずつループするようなアルゴリズムを組めばOKです。idc.next_head が次の命令を取得できなくなった場合 idc.BADADDR を返すので、アドレスがこの値と同じになったらループを終了させます。*3これを実現したコードが下記となります。コメントを除けば15行くらいで書けますね。*4

import idc

HEAD = 0x401025


def solve(start_ea: int) -> bytearray:
    answer: list[int] = []
    while start_ea != idc.BADADDR:
        # 開始アドレスのニーモニックとオペランドを取得する
        mnem: str = idc.print_insn_mnem(start_ea)
        value: int = idc.get_operand_value(start_ea, 1)
        # 命令が "mov" または 第二オペランドが 0 でなくなったら
        # flag とは関係ないのでループ終了
        if mnem != "mov" or value == 0:
            break
        # flag の計算
        answer.append(0xC3 - value)
        # 解析対象のアドレスを次の命令へ移動
        start_ea = idc.next_head(start_ea)
    return bytearray(answer)


if __name__ == "__main__":
    answer: bytearray = solve(HEAD)
    print(f"FLAG is `{answer.decode()}`")

このコードをファイルとして保存した場合、IDA Proの上部メニューにある "File => Script file..." からpythonファイルを指定すればIDAPythonコードを実行してくれます。

IDAPython ファイルの指定

実行結果は Output Window に出力されるので確認してください。無事に欲しい情報が手に入っているはずです。簡単な例ですが、これにてバイナリの自動解析ができましたね!!!

Exercise 1 実行結果

Exercise 2: Emotet を解析して難読化された文字列を復号する

さて、簡単なCTF形式の問題でIDAPythonを活用してみましたが、「こんなレベルの内容が実際のマルウェア解析の現場でも活きるんかいなー」と疑問に思った方もいらっしゃるかもしれません。しかしながら、マルウェア解析の現場でもこのレベルのスクリプトを書くだけで楽になる場面というのは非常に多く存在します。

その一例として、今回は一昔前に使われた Emotet というマルウェアを取り上げてみます。昔のEmotetを取り上げた理由は、初心者でも解析できる適度な暗号化処理が、ほぼ全ての文字列に施されていて説明に使いやすかったからです。(最新のタイプはかなり複雑なので、今回は扱いません。) インターネット上にuploadされている Emotet DLL はpackされているものが非常に多いので外観は複雑に見えますが、unpack後のバイナリは機能があまり多くなくそこまで複雑でもありません。(Unpack 後のEmotetはGithubにも置いてありますが、本物のマルウェアであるため取り扱いには十分注意してください。取り扱いに自信がない方はスルーしてコードだけ確認をしてください。) しかし、難読化の処理はしっかり施されているので、解析者側で適切な処理をしなければ非常に読みにくい状態になっています。例えば、Emotet が内部で使用する文字列は以下のようなデータ構造を持っており、内部にある復号用の関数を通して初めて平文を確認することができます。

オフセット サイズ データ
0 4 XOR KEY
4 4 文字列のサイズ (KEY で暗号化された状態)
8 文字列のサイズ 文字列 (KEY で4bytes単位で暗号化された状態)

一例を挙げると、0x10001610 に存在するデータは、 %s%s.dll という文字列を暗号化したものですが、表層上は以下の画像にように見え、mw_string_decrypt という関数の第4引数として渡されてデコードされます。*5

難読化文字列の表層情報とデコード関数

難読化文字列の可視化からはじめてみる

まずは難しいことは考えず、難読化文字列を可視化するところからはじめてみましょう。IDA Pro は逆アセンブルの画面にコメントを追加したり、変数名*6を適切に追加することで解析を補助することができます。ここでは難読化文字列の開始アドレスを与えた場合、文字列をデコードしてコメントの追加と変数名の変更をするIDAPythonスクリプトを書いてみましょう。上記のコードを実現するために足りない情報は、以下の3つです。

  • アドレスからデータを取得する方法 (文字列の開始アドレスを与えたらその領域にあるデータを取得する)
  • コメントの追加方法 (デコード後の文字列をコメントとして追記する)
  • 変数名の変更方法 (文字列の開始アドレスで使われている変数名をデコード後の文字列に変える)


これもまたチートシートからそれっぽいメソッドを探してきましょう。最終的に以下のメソッドに辿り着くのではないかと思います。

やりたい処理 IDAPythonのメソッド
アドレスからデータを取得する方法 idc.get_bytes
コメントの追加方法 idc.set_name
変数名の変更方法 idc.set_cmt

ここまでできれば、あとは冒頭でお話しした文字列のデータ構造の解釈をそのままpythonスクリプトに落とし込むだけですね。コメントを抜きにすれば、Emotetの文字列をデコードするコードも20行程度で書けると思います。

from dataclasses import dataclass
import idc

SAMPLE_STRING_ADDR = 0x10001610


@dataclass
class EmotetString:
    xor_key: bytes
    size: int
    encrypted_text: bytes

    def __init__(self, head_addr: int):
        # Emotet 文字列のデータ構造に従ってデータを分解する
        self.xor_key: bytes = idc.get_bytes(head_addr, 4)
        self.size: int = self.__calc_size(idc.get_bytes(head_addr + 4, 4))
        self.encrypted_text: bytes = idc.get_bytes(head_addr + 8, self.size)

    def __calc_size(self, encrypted_size: bytes) -> int:
        int_xor_key: int = int.from_bytes(self.xor_key, "little")
        int_enc_size: int = int.from_bytes(encrypted_size, "little")
        # XOR の鍵から文字列のサイズを計算
        return int_xor_key ^ int_enc_size

    def decode_string(self) -> str:
        dec_string: list[int] = []
        # 復号アルゴリズムに沿って、4bytes ずつXORでデコード
        for i, enc in enumerate(self.encrypted_text):
            key: int = self.xor_key[i % 4]
            dec_string.append(key ^ enc)
        return bytearray(dec_string).decode()


if __name__ == "__main__":
    string = EmotetString(SAMPLE_STRING_ADDR)
    decoded_string: str = string.decode_string()

    # デコードできた文字列の名前に変数名を変更する
    idc.set_name(SAMPLE_STRING_ADDR, decoded_string, idc.SN_NOCHECK)

    # デコードできた文字列をコメントとして逆アセンブルの画面に残す
    idc.set_cmt(
        SAMPLE_STRING_ADDR,
        f"size: {string.size}, string: {decoded_string}",
        True,
    )
    print(f"[{hex(SAMPLE_STRING_ADDR)}]: {decoded_string}")

ちょっとだけコードに解説を入れると、EmotetString クラスのイニシャライザで idc.get_bytes を利用して鍵部分のデータを取得し、それを使ってサイズのデータをデコードし、取得できたサイズを元にさらに文字列分のデータを取得するようになっています。あとは decode_string のめっちゃシンプルなメソッドを呼べば復号できるという流れです。

あとは Exercise 1 と同様のやり方でpythonファイルを実行してみましょう。逆アセンブル画面で変数名の変更とコメントが入ったことがわかります。いい感じに可視化されました!!

Exercise 2 実行結果

Exercise 3: Emotet の難読化された文字列を全て復号する

さて、IDPython を使った文字列のデコードまで成功しましたが、Exercise 2 のスクリプトをそのまま運用するのは少々現実的ではありません。なぜなら現在のスクリプトでは『復号対象の文字列の開始アドレスを知っていなければ動かない』からです。Emotet の内部で使用されている文字列はほぼ全て暗号化されているため、一々手動でアドレスを指定していたらキリがありません。なんとかして、難読化された文字列の開始アドレスを自動で取得する方法を考えましょう。

Emotet のコード全体から復号関数を探す

難読化文字列の開始アドレスを知る最も直感的なアプローチは、復号に使用される関数に着目することです。Emotet が難読化文字列を復号するには、必ず復号用の関数に文字列の開始アドレスを引数として渡す必要があります。なので、文字列の復号に使用する関数を Emotet 全体から見つけることに挑戦してみましょう。やり方はいくつか考えられますが、静的解析をして得られた特徴的なパターンを、正規表現を用いてコード全体から探してくることが最も簡単なのではないかと思います。私の場合、Emotet が XOR する際に多用するshr命令のビットシフトを捕まえる方針にしました。

Emotet の文字列復号処理固有だと思われるパターンの抽出

これを私なりにパターンにすると以下のように書けます。

 STR_FUNC_PATTERN: bytes = rb"\xc1.\x08.{3,10}\xc1.\x10.{3,10}\xc1.\x08"


あとは、Emotet のコードがある領域からこのパターンにマッチするアドレスを探せばよさそうです。Emotet の全体からコードがある領域を探すには、チートシートにある "セグメント" の部分を利用します。コードは ".text" という名称のセグメントにあるので、そこの領域のデータをまるっと確保しましょう。データの取得については Exercise 2 で使った idc.get_bytes が使えますね。これをIDAPythonスクリプトにすると以下のようになります。

    def get_text_segment_bytes() -> dict[int, bytes]:
        # ".text" セグメンツ領域の全てのデータを取得
        mem: dict[int, bytes] = {}
        for segment in idautils.Segments():
            segment_name: str = idc.get_segm_name(segment)
            if segment_name != ".text":
                continue
            segment_start: int = idc.get_segm_start(segment)
            segment_end: int = idc.get_segm_end(segment)
            segment_data = idc.get_bytes(
                segment_start,
                segment_end - segment_start,
            )
            # データを辞書形式で保存: `{0x10001000: b"\xde\xad\xbe\xef......."}`
            mem[segment_start] = segment_data
        return mem

セグメンツの開始アドレスを辞書のkeyとして保存しているのは、 RVA *7を取得できるようにするためです。例えば、 ".text" セグメンツの開始アドレスが 0x10001000 であった場合、取得したデータ内のパターンが0x1000番目のオフセットで見つかれば、0x10001000 + 0x1000 = 0x10002000 という計算で RVA を求めることができます。つまり、この関数で取得したデータからパターンを抽出することで、復号関数のアドレスを求めることができる可能性があります。

復号関数の引数から文字列の開始アドレスを探す

上記の結果から、パターンにマッチするデータのアドレスにアクセスできることがわかりました。さらにチートシートを確認すると、ida_funcs.get_func メソッドで生成されるオブジェクトの start_ea にアクセスすることで、該当のアドレスが存在する関数の開始アドレスにアクセスできることもわかります。

func_start_addr: int = ida_funcs.get_func(addr).start_ea

よって文字列復号関数のアドレスまでわかるので、この関数で文字列復号のアドレスがどのように使われているかがわかれば自動化が達成できそうです。ここで、先ほど作成したパターンから復号関数を探すスクリプトを実行してみましょう。0x10005e78 と 0x10017af5 の2つのアドレスが出力されたはずです。これら2つの関数がどのようにして使われているか呼び出し元のアドレスから確認してみましょう。するとどうでしょうか、前者は難読化文字列のアドレス*8を関数の第二引数として、後者は第四引数として使用し復号していることがわかります。そして 0x10017af5 で使用されている第二引数にも注目してみましょう。こちらは "dword_..." のような即値が使われていません。

文字列復号関数の引数比較

つまり、『パターンが使われている関数の呼び出しもとを確認して、第二引数に即値が使われている場合はそれを難読化文字列の先頭アドレスとし、そうでない場合は第四引数として使われている即値を文字列のアドレスとする』 というスクリプトを組んで Exercise 2 のスクリプトと組み合わせば、自動化の夢が実現しそうです。

IDAPython スクリプトにする

まずは使えそうなIDAPythonのメソッドをチートシートから探しましょう。まず関数の呼び出しもとのアドレス取得については、"参照元のアドレス一覧を表示"というそのまま使えそうな項目があるのでこれを使います。以下のように使用することで、参照元のアドレス一覧をリストで取得できます。

[ref.frm for ref in idautils.XrefsTo(addr)]

それでは、参照元のアドレスから引数として利用されている難読化文字列のアドレスを探すコードを書きましょう。引数を探すためには関数の呼び出しアドレスより前を辿る必要がありますが、それに使用できるメソッドは idc.prev_head です。これを利用すれば、Exercise 1 と同じようにループで命令を辿ることができます。これで、目的の引数を見つけてくればOKですね。特別に使用しているメソッドは idc.get_operand_type というオペランドのタイプを確認するためのメソッドです。このメソッドのreturnが idc.o_imm と一致していればそれは即値なので、文字列のアドレスといえるでしょう。その他は idc.print_insn_mnem, idc.get_operand_value といった Exercise 1 でも利用したメソッドを利用しているので、難しくないのではないかと思います。

    def get_string_addr_from_args(addr: int) -> Optional[int]:
        push_cnt: int = 0
        # 30を巻き戻る限界とする
        for _ in range(30):
            addr = idc.prev_head(addr)
            mnem: str = idc.print_insn_mnem(addr)
            ope_1st: str = idc.print_operand(addr, 0)
            ope_type: int = idc.get_operand_type(addr, 1)

            # 第二引数が難読化文字列のアドレスとして利用されている関数のパターン
            if mnem == "mov" and ope_1st == "edx" and ope_type == idc.o_imm:
                return idc.get_operand_value(addr, 1)
            # 第四引数が難読化文字列のアドレスとして利用されている関数のパターン
            elif mnem == "push" and push_cnt == 1:
                return idc.get_operand_value(addr, 0)
            # 第四引数まで遡るために、1回目のpushだけカウントアップする
            elif mnem == "push":
                push_cnt += 1

        return None

さて、これで全ての難読化文字列があるアドレスが取得できたので、Exercise 2 のスクリプトと組み合わせれば終わりですね。コードは長いのでGithubのプロジェクトに置いたものを参考にしてください。*9それでは実行してみましょう。

Exercise 3 の実行結果

いい感じに文字列が復号されました! これで次に Emotet の亜種を解析する場合は、このスクリプトを先に実行するだけでコード全体がすごく見やすくなりそうです。一つマルウェア解析の自動化完了ですね。

おわりに

意外と要望が多かった IDAPython を用いたマルウェア解析の自動化話を取り上げてみました。今回使用したスクリプトと検体はGithubに公開しているため、誰でも手を動かしながら再現を取れるようにしています。IDAPython の勉強してみたい方の参考になれば幸いです。
もし本投稿の内容を「完全に理解した」状態になった方がいらっしゃれば、さらなる自動化スクリプトの機能を追加してみましょう。本スクリプトが行う難読化解除手法は一部の文字列デコードしかありませんが、Emotet には

  • 別の関数による文字列難読化
  • API の hash 化
  • Control Flow Flattening による処理の平坦化

といった多種多様な難読化手法を備えています。それぞれの難読化解除スクリプトを実装して、Emotet のコードをなるべく多く自動で解析してみてください。

github.com


また、今回の記事のように『IDAPython使ってゴリゴリマルウェア解析したいなー』という方がいらっしゃいましたら絶賛募集中ですので、是非ともリクルートページを一読していただければなと思います!!

nflabs.jp


これにてNFLaboratoriesのアドベントカレンダーは終了です。ありがとうございました。もし皆様にとって有益な記事がありましたら、Twitterのフォローと本ブログの「読者になる」ボタンのクリックをしていただけるとすごく嬉しいです。

それでは、また次の記事でお会いしましょう🥳

twitter.com

*1:なお、本稿はIDAPythonの使い方をメインに扱うので、バイナリの具体的な読み方などは扱わず「こう読めますね!!」の体で進めます。ご容赦ください。

*2:使用されている命令が mov ではなく nop の場合は1byte命令なので1byte分の長さしかアドレスに違いは出なくなります。つまり、addr += 4 のような毎回固定のオフセット増減で操作できません。

*3:ここでは割愛しますが、 idc.BADADDR による終端制御を考えたくない場合は idautils.Heads メソッドを使用するとよりスマートに実現できます

*4:Github から見たい方は、こちらのコードを参照してください。

*5:mw_string_decrypt は、Hex-raysが提供するLuminaサーバのメタデータを利用すると自動的に命名が適用されます。

*6:画像で dword_10001610 と書かれている部分です

*7:Relative Virtual Address: 相対仮想アドレスのことで、ベースアドレスからのオフセットを表現しています。本稿で説明しているアドレスも全てこれにあたります。

*8:dword_.... と書かれている部分を指します

*9:https://github.com/minanokawari1124/idapython_exercise/blob/main/exercise_for_emotet/deobfuscate_emotet.py