IDAPythonによる解析の自動化をやってみる(静的解析編)
IDAPythonによる解析の自動化をやってみる
逆アセンブラ,デコンパイラのデファクトスタンダードなツールの一つにIDA Proがある.IDA ProはPythonを使ったスクリプティング機能を提供しており,この機能を使うことでプラグインといった形でIDA自身の機能を拡張したり,面倒な手動解析を自動化することができる.このエントリでは,マルウェア解析を例としてIDAPythonを使った解析工程の一部の自動化をやってみる.
シナリオ
マルウェア作者は悪意あるコード部分で使用するAPIを動的にインポートすることで,静的解析のハードルを上げようと試みることがある.つまり実行の初期の段階でグローバルな変数にAPIのアドレスを保存しておき,API使用時には関数ポインタとしてこれを呼び出す.こうすることにより静的解析を行う解析者からはcall命令でデータ領域のある値へジャンプするように見え,一見して動作がわからなくなる.
具体的には,WindowsにはAPIのアドレスを取得するAPIとしてGetProcAddressがあり,引数に使用したいAPI名を渡して使用する.マルウェア作者は解析難度を上げる狙いでこのAPI文字列を一度暗号化したうえで,実行時に復号し動的なAPIのインポートを行うことがある.解析者はインポートするAPI名を特定するため動的解析や自作の復号スクリプトを用いてこれを特定する.
ここでは以下のステップで動的解決されたAPI名を操作し,解析の自動化にトライしてみる.
参考
IDAPythonに関する日本語情報はかなり少なく,基本的に既存のスクリプトを参考にIDA内臓のインタープリターで適宜実行して出力を確認しながらスクリプトを作成していくことになる.またIDAPythonは7.4以降からAPI名に変更があったため,古い記事を読んで混乱しないよう注意する必要がある.以下はこのエントリを書く際に参考にした資料.
- hex-raysのIDA SDKのドキュメント
- API名の新旧比較表.IDAのバージョンアップに伴いAPI名が変わった.IDAPythonに関する昔の記事をみてAPIの現在と昔のバージョンの対応に困ったらここをみるとよい
- IDAのコマンドライン実行時のオプションについて
- IDAPythonのCheatSheet
- JSAC ワークショップの資料.IDAPythonではなくGhidraのスクリプトだが解決したい問題などは共通
- The IDA Pro Book, 2nd Edition
- paloalto公開のIDAPythonを使った解析自動化のチュートリアル.日本語記事はパート6しか公開されていないよう?
環境
IDA Pro 7.6 Python 3.9 Visaul Studio 2019
解析対象コード
解析対象にする実行ファイルのコードを以下に示す.
#include <Windows.h> #include <stdio.h> using fAdjustTokenPrivileges = BOOL(WINAPI*)(_In_ HANDLE TokenHandle, _In_ BOOL DisableAllPrivileges, _In_opt_ PTOKEN_PRIVILEGES NewState, _In_ DWORD BufferLength, _Out_writes_bytes_to_opt_(BufferLength, *ReturnLength) PTOKEN_PRIVILEGES PreviousState,_Out_opt_ PDWORD ReturnLength); fAdjustTokenPrivileges g_AdjustTokenPrivileges = nullptr; char* DecryptString(char* Buffer, size_t BufferCount, const char* in) { int i; char* orig = Buffer; char key[56] = { 0x72,0x70,0x74,0x42,0x41,0x63,0x64,0x45,0x32,0x2e,0x71,0x4d,0x52,0x49,0x55,0x4a,0x6f,0x35,0x77,0x4b,0x36,0x47,0x73,0x56,0x4e,0x46,0x66,0x62,0x61,0x6a,0x58,0x67,0x78,0x34,0x59,0x5a,0x53,0x37,0x65,0x6d,0x79,0x30,0x43,0x7a,0x44,0x39,0x31,0x4c,0x6c,0x57,0x69,0x76,0x33,0x4f,0x75 }; snprintf(Buffer, BufferCount, "%s", in); if (!*Buffer) return orig; do { i = 0; while (*Buffer != key[i]) { if (++i >= 0x37) goto PASS; } *Buffer = key[(i + 8) % 0x37u]; PASS: ; } while (*++Buffer); return orig; } void SolveAPI() { char buf[0x100] = { 0 }; char* DLLName = nullptr; char* APIName = nullptr; // vOz6lCDrpOyy -> Advapi32.dll DLLName = DecryptString(buf, 0x32, "vOz6lCDrpOyy"); HMODULE hDLL = LoadLibraryA(DLLName); if (hDLL) { // vOG1UWT2kXnPLCzCyXVXU -> AdjustTokenPrivilege APIName = DecryptString(buf, 0x32, "vOG1UWT2kXnPLCzCyXVXU"); g_AdjustTokenPrivileges = (fAdjustTokenPrivileges)GetProcAddress(hDLL, APIName); } } int main() { SolveAPI(); // AdjustTokenPrivilegeを使ったmalicousなコードが続く return 0; }
暗号化部分の実装は疑似的なものでご容赦願いたい.これをVisual StudioでコンパイルしSolveAPIのデコンパイル結果をIDAで見ると次のようになる.
dword_403384
にGetProcAddressの戻り値が代入されているためこれが関数ポインタとして後続の処理でcallされると察しはつくが,シンボルがないためどのAPIかまでは特定できない.
暗号化された文字列を復号する
まずは暗号化された文字列を復号するスクリプトを作成する.復号関数をPythonで書き直すと以下のようになるだろう.
def decrypt(enc: str) -> str: key = "rptBAcdE2.qMRIUJo5wK6GsVNFfbajXgx4YZS7emy0CzD91LlWiv3Ou" dec = "" for e in enc: j = 0 for k in key: if e == k: break if j >= 0x37: break j = j + 1 if j >= 0x37: dec += e else: dec += key[(j+8) % 0x37] return dec
この関数に暗号化された文字列を渡せばよさそうだ.まずはアドレス(0x00402114)を決め打ちでやってみる.このアドレスは暗号化された文字列vOG1UWT2kXnPLCzCyXVXU
が保持されているアドレスである.このアドレスをread_string関数に渡すと終端文字が見つかるまで1バイトずつ読んで文字列として返してくれる.
画像左下部に実行結果が出力されている.うまく復号できているようだ.
すべての暗号化された文字列の取得
先の例では暗号化された文字列vOG1UWT2kXnPLCzCyXVXU
のアドレスを決め打ちでスクリプトを作成した.次はこのアドレスの取得もスクリプトで自動化させる.そのために以下のようなコードを追加する.
def decrypt_string(func_addr: int) -> str: args_3 = idaapi.get_arg_addrs(func_addr)[2] #4 enc_data_addr = get_operand_value(args_3, 0) #5 e = read_string(enc_data_addr) return DecryptString(e) for x in XrefsTo(get_name_ea_simple('DecryptString')): #1 xref_addr = x.frm #2 if ida_bytes.is_code(ida_bytes.get_full_flags(xref_addr)): #3 s = decrypt_string(xref_addr) print('[+] {} at {}'.format(s, hex(xref_addr)))
このコードには説明が必要だろう.
- XrefsToはアドレスを与えるとそのアドレスを参照する全てのアドレスを順次返すgeneratorで,xrefクラスをコピーしたインスタンスが得られる.
- xrefクラスのメンバ変数frmに参照するアドレスが保持されているのでそれを取得
- ただしこれはバイナリ内の全ての参照なのでコード領域からの参照だけをみるようにする
- idaapi.get_arg_addrsは与えられたアドレスの関数プロトタイプに基づいて引数のアドレスを保持したリストを返す
- get_operand_valueでそのアドレスの指定されたオペランド番号の値を得る(IDAの逆アセンブル画面ではアドレス
00401279
のオペランドがoffset aVog1uwt2kxnplc
となっているがこれはIDAが気を利かせて表示を変えているということだ)
これを実行すると以下のようにDecryptString
の引数に与えられた暗号化された文字列を復号して表示してくれる.
関数名指定部分の実装を抽象化
for x in XrefsTo(get_name_ea_simple('DecryptString')): #1
にリネームした関数名DecryptString
をセットしてしまっている.これでは完全な自動化とは言えない.次はこの関数を特定する部分も自動化しよう.
正規表現を使ってこの関数だと判定できる一意な機械語のパターンを見つける.今回は以下のBufferへの代入処理をパターンとして選んだ.mkYaraというプラグインを使えば,逆アセンブル画面の注目したい範囲を選択したあと右クリックから機械語ベースのルールを作成できる.ルールの厳格さは3段階から調整でき,Looseなルールを選択すれば以下のように即値が入っているオペランドの値を自動でメタ文字に変えてくれるなど即座にルールを生成できておすすめだ.
このパターンを検索するため以下のようなコードを追加した.
seg_mapping = {idc.get_segm_name(x): (idc.get_segm_start(x), idc.get_segm_end(x)) for x in idautils.Segments()} #1 start = seg_mapping['.text'][0] end = seg_mapping['.text'][1] pattern = "8B 45 ?? 83 C0 ?? 33 D2 B9 ?? ?? ?? ?? F7 F1 8B 45 ?? 8A 4C 15 ?? 88 08" addr = ida_search.find_binary(start, end, pattern, 16, idc.SEARCH_DOWN) #2 func_addr = idaapi.get_func(addr).start_ea #3 print('[*] target function found at {}'.format(hex(func_addr)))
- idautils.Segmentsは解析対象バイナリ内のすべてのセグメントを順次返すgeneratorで,セグメント名をキー,開始アドレスと終端アドレスのタプルを値として持つ辞書としてseg_mappingに代入している.今回は`.text'セクション内のみ検索する
- ida_search.find_binaryは引数に与えられたパターンでstartとendの範囲を検索する
- パターンが見つかったアドレスをidaapi.get_funcに渡すことでそのアドレスが属する関数に関する情報を保持したクラスインスタンスを生成できる.この値をXrefsToに渡す
このようにすれば複数検体が存在し,デコード関数の実装が多少変わったとしても自動化が可能だろう.
復号した文字列で変数をリネームする
復号した文字列を使ってグローバル変数をリネームしよう.リネームすることでこの変数の意味が明確になり解析がずっとしやすくなるはずだ.
def set_name_wrap(addr: int, name: str): i = 0 while True: if ( print_insn_mnem(addr) == 'mov' #2 and get_operand_type(addr, 0) == o_mem and get_operand_type(addr, 1) == o_reg and print_operand(addr, 1) == 'eax'): break elif i > 10: return else: addr = next_head(addr) #3 i += 1 set_name(get_operand_value(addr, 0), name, ida_name.SN_FORCE) #4 for x in XrefsTo(func_addr): # snip print('[+] {} at {}'.format(s, hex(xref_addr))) set_cmt(xref_addr, s, 0) #1 set_name_wrap(xref_addr, s)
- set_cmtで逆アセンブル画面のアドレスにコメントを付与できる
- set_name_wrapでは逆アセンブル結果を一行ずつ走査し,オペランドのタイプやニーモニックをもとにして復号した文字列をグローバル変数に代入する処理を探す
- next_headで指定したアドレスの次のアドレスを得る.次のアドレスとはIDAが認識している次の命令のアドレスという意味である
これを実行すると以下のように,逆アセンブル画面ではDecryptString
の横に復号した文字列がコメント付けされ,デコンパイル画面では変数名がAdjustTokenPrivilegesとリネームされていることがわかる.
Scaleする解析
ここまでの例では復号する文字列はたかだか2つであり,あまりスクリプト作成の恩恵を感じなかった.だが今回作成したスクリプトを使えば,以下のような手動でリネームすることが憚られるようなバイナリに対しても適用でき,自動化の恩恵を感じられるだろう.
またバッチモードでIDAを利用すれば,複数ファイルにも対応できる.以下は指定したフォルダ内の検体ファイルを全て解析するPowerShellワンライナー.完全なヘッドレスモードで実行するにはida.exeではなくidat.exeを使用する.またスクリプトや解析対象のファイルパスはフルパス指定すること.
Get-ChildItem <path\to\folder> | foreach {idat.exe -c -A -S<path\to\script> $_.FullName }
参考: running IDA python without gui
IDAPythonスクリプト
import idautils, idc, idaapi def read_string(addr: int) -> str: #read string from specific address res = "" while get_wide_byte(addr) != 0x0: s = get_wide_byte(addr) res += chr(s) addr += 1 return res def DecryptString(enc: str): key = "rptBAcdE2.qMRIUJo5wK6GsVNFfbajXgx4YZS7emy0CzD91LlWiv3Ou" dec = "" for e in enc: j = 0 for k in key: if e == k: break if j >= 0x37: break j = j + 1 if j >= 0x37: dec += e else: dec += key[(j+8) % 0x37] return dec def decrypt_string(func_addr: int) -> str: args_3 = idaapi.get_arg_addrs(func_addr)[2] enc_data_addr = get_operand_value(args_3, 0) e = read_string(enc_data_addr) return DecryptString(e) def set_name_wrap(addr: int, name: str): i = 0 while True: if ( print_insn_mnem(addr) == 'mov' and get_operand_type(addr, 0) == o_mem and get_operand_type(addr, 1) == o_reg and print_operand(addr, 1) == 'eax'): break elif i > 10: return else: addr = next_head(addr) i += 1 set_name(get_operand_value(addr, 0), name, ida_name.SN_FORCE) def main(): seg_mapping = {idc.get_segm_name(x): (idc.get_segm_start(x), idc.get_segm_end(x)) for x in idautils.Segments()} start = seg_mapping['.text'][0] end = seg_mapping['.text'][1] pattern = "83 C0 ?? 33 D2 ?? ?? ?? ?? ?? F7 ??" addr = ida_search.find_binary(start, end, pattern, 16, idc.SEARCH_DOWN) func_addr = idaapi.get_func(addr).start_ea print('[*] target function found at {}'.format(hex(func_addr))) for x in XrefsTo(func_addr): xref_addr = x.frm if ida_bytes.is_code(ida_bytes.get_full_flags(xref_addr)): s = decrypt_string(xref_addr) print('[+] {} at {}'.format(s, hex(xref_addr))) set_cmt(xref_addr, s, 0) set_name_wrap(xref_addr, s) with open('<path/to/result>', 'a') as f: f.write('[+] Analysis complete: ' + get_input_file_path() + '\n') if __name__ == '__main__': ida_auto.auto_wait() main() idc.exit()
終わりに
ここではIDAPythonスクリプトを使った解析の自動化をやってみた.バイナリの検索や変数のリネーム,バッチモードを使った複数ファイルへの適用などいずれもオーソドックスな使い方だ.IDAPythonを使えばこのほかにもバイナリへのパッチング,デバッガ操作による柔軟なデバッグなどをはじめとしたバイナリ操作はもちろん,Qtを使ったGUIプラグインの作成や独自アーキテクチャなマシン語の解析などもできる.機会があればこれらも記事として公開したい.
このエントリは,IPFactory Advent Calendar 2021 6日目のエントリです.詳細はこちら.
前日は,@DuGlaserによる「ただ移行するだけでいいのか?」でした.明日の枠は@n01e0です.楽しみですね.