シミュレーション実行の理解#
私たちは多くの場合、実機を使ってデバッグするのが好きです。なぜなら、実機環境が最も良いからです。しかし、時には実機環境を見つけられなかったり、環境を再現するのが難しい場合(例えば、ルーターの解析など)もあります。そのような時に自然とシミュレーターを考えるようになります。Windows 上の夜神シミュレーターや Linux の qemu シミュレーターのように。
しかし、シミュレーターにも一定の限界があります。例えば、夜神シミュレーターは実際には x86_64 の命令セットしか実行できません。このような時にシミュレーション実行フレームワークが登場しました。
Unicorn は非常に優れたクロスプラットフォームのシミュレーション実行フレームワークであり、Arm、Arm64(Armv8)、M68K、Mips、Sparc、X86(X86_64 を含む)などの命令セットのネイティブプログラムをクロスプラットフォームで実行できます。
unicorn の使用#
私は比較的ストレートにコード実行をデモするのが好きです。インストールは簡単で、pip install
だけで済みます。
それでは、unicorn を使用する過程で必要なステップをいくつか紹介します:
- step1:
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
仮想マシンを初期化し、命令セットとアーキテクチャを選択します。 - step2:
mu.mem_map(ADDRESS, 2 * 0x10000)
メモリ領域をマッピングします。 - step3:
mu.mem_write(ADDRESS, ARM_CODE)
メモリ領域にコードを書き込みます。 - step4:
mu.reg_write
レジスタの値を設定します。つまり、コード実行のコンテキスト環境を設定します。 - step5:
mu.emu_start
シミュレーションを開始します。 - step6: 実行中の状態を監視します。例えば、単一命令の実行結果やレジスタ値の変化など、gdb デバッグのように。
上記のプロセスを理解したら、シミュレーション実行の大まかな流れが見えてきます。以下は実際にコードを実行する例です。
from unicorn import *
from unicorn.arm_const import *
ARM_CODE = b"\x37\x00\xa0\xe3\x03\x10\x42\xe0"
# mov r0, #0x37;
# sub r1, r2, r3
# ARMのテスト
# 命令をトレースするためのコールバック
def hook_code(uc, address, size, user_data):
print(">>> 0x%xで命令をトレース中, 命令サイズ = 0x%x" %(address, size))
def test_arm():
print("ARMコードをエミュレート中")
try:
# ARMモードでエミュレーターを初期化
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
# このエミュレーションのために2MBのメモリをマッピング
# uc_mem_mapでマッピングするメモリアドレスとサイズは0x1000に揃える必要があります
ADDRESS = 0x10000
mu.mem_map(ADDRESS, 2 * 0x10000)
mu.mem_write(ADDRESS, ARM_CODE)
mu.reg_write(UC_ARM_REG_R0, 0x1234)
mu.reg_write(UC_ARM_REG_R2, 0x6789)
mu.reg_write(UC_ARM_REG_R3, 0x3333)
# UC_HOOK_CODEは命令レベルのフックです
mu.hook_add(UC_HOOK_CODE, hook_code, begin=ADDRESS, end=ADDRESS)
# 無限の時間でマシンコードをエミュレート
mu.emu_start(ADDRESS, ADDRESS + len(ARM_CODE))
r0 = mu.reg_read(UC_ARM_REG_R0)
r1 = mu.reg_read(UC_ARM_REG_R1)
print(">>> R0 = 0x%x" % r0)
print(">>> R1 = 0x%x" % r1)
except UcError as e:
print("ERROR: %s" % e)
test_arm()
上記のコードでは、ARM のコードをシミュレーション実行したいと考えています。もちろん、コードは非常にシンプルで、実行結果を一目で見ることができます。
mov r0, #0x37
sub r1, r2, r3
この時、私たちがやるべきことは、step1〜step6 のプロセスに従ってコードを書くことです。そして、ここで特に監視デバッグの方法について説明します。
Unicorn は命令レベルのフックを提供しており、コールバック関数を記述するだけでコンテキストを監視できます。
# 命令をトレースするためのコールバック
def hook_code(uc, address, size, user_data):
print(">>> 0x%xで命令をトレース中, 命令サイズ = 0x%x" %(address, size))
... コード省略
# UC_HOOK_CODEは命令レベルのフックです
mu.hook_add(UC_HOOK_CODE, hook_code, begin=ADDRESS, end=ADDRESS)
最後に、このコードを実行した後のレジスタの結果は以下の通りです:
❯ python unicorn_t2.py
ARMコードをエミュレート中
>>> 0x10000で命令をトレース中, 命令サイズ = 0x4
>>> R0 = 0x37
>>> R1 = 0x3456
unicorn は何ができるのか?#
このように強力なシミュレーション実行フレームワークを使用するので、私たちは… アセンブリを書くことができます!
笑ってしまいますが、以前は x86 や arm のアセンブリをシミュレーション実行しようとすると、環境のシミュレーションに頭を悩ませていました。しかし、unicorn があれば、どんな言語のアセンブリでも簡単に書けるようになります!
例えば、arm を使ってフィボナッチ数列を計算するアセンブリを書くことができます。arm の命令を学ぶために。
3 つのレジスタがあれば十分で、互いに加算し、最終的なコードは以下の通りです:
.global main
main:
MOV R0, #10 // フィボナッチ数列の長さを10に設定
MOV R1, #0 // 最初のフィボナッチ数を0に初期化
MOV R2, #1 // 2番目のフィボナッチ数を1に初期化
loop:
CMP R0, #0 // カウンタが0かどうかをチェック
BEQ end // カウンタが0の場合、endにジャンプ
ADD R3, R1, R2 // 次のフィボナッチ数を計算
MOV R1, R2 // 最初のフィボナッチ数を現在の2番目のフィボナッチ数に更新
MOV R2, R3 // 2番目のフィボナッチ数を計算した数に更新
SUB R0, R0, #1 // カウンタを1減らす
B loop // ループの開始地点に戻る
end:
// プログラムを終了
arm アセンブリ命令の学習#
一般的な arm アセンブリ命令を以下に記録します:
MOV
: データ転送命令。即値または他のレジスタの値をレジスタに移動します。例えば、MOV R0, #10
は 10 をレジスタ R0 に移動します。CMP
: 比較命令。2 つのレジスタの値を比較します。結果は保存されませんが、状態レジスタに影響を与えます(条件フラグを設定します)。例えば、CMP R0, #0
は R0 と 0 を比較します。BEQ
: 条件分岐命令。最近のCMP
の結果が等しい場合、ラベルにジャンプします。例えば、BEQ end
は条件が満たされている場合(等しい場合)にend
にジャンプします。ADD
: 加算命令。2 つのレジスタの値を加算し、別のレジスタに保存します。例えば、ADD R3, R1, R2
は R1 と R2 の値を加算し、結果を R3 に保存します。SUB
: 減算命令。1 つのレジスタの値から他のレジスタの値または即値を減算します。例えば、SUB R0, R0, #1
は R0 の値を 1 減らします。B
: 無条件ジャンプ命令。指定されたラベルにジャンプします。例えば、B loop
は無条件でloop
ラベルに戻ります。LDR/STR
(ロード / ストア): メモリからデータをレジスタにロードしたり、レジスタからメモリにデータを保存したりします。- 例:
LDR R3, [R1]
は R1 で指定されたメモリアドレスからデータを R3 にロードします。 - 例:
STR R3, [R1]
は R3 のデータを R1 で指定されたメモリアドレスに保存します。
- 例:
BL/BLX
(リンク付き分岐 / リンク付き分岐と交換): 関数呼び出しに使用され、戻りアドレスをリンクレジスタ(LR)に保存します。
- 例:
BL function_name
はfunction_name
という名前の関数を呼び出し、戻りアドレスを LR に保存します。
PUSH/POP
(スタックプッシュ / スタックポップ): スタックを操作するために使用され、通常は関数呼び出しでレジスタを保存および復元します。- 例:
PUSH {R0, R1, LR}
は R0、R1、およびリンクレジスタ LR をスタックにプッシュします。 - 例:
POP {R0, R1, LR}
はスタックから R0、R1、および LR にデータをポップします。
- 例:
BNE, BGT, BLE, etc.
(等しくない場合の分岐、より大きい場合の分岐、より小さいまたは等しい場合の分岐など): CMP 命令で設定されたフラグに基づいて分岐する条件分岐命令。
- 例:
BNE somewhere
は最後の比較結果が等しくない場合、somewhere
にジャンプします。
シミュレーション実行#
ここでは、私たちが直接書いた arm アセンブリコードは機械が認識できないため、keystone ツールを使用して機械コードに変換する必要があります。機械コードに変換した後、シミュレーション実行が可能になります。
from unicorn import *
from unicorn.arm_const import *
from keystone import *
# ARMアセンブリコード
arm_code = """
.global main
main:
MOV R0, #10 // フィボナッチ数列の長さを10に設定
MOV R1, #0 // 最初のフィボナッチ数を0に初期化
MOV R2, #1 // 2番目のフィボナッチ数を1に初期化
loop:
CMP R0, #0 // カウンタが0かどうかをチェック
BEQ end // カウンタが0の場合、endにジャンプ
ADD R3, R1, R2 // 次のフィボナッチ数を計算
MOV R1, R2 // 最初のフィボナッチ数を現在の2番目のフィボナッチ数に更新
MOV R2, R3 // 2番目のフィボナッチ数を計算した数に更新
SUB R0, R0, #1 // カウンタを1減らす
B loop // ループの開始地点に戻る
end:
// プログラムを終了
"""
# Keystoneエンジンを初期化
ks = Ks(KS_ARCH_ARM, KS_MODE_ARM)
# ARMアセンブリコードをバイナリコードにコンパイル
arm_code_binary, _ = ks.asm(arm_code.encode())
# シミュレーターを設定
mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)
# メモリ空間を割り当て、ARMコードをメモリにロード
ADDRESS = 0x1000000
mu.mem_map(ADDRESS, 0x1000)
mu.mem_write(ADDRESS, bytes(arm_code_binary))
# レジスタの初期値を設定
mu.reg_write(UC_ARM_REG_SP, 0x7fffffff)
# 各命令実行後にレジスタの値を出力するフック関数を定義
def hook_code(uc, address, size, user_data):
print(f"0x{address:x}で命令が実行されました")
# レジスタ名のマッピング
reg_names = {
UC_ARM_REG_R0: 'R0',
UC_ARM_REG_R1: 'R1',
UC_ARM_REG_R2: 'R2',
UC_ARM_REG_R3: 'R3',
}
for reg in [UC_ARM_REG_R0, UC_ARM_REG_R1, UC_ARM_REG_R2, UC_ARM_REG_R3]:
reg_value = mu.reg_read(reg)
reg_name = reg_names.get(reg, 'Unknown')
print(f"{reg_name}: {reg_value}")
print()
# フック関数を追加し、各命令実行後にトリガーします
mu.hook_add(UC_HOOK_CODE, hook_code)
# シミュレーション実行を開始
try:
mu.emu_start(ADDRESS, ADDRESS + len(arm_code_binary))
except UcError as e:
print(f"エラー: {e}")
# レジスタの値を出力
print("R1:", mu.reg_read(UC_ARM_REG_R1))
print("R2:", mu.reg_read(UC_ARM_REG_R2))
print("最終結果 R3:", mu.reg_read(UC_ARM_REG_R3))
問題解決#
インターネット上で見つけられる面白い問題の一つは、この 100mazes という問題です。参考:例題:MTCTF2021 100mazes
問題の要求は:100 個の迷路があり、各迷路のルートを示し、最後にこれらのルートの md5 結果をフラグとして提出することです。
もちろん、手動で解決することはできませんので、各迷路のコードを観察します:
そして、各関数は実際には迷路データを読み取り、あなたの入力ルートに基づいてゴールに到達できるかどうかを判断します:
したがって、コードを記述して迷路データを抽出し、DFS 検索を通じて迷路を進むことができます(実際にはシミュレーション実行を使用しなくても問題ありません。スクリプトを記述して idapython で迷路データを抽出し、コードを記述して迷路を進むこともできます)。
ここでは、unicorn を使用してコードをシミュレーション実行する方法を重点的に分析します。unicorn は命令レベルを直接シミュレーションできますが、printf のような関数に対しては無力ですので、追加の処理が必要です。
さらに、ここではレジスタの他にスタックもシミュレートする必要があります。
重要なコードは以下の通りです。
def hook_code(uc, address, size, user_data):
global map_data, str_map, ans_map, ans, all_input
# print('>>> 0x%xで命令をトレース中, 命令サイズ = 0x%x' % (address, size))
assert isinstance(uc, Uc)
code = uc.mem_read(address, 4)
if code == b"\x48\x0F\xC7\xF0":
uc.reg_write(UC_X86_REG_RIP, address + 4) # rdrand raxに出会ったらスキップ
if address == 0x640: # printfのretに出会ったら
rsp = uc.reg_read(UC_X86_REG_RSP)
retn_addr = u64(uc.mem_read(rsp, 8))
uc.reg_write(UC_X86_REG_RIP, retn_addr)
elif address == 0x650: # getcharに出会ったら 迷路を読み取る
rbp = uc.reg_read(UC_X86_REG_RBP)
maze_data = uc.mem_read(rbp - 0xC6A, 0x625) # 迷路データ
step_data = uc.mem_read(rbp - 0x9F9, 4).decode() # 方向データ
xor_data = uc.mem_read(rbp - 0x9D0, 0x9C4) # XORデータ
lr_val = u32(uc.mem_read(rbp - 0x9F4, 4)) # スタート地点x
ur_val = u32(uc.mem_read(rbp - 0x9F0, 4)) # スタート地点y
maze_data = list(maze_data) # XOR処理
for i in range(0, 0x9C4, 4):
maze_data[i // 4] ^= u32(xor_data[i: i + 4])
for i in range(25): # 最終的な迷路を合成
line_data = ""
for j in range(25):
line_data += chr(maze_data[i * 25 + j])
# print(line_data)
map_data = maze_data
str_map = step_data
ans = ""
assert dfs(0, -1, -1, lr_val, ur_val) # 深さ優先探索
# print(ans)
all_input += ans
# leave;ret
rbp = uc.reg_read(UC_X86_REG_RBP)
new_rbp = u64(uc.mem_read(rbp, 8))
retn_addr = u64(uc.mem_read(rbp + 8, 8))
uc.reg_write(UC_X86_REG_RBP, new_rbp)
uc.reg_write(UC_X86_REG_RSP, rbp + 0x18)
uc.reg_write(UC_X86_REG_RIP, retn_addr)