Dynamic Disassembly and Binary Analysis
gdbトレース、ファジング、シンボリック実行、CFG
マルウェア解析の現場では、未知のバイナリがやってくる。ソースコードなんかない。シンボル情報もストリップされてる。中身が何をするかは実行してみないとわからない。 でも「実行してみる」にも色々ある。静的逆アセンブリはバイナリを実行せずに命令列を読み解く手法で、動的逆アセンブリは実際に実行しながら命令をトレースする手法。どっちも必要だけど、この記事は動的のほうの話。
静的解析の限界
まず動的解析がなぜ必要かから。静的逆アセンブリにはデータとコードの区別、間接呼び出しの解決、難読化への対応という根本的な課題がある。x86はバイト列のどこからでもデコードできる可変長命令セットだから、「ここからが命令」という保証がない。間接ジャンプ jmp *%rax のターゲットは実行時のレジスタ値に依存するから、静的には解決不可能。
マルウェアはさらに厄介。アンチ解析チェック(デバッガ検知、仮想環境検知)を通過すると本当の振る舞いを隠して、解析環境ではおとなしくする。静的解析だけでは本当の挙動が見えない。
動的解析ではレジスタやメモリの具体的な内容がリアルタイムで利用できるから、これらの問題の多くが解消される。実行が特定のアドレスに到達したら、そこに命令があるのは確実。間接ジャンプのターゲットも実行時には確定している。動的逆アセンブリは実行トレーサ(execution tracer)とも呼ばれる。
gdbでバイナリをトレースする
Linuxには「fire-and-forget」方式で実行をトレースする標準ツールが存在しない。WindowsにはOllyDbgがあるのに。標準ツールだけで最も簡単にやる方法は、gdbのコマンドを組み合わせること。
/bin/ls をgdbでトレースしてみる。
$ gdb /bin/ls
(gdb) info files # セクション情報とエントリポイントを確認
エントリポイントが 0x4049a0 だとわかるから、ここにブレークポイントを設定する。ページングを無効にして(画面出力の一時停止を防ぐため)、ログをファイルに書き出す設定にする。
(gdb) b *0x4049a0 # エントリポイントにブレーク
(gdb) set pagination off # ページング無効化
(gdb) set logging on # gdb.txtにログ出力
(gdb) set logging redirect on
(gdb) run # 実行開始、ブレークで停止
(gdb) display/i $pc # PCの命令を表示
(gdb) while 1 # 無限ループで
>si # シングルステップ実行
>end
si はシングルステップ実行。1命令ずつ実行して、その命令をログに記録する。/bin/ls みたいな小さなプログラムでも、実行完了までに数万から数十万の命令がトレースされる。
$ wc -l gdb.txt
614390 gdb.txt
614,390行。/bin/ls でこれ。gdbのメタ情報も含まれているから、実際の命令だけをフィルタリングする。
$ egrep '^=> 0x[0-9a-f]+:' gdb.txt | head -n 20
=> 0x4049a0: xor %ebp,%ebp
=> 0x4049a2: mov %rdx,%r9
=> 0x4049a5: pop %rsi
=> 0x4049a6: mov %rsp,%rdx
=> 0x4049a9: and $0xfffffffffffffff0,%rsp
=> 0x4049ad: push %rax
=> 0x4049ae: push %rsp
=> 0x4049af: mov $0x413c50,%r8
=> 0x4049b6: mov $0x413be0,%rcx
=> 0x4049bd: mov $0x402a00,%rdi
=> 0x4049c4: callq 0x402640 <__libc_start_main@plt>
エントリポイント 0x4049a0 で xor %ebp,%ebp から始まる。%ebp をゼロクリアして、__libc_start_main を呼び出す。CRT(C Runtime)の初期化コード。main 関数のアドレスは 0x402a00 に %rdi 経由で渡されている。
フィルタリングされた出力は、gdbの生ログよりずっと読みやすい。実際に実行された命令だけが残るから、静的逆アセンブリの「この命令は本当に実行されるのか?」という疑問がない。
コードカバレッジの問題
動的解析の最大の欠点はコードカバレッジ。実行されたコードパスしか見えない。ロジックボムのように特定のタイミングでだけ悪意を発動するマルウェアは、通常の動的解析では検出できないかもしれない。マルウェアがデバッガ検知で解析中であることを察知したら、本当の振る舞いを隠す。
静的解析なら全コードパスが見えるけど、どのパスが実際に実行されるかはわからない。動的解析は実行されたパスだけが見えるけど、そのパスが全体のごく一部かもしれない。銀の弾丸はない。だから両方を補完的に使う。
コードカバレッジを改善する手法は大きく3つ。テストスイート、ファジング、シンボリック実行。
テストスイート
一番シンプル。既知のテスト入力でバイナリを実行して、コードパスをカバーする。Makefileの test ターゲットで make test を実行すれば、テストケースごとにプログラムが走って出力が diff で検証される。
PROGRAM := foo
test: test1 test2 test3 .....
test1:
$(PROGRAM) < input > output
diff correct output
PROGRAM 変数を上書きすれば動的解析ツールの中でバイナリを実行できる。make test PROGRAM="gdb foo" でgdbの中で各テストケースを走らせれば、テストがカバーする全コードパスを動的に解析できる。
テストスイートがあるのはオープンソースか自分で開発したソフトウェアの場合で、マルウェアには当然ない。マルウェアを解析するときは自分で入力を作る必要がある。
ファジング
ファザー(fuzzer)は新しいコードパスをカバーするための入力を自動的に生成するツール。入力の生成方法で2つに分かれる。
生成ベースは入力フォーマットがわかっている場合に有効で、フォーマット仕様に従って入力を一から生成する。変異ベースは既存のテスト入力を何らかの方法で変異させて(ビットフリップ、バイト挿入、削除など)新しい入力を作る。AFL(American Fuzzy Lop)は変異ベースの代表格で、カバレッジガイデッド。つまり新しいコードパスをカバーする変異を優先的に保持する。
ファザーの限界は、複雑な if/else の条件シーケンスに埋もれたコードパスにはたどり着けないこと。ファザーの「推測」が条件を満たさなければ、結局そのパスは未到達のまま。クラッシュが見つかるまでランダムに入力を変化させ続けるから、計算時間が膨大になることもある。
AFL、MicrosoftのProject Springfield、GoogleのOSS-Fuzzが有名。AFLは無償で使えるし、オンラインドキュメントも充実している。
シンボリック実行
ファジングより高度な手法。通常のプログラム実行では全ての変数に具体的な値が入るけど、シンボリック実行では変数を「シンボリックな値」(数学の記号のようなもの)で表してプログラムを実行する。
x = int(argv[0]) # x → シンボル α₁
y = int(argv[1]) # y → シンボル α₂
z = x + y # z → α₁ + α₂
if x < 5:
foo(x, y, z) # パス制約: α₁ < 5
else:
bar(x, y, z) # パス制約: α₁ ≥ 5
シンボリック実行はパス制約(path constraint)を計算する。if (x < 5) を通過したら、 というパス制約が追加される。このパス制約のリストを制約ソルバー(constraint solver、Z3やSTP)に渡すと、制約を全て満たす具体的な値を返してくれる。
たとえば の制約があるとき、制約ソルバーは みたいな解を返す。この具体値でプログラムを実行すると、if 分岐を通過する。次に制約を にひっくり返して制約ソルバーに解を求めれば、else 分岐をカバーする入力が得られる。
コードカバレッジの観点では理想的。全分岐の両方向をカバーする入力を体系的に生成できる。だけど実際問題として、分岐命令の個数に応じてパスの数が指数的に増えるから、シンボリック実行はすぐにスケーラブルではなくなる。パス制約を解決するための計算量も膨大になりうる。
ファジングとシンボリック実行を組み合わせる手法(動的テイント解析)も研究されている。ファジングで大まかにカバーしつつ、到達しにくいパスだけシンボリック実行で狙い撃ちにする。
制御フローグラフ(CFG)
逆アセンブルしたコードを関数に分割するのは第一歩だけど、関数の中身を理解するには構造化が必要。制御フローグラフ(CFG)は関数内のコードを基本ブロック(basic block)に分割して、ブロック間の遷移をグラフで表現する。
基本ブロックとは、先頭以外にジャンプターゲットがなく、末尾以外にジャンプ命令がない命令列。つまり「一度入ったら最後まで直線的に実行される」コードの塊。分岐命令で基本ブロックが終わって、次のブロックに条件分岐やフォールスルーで遷移する。
IDA Proのようなツールは逆アセンブルした関数のCFGを自動生成してくれる。CFGを見れば、ループ構造、条件分岐、関数の複雑さが一目でわかる。手動解析でも自動解析でも、CFGはバイナリ解析の基本中の基本。
コールグラフ
CFGが関数内の制御フローを表すのに対して、コールグラフは関数間の呼び出し関係を表す。
関数 が と を呼び出して、 と がそれぞれ を呼び出す。マルウェア解析では、コールグラフで「怪しい関数」(ネットワーク通信、ファイル操作、レジストリ変更)を特定してから、その関数のCFGを詳細に解析するのが定石。
全ての関数のCFGを和集合にしたものはICFG(Interprocedural CFG)と呼ばれる。ICFGでは関数境界を越えた制御フローが見えるけど、そのぶん巨大で複雑になる。
.eh_frameセクションによる関数検出
ELFバイナリの .eh_frame セクションにはDWARFベースのデバッグ情報(スタック巻き戻し情報)が含まれていて、バイナリ内の全関数の境界情報がある。gccが -fno-asynchronous-unwind-tables フラグなしでコンパイルしたバイナリなら、ストリップされていてもこの情報が残っている。
.eh_frame はC++の例外処理だけじゃなくて、backtrace() や __builtin_return_address(n) でも使われるから、gccが生成する全てのバイナリにデフォルトで含まれる。Ryan O'Neill(ElfMaster)がこの手法を最初に紹介して、.eh_frame をパースして関数のアドレスとサイズを取り出すコードを公開している。
シグネチャベースの関数検出(既知のプロローグパターン push %rbp; mov %rsp,%rbp を探す)よりも正確で、コンパイラの最適化でプロローグが省略されたケースでも対応できる。
マルウェアのシグネチャ検出との関係
アンチウイルスソフトがやっているシグネチャ検出は、バイナリの規則的なバイト列(YARAルールなど)をパターンマッチングで探す静的手法。既知のマルウェアファミリには有効だけど、バイト列を少し変えるだけで回避できる。ポリモーフィックマルウェアは実行のたびにコードを暗号化・復号するから、静的なバイトパターンが毎回変わる。
動的解析はこの限界を超える。実行時にメモリ上で復号されたコードをトレースすれば、暗号化前の本当の命令列が見える。サンドボックスでマルウェアを実行して挙動を観察する手法(Cuckoo Sandbox等)も動的解析の一種。
だけど動的解析にも限界がある。マルウェアがサンドボックスを検知して挙動を変える(sandbox evasion)ケースは増えている。VMwareのI/Oポートをチェックする、RDTSCで実行時間を計測する、特定のプロセス名(vmtoolsd, vboxservice)の存在を確認する。検知されたら無害な振る舞いだけを見せて、本当のペイロードは実行しない。
結局、静的解析も動的解析も単独では不十分。両方を組み合わせて、さらにファジングやシンボリック実行でコードカバレッジを広げるのがモダンなんだよね。