先日書いた Unicorn と Capstone を使用した自己復元型バイナリの難読化解除手法 の記事で題材にした自己復元型バイナリの難読化解除について、Unicorn と Capstone を使用して実行をエミュレートしなくても静的解析だけで同等の操作を行うことができるのでは?と思い立ちました。
これまではこの手の実装をする際には はじめての Ghidra Script で CTF の問題を解く のように Ghidra Script を使用してきましたが、せっかく有料版(Personal) の Binary Ninja を使っているのにあまり活用できておらずもったいないので、今回は勉強もかねて Binary Ninja の Python API での実装にトライしてみることにしました。
もくじ
Binary Ninja の Python API を使ってみる
インストール
初めて Binary Ninja をインストールした場合は、とりあえず任意のインストールディレクトリ内の binaryninja/scripts/linux-setup/sh
を実行しておきます。
これを実行すると、デスクトップに Binary Ninja のショートカットが作成されたり PATH が登録されたりします。
Binary Ninja の GUI から Python API を使用する
Binary Ninja の Python API は特に面倒なセットアップ手順を必要とせず、すぐに使用を始めることができます。
ただ、僕が使用している Personal ライセンスは、現時点では headless processing
と呼ばれる機能をサポートしていないため、Python API を使用する場合は常に Binary Ninja の GUI を使用する必要があります。
The “headless processing” in the Commercial and Ultimate editions refers to the ability to run plugins without the GUI (for example “import binaryninja” from within a console or stand-alone python plugin), but both versions support the same full API. The Non-Commercial edition supports accessing the API only through plugins load
Binary Ninja の GUI で Python のインタプリタを起動してスクリプトを実行することで結果を確認することができます。
また、インタプリタしか使用できないわけではなく、Python スクリプトを記述したファイルを GUI から実行することで解析のオートメーションは可能です。
Binary Ninja API による各種操作
サンプル 1
サンプルとして以下のスクリプトを作成してみました。
def main():
# Load current view
bv = current_view
# Get functions
for func in bv.functions:
if func.name == "main":
main_func_startaddr = func.start
print(f"FUNC_NAME: {func.name},\t OFFSET: {hex(func.start)}")
# Get disassemble code
func_addr = 0x43e0
instruction = bv.get_disassembly(func_addr)
print(f"ADDRESS: {hex(func_addr)},\t CODE: {instruction}")
# Get disassemble function code(until return)
start_addr = main_func_startaddr
end_addr = start_addr + 0x4000
current_addr = start_addr
instruction = ""
while (current_addr < end_addr) and (instruction != "retn"):
instruction = bv.get_disassembly(current_addr)
instruction_length = bv.get_instruction_length(current_addr)
current_addr += bv.get_instruction_length(current_addr)
if instruction_length > 0:
print(f"0x{current_addr:x}: {instruction}")
else:
break
# Convert to NOP
## Stat undo actions
undo_actions_state = bv.begin_undo_actions()
target_address = 0x43e0
if bv.convert_to_nop(target_address):
print(f"Converted 0x{target_address:x} to NOP.")
else:
print(f"Failed converted 0x{target_address:x} to NOP.")
## Commit actions
bv.commit_undo_actions(undo_actions_state)
# Save file
bv.file.save()
return
if __name__ == "__main__":
import os
import sys
sys.stdout = open(f"{os.path.dirname(os.path.realpath(__file__))}/stdout.log", "w")
main()
sys.stdout = sys.__stdout__
上記のスクリプトを実行すると、以下のように関数や逆アセンブル結果などの情報にアクセスすることができます。
BinaryView のロードと関数一覧の表示
まず、Binary Ninja では BinaryView というクラスをインターフェースとしてバイナリデータをクエリすることができるようになっているようです。
そのため、ファイルを解析してデータを読み書きしたり変更したりといった操作は基本的にはこの BinaryView を通して行います。
BinaryView にて取得、操作可能な対象は以下のドキュメントに記載があります。
参考:binaryview module — Binary Ninja API Documentation v4.2
GUI から Python API を発行する場合、Magic 変数である current_view
を使用して現在参照している BinaryView にアクセスすることができます。
参考:User Guide - Binary Ninja User Documentation
以下のコードでは、取得した BinaryView から functions の走査を行い、バイナリ内のすべての関数の情報を列挙しています。
# Load current view
bv = current_view
# Get functions
for func in bv.functions:
if func.name == "main":
main_func_startaddr = func.start
print(f"FUNC_NAME: {func.name},\t OFFSET: {hex(func.start)}")
逆アセンブル結果の参照
以下は、特定のアドレスを指定して特定のアドレスの逆アセンブル結果を取得できるコードです。
以下の例だと 0x43e0 からの 1 命令分のみの情報を取得できます。
# Get disassemble code
func_addr = 0x43e0
instruction = bv.get_disassembly(func_addr)
print(f"ADDRESS: {hex(func_addr)},\t CODE: {instruction}")
0x43e0 から始まる関数全体の逆アセンブル結果を取得する場合は、以下のサンプルのようなスクリプトを利用できます。
# Get disassemble function code(until return)
start_addr = main_func_startaddr
end_addr = start_addr + 0x4000
current_addr = start_addr
instruction = ""
while (current_addr < end_addr) and (instruction != "retn"):
instruction = bv.get_disassembly(current_addr)
instruction_length = bv.get_instruction_length(current_addr)
current_addr += bv.get_instruction_length(current_addr)
if instruction_length > 0:
print(f"0x{current_addr:x}: {instruction}")
else:
break
特定の命令をすべて NOP に置き換える
以下は、GUI からも行うことが可能な Convert To NOP を Python API から行うコードです。
API からデータの変更を行う場合はまず、begin_undo_actions
関数によって対象の BinaryView で Undo 可能な操作の開始を宣言します。
続けて、bv.convert_to_nop(addr)
で特定のアドレスの命令をまとめて NOP に置き換え、最後に bv.commit_undo_actions(undo_actions_state)
で操作をコミットします。
また、bv.file.save()
で変更後の BinaryView をファイルとして保存しています。
# Convert to NOP
## Stat undo actions
undo_actions_state = bv.begin_undo_actions()
target_address = 0x43e0
if bv.convert_to_nop(target_address):
print(f"Converted 0x{target_address:x} to NOP.")
else:
print(f"Failed converted 0x{target_address:x} to NOP.")
## Commit actions
bv.commit_undo_actions(undo_actions_state)
# Save file
bv.file.save()
特定の命令を置き換える
Convert to NOP を行う場合の操作は簡単でしたが、NOP ではなく任意のバイト値に置き換える操作も比較的容易にできます。
例えば、以下は read で読み取った特定のアドレスのバイト値を write で別の値に置き換えることが可能なコードです。
# 特定の実行アドレスのバイナリを XOR したバイナリに置き換える
data = bv.read(target_addr, size)
bv.write(
target_addr,
(int.from_bytes(data, byteorder="little") ^ key_value).to_bytes(size, byteorder="little")
)
Binary Ninja で自己復元型バイナリの難読化解除を行う
実際に Binary Ninja の Python API を使用し、Unicorn と Capstone を使用した自己復元型バイナリの難読化解除手法 の記事で題材にした自己復元型バイナリの難読化解除にトライします。
バイナリの実装や難読化解除の方針については上記の記事に記載しているので今回は割愛します。
最終的に作成したスクリプトは以下の通りです。
このスクリプトを Binary Ninja の GUI でロードすることで実行コードの難読化解除を行うことができました。
# Load current view
bv = current_view
call_addrs = [0x43e0]
deobfuscated_addrs = []
pushfq_flag = False
current_call_addr = 0
previous_call_addr = 0
def deobfuscate(current_call_addr):
global bv
global call_addrs
global pushfq_flag
global deobfuscated_addrs
hex_pattern = r"0x[0-9a-fA-F]+"
word_pattern = r".word|byte"
undo_actions_state = bv.begin_undo_actions()
# Get disassemble function code(until return)
start_addr = current_call_addr
end_addr = start_addr + 0x4000
current_addr = start_addr
instruction = ""
while (current_addr < end_addr):
instruction = bv.get_disassembly(current_addr)
instruction_length = bv.get_instruction_length(current_addr)
if ("xor" in instruction) and (pushfq_flag == True):
# "xor dword [rel 0x43ec], 0xaeee8e1"
hex_numbers = re.findall(hex_pattern, instruction)
word_type = re.findall(word_pattern, instruction)[0].replace(" ", "")
target_addr = int(hex_numbers[0], 16)
key_value = int(hex_numbers[1], 16)
if target_addr > current_addr:
size = 0
if word_type == "byte":
size = 1
elif word_type == "word":
size = 2
elif word_type == "dword":
size = 4
elif word_type == "qword":
size = 8
bv.convert_to_nop(current_addr)
data = bv.read(target_addr, size)
bv.write(
target_addr,
(int.from_bytes(data, byteorder="little") ^ key_value).to_bytes(size, byteorder="little")
)
else:
bv.convert_to_nop(current_addr)
elif "pushfq" in instruction:
bv.convert_to_nop(current_addr)
pushfq_flag = True
elif "popfq" in instruction:
bv.convert_to_nop(current_addr)
pushfq_flag = False
elif "retn" in instruction:
bv.commit_undo_actions(undo_actions_state)
break
else:
if pushfq_flag:
bv.convert_to_nop(current_addr)
else:
if "call" in instruction:
call_addr = int(re.findall(hex_pattern, instruction)[0], 16)
if call_addr >= 0x1260:
call_addrs.append(call_addr)
bv.commit_undo_actions(undo_actions_state)
current_addr += bv.get_instruction_length(current_addr)
return
def main():
global bv
global call_addrs
global deobfuscated_addrs
while len(call_addrs) > 0:
current_call_addr = call_addrs.pop()
if current_call_addr not in deobfuscated_addrs:
deobfuscate(current_call_addr)
deobfuscated_addrs.append(current_call_addr)
current_func = bv.get_function_at(current_call_addr)
current_func.reanalyze()
bv.update_analysis_and_wait()
return
if __name__ == "__main__":
import os
import sys
sys.stdout = open(f"{os.path.dirname(os.path.realpath(__file__))}/stdout.log", "w")
main()
sys.stdout = sys.__stdout__
実際にこのスクリプトで復元したコードは、以下のように Flag の取得に必要な実行コードを正しく復元できていました。
また、プログラム実行時の動作も難読化解除前と同等でした。
このスクリプトを作成した際のポイントをいくつかまとめておきます。
逆アセンブル結果の取得方法
今回のスクリプトを作成した際には逆アセンブル結果を文字列として返却する bv.get_disassembly(current_addr)
を使用しておりました。
そのため、命令やアドレスの取得の際には正規表現を使用しました。
Binary Ninja API で逆アセンブル結果を取得する際にはこれ以外にも逆アセンブル結果を含む Generator を返す disassembly_text
などいくつかのメソッドを使用できそうです。
参考:binaryview module — Binary Ninja API Documentation v4.2
reanalyze と updateanalysisand_wait の実行タイミングを考慮する
current_func.reanalyze()
は、特定の関数の再解析を行います。
また、bv.update_analysis_and_wait()
は解析結果が完全に反映されることを待つ関数です。
current_func = bv.get_function_at(current_call_addr)
current_func.reanalyze()
bv.update_analysis_and_wait()
初めは特定の命令を変更する度に bv.update_analysis_and_wait()
の実行をトリガーしていたのですが、そうするとスクリプトの実行が永遠に完了しなくなってしまったため、全体を通して数回のみ実行される箇所に変更しました。
逆に bv.commit_undo_actions(undo_actions_state)
はかなり頻繁に実施しているはずなのですが、こちらはそこまで実行時間に影響しているようには感じませんでした。
まとめ
いつか使おうと思って中々活用できてなかった Binary Ninja の Python API を使用してみました。
Ghidra Script を初めて使ったときよりなんとなく使いやすい気がします。(課金バイアスかもしれませんが、、、)