Chapter 17
動的解析 — 実機とエミュレータで動かしながら読む
この章のゴール.
静的解析(読むだけ)で行き詰まったとき、実機のデバッガかエミュレータで動かして、 メモリ・レジスタ・実行の流れを観察できるようになること。 クラッシュ時のスタックから呼び出し履歴を復元できること。
この章で使う既出の用語(定義は各リンク先). OpenOCD(02 章 7 節)、チェックサム(03 章 8 節)、JTAG(04 章 2 節)、SWD(04 章 2 節)、ELF(05 章 1 節)、スタック(07 章 7 節)、引数(12 章 3 節)、呼び出し(16 章 5 節)、逆アセンブル(16 章 1 節)
1. 静的と動的
動的解析が効くのは、静的では追いきれないとき。「暗号鍵が実行時に計算される」「状態遷移が複雑」「どの経路を通るか入力で変わる」「周辺機器の応答に依存する」場合である。
2. 実機デバッガ — gdb + OpenOCD
04 章で吸い出しに使った SWD / JTAG は、そのまま実行制御にも使える。
# 端末 1: OpenOCD をデバッグサーバとして起動
openocd -f interface/stlink.cfg -f target/stm32u5x.cfg
# 端末 2: gdb で接続
arm-none-eabi-gdb firmware.elf # ELF があればシンボル付き
(gdb) target extended-remote :3333
(gdb) monitor reset halt
(gdb) break main
(gdb) continue
(gdb) info registers # R0-R15, PC, SP を見る
(gdb) x/16xw 0x20000000 # RAM を 16 ワード表示
(gdb) dump binary memory ram.bin 0x20000000 0x20040000 # 動作中の RAM をダンプできること:
- ブレークポイント: 特定アドレス / 関数で止める。「鍵を使う関数」で止めて、その瞬間の RAM を読めば、実行時に組み立てられた鍵が見える(13 章の動的版)
- ウォッチポイント: 特定アドレスが読み書きされたら止める。「この設定変数を書き換えるのは誰か」を突き止める
- ステップ実行: 1 命令ずつ進めて、レジスタの変化を追う
- メモリの読み書き: 実行中の状態を観察・改変する
シンボル付き ELF(05 章)があれば関数名・変数名・行番号でデバッグできる。なければアドレスで操作し、16 章の逆アセンブル結果と照らす。
3. エミュレータ — 実機なしで
実機やプローブがない、壊すのが怖い、多数を自動で回したい、というときはエミュレータを使う。
| ツール | 向くもの |
|---|---|
| QEMU | 一部の Cortex-M ボード、Linux(Cortex-A)。qemu-system-arm -M mps2-an385 -kernel fw.elf -s -S(-s -S で gdb 待ち受け) |
| Renode | 多数の組み込みボードと周辺機器を再現。センサや通信を含む系全体をエミュレート。スクリプトで自動化しやすい |
| Unicorn Engine | CPU コアだけを部分的に実行(関数単位のエミュレーション)。Python から。「この暗号関数だけ動かして出力を見る」 |
Renode の例:
(monitor) mach create
(monitor) machine LoadPlatformDescription @platforms/boards/stm32f4_discovery.repl
(monitor) sysbus LoadELF @firmware.elf
(monitor) sysbus.cpu LogFunctionNames true # 実行した関数を記録
(monitor) startエミュレータの利点は、周辺機器の応答を自由に差し替えられ、実行を記録・再現できることである。難点は、対象ボードや周辺が再現されていないと動かないこと。
4. 部分エミュレーション — Unicorn
ファーム全体は動かせなくても、特定の関数だけをエミュレートできる。 「この関数はチェックサムを計算しているらしい」という仮説(16 章)を、実際に入力を与えて出力を見て確かめる。
from unicorn import *
from unicorn.arm_const import *
code = open("fw.bin","rb").read()[0x1200:0x1300] # 対象関数のバイト列
mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
mu.mem_map(0x0, 0x10000)
mu.mem_write(0x1200, code)
mu.reg_write(UC_ARM_REG_R0, 0x20000000) # 引数を設定
# ... 入力データを RAM に置く ...
mu.emu_start(0x1200 | 1, 0x1300) # Thumb は |1
print(hex(mu.reg_read(UC_ARM_REG_R0))) # 戻り値これは、鍵導出・チェックサム・独自エンコードなど「アルゴリズムを取り出したい」ときに強力である。
5. クラッシュの解析 — スタックから履歴を復元
機器が HardFault で止まった、ウォッチドッグでリセットした、というとき、止まった瞬間のスタックとレジスタが原因を語る。
Cortex-M は例外発生時、R0-R3・R12・LR・PC・xPSR を自動でスタックに積む。フォールトハンドラや RAM ダンプ(04 章)でこれを取れば:
- 積まれた PC = クラッシュした命令のアドレス。
addr2line(ELF があれば)でどの行か - 積まれた LR = 呼び出し元。1 つ前の関数
- スタックをさかのぼると、戻りアドレス(0x0800xxxx の奇数、07 章)が点在する。これを拾えば呼び出しの履歴(コールスタック)が復元できる
import struct
stack = open("ram_stack.bin","rb").read()
for i in range(0, len(stack), 4):
v = struct.unpack_from("<I", stack, i)[0]
if 0x08000000 <= v < 0x08100000 and v & 1: # フラッシュ範囲の奇数 = 戻りアドレス候補
print(f"stack+{i:#06x}: {v:#010x} -> {v & ~1:#010x}")
# arm-none-eabi-addr2line -e fw.elf <v & ~1> で行にESP32 の espcoredump.py、Zephyr の coredump、各種フォールトハンドラは、この作業を半自動でやってくれる(12 章)。
6. 通信を覗く
機器が外とやり取りするなら、通信の観察も動的解析である。
- UART: ロジックアナライザ / USB-シリアル変換でログや対話を読む
- SPI / I²C: ロジックアナライザ(Saleae、
sigrok)でセンサ・フラッシュとのやり取りをデコード - 無線 / ネットワーク: パケットキャプチャ(Wireshark、
nRF Sniffer)
これらで得た通信フレームは、また 03 章の構造解析の対象になる。
7. 静的と動的を往復する
実務では両者を行き来する。
- 静的(16 章)で「この関数が鍵を作るらしい」と当たりをつける
- 動的でその関数にブレークを置き、実行時の入出力を見て確かめる
- 分かったことを静的解析に書き戻す(関数名・型を付ける)
- 次の疑問へ
8. 手を動かす
動的解析の手段を選ぶ
この章のポイント
- 静的で行き詰まったら動かして観察。実行時にしか分からない鍵・経路・状態が見える
- 実機は gdb + OpenOCD / pyOCD。関数にブレークして RAM を読み、実行時の鍵や状態を捕まえる
- 実機がなければ QEMU / Renode(系全体)、Unicorn(関数単位)。周辺応答を差し替え、再現できる
- クラッシュはスタックの戻りアドレス(0x0800 台の奇数)から履歴を復元。PC/LR を
addr2lineで行に - 通信の観察も動的解析。得たフレームは 03 章の対象
- 静的と動的を往復し、分かったことを書き戻して育てる