Binary Analysis 07 · Cortex-M のイメージ — ベクタテーブルから構造を復元する

Chapter 07

Cortex-M のイメージ — ベクタテーブルから構造を復元する

この章のゴール.

シンボルのない生バイナリを見て、ここが Cortex-M のファームウェアだと判断でき、 ベクタテーブルからスタック位置・エントリポイント・割り込みハンドラを読み取れるようになること。 Thumb コードとリテラルプール、フラッシュとRAMのアドレスを区別できること。

この章で使う既出の用語(定義は各リンク先). メモリマップ(01 章 3 節)、オフセット(03 章 1 節)、サイズ(04 章 7 節)、ブートローダ(04 章 5 節)、ELF(05 章 1 節)

1. 手がかりのない生バイナリ

フラッシュから吸い出した .bin には、ELF のようなヘッダもシンボルもない。ただのバイト列である。 しかし Cortex-M(Arm のマイコン向け CPU)のファームウェアには、必ず決まった形で始まるという強力な手がかりがある。それがベクタテーブルである。

2. ベクタテーブル — 先頭に必ずある表

Cortex-M では、フラッシュの先頭(またはリセット時にマップされる先頭)に、32 ビットの値が並んだ表が置かれる。これがベクタテーブルで、リセットや割り込みが起きたときに CPU が「どこへ飛ぶか」を書いてある。

オフセット内容読み方
0x00初期スタックポインタ(MSP)RAM の範囲のアドレス(0x2000xxxx など)。スタックの初期値
0x04リセットハンドラ起動後に最初に実行されるコードのアドレス(フラッシュ範囲、最下位ビットが 1)
0x08NMI ハンドラ
0x0CHardFault ハンドラ
0x10〜0x3Cその他のシステム例外MemManage、BusFault、UsageFault、SVC、PendSV、SysTick
0x40〜外部割り込み(IRQ)ハンドラ周辺機器ごと。数十〜200 個

これが「Cortex-M のイメージだ」と判断する決め手である。

xxd -e -g4 -l 64 fw.bin
# 20000400 080081c5 08008181 08008183 ...
#    ↑MSP     ↑Reset  ↑NMI     ↑HardFault
ベクタテーブル — 生イメージの先頭に必ずある表初期スタックポインタ(MSP)RAM 範囲 0x2000xxxx+0x00リセットハンドラフラッシュ範囲・奇数+0x04NMI / HardFault …システム例外+0x08外部割り込み(IRQ)周辺機器ごと、数十〜200 個+0x40「Cortex-M のイメージだ」と判断する決め手① オフセット 0 の u32 が RAM 範囲(0x20000000 台) ② オフセット 4 以降の u32 がフラッシュ範囲で、すべて奇数(最下位ビット=1)xxd -e -g4: 20000400 080081c5 08008181 08008183 ← MSP Reset NMI HardFault
先頭 = MSP(RAM 範囲)、+4 = リセットハンドラ(フラッシュ範囲・奇数)が決め手

3. なぜハンドラのアドレスは奇数なのか — Thumb ビット

Cortex-M は Thumb 命令セット(16/32 ビット幅の Arm 命令)だけを実行する。 分岐先アドレスの最下位ビットが 1 なら「Thumb で実行せよ」という約束で、この 1 は「Thumb ビット」と呼ばれる。 だからベクタテーブルの各ハンドラアドレスは、実際のコード位置 + 1 になっている。

この「全部奇数」という性質が、ベクタテーブルを見分ける第 2 の手がかりになる。

4. ロードアドレスを突き止める

生バイナリのオフセット 0 が、実機のどのアドレスに焼かれているか(ロードアドレス)が分からないと、テーブルのアドレスと実際の位置がつながらない。

手がかり:

  1. リセットハンドラのアドレス(オフセット 4 の値、Thumb ビットを落とす)は、このイメージの中を指しているはず。その値が 0x08008000 台なら、イメージはフラッシュの 0x08008000 付近に焼かれている
  2. ダンプを取ったとき(04 章)に記録したアドレス
  3. チップのメモリマップ(フラッシュの開始アドレス)
  4. ブートローダの後ろに置かれるアプリは、0x08000000 ではなくオフセットした位置(0x08008000 など)に焼かれる。MCUboot なら 0x8000 + ヘッダ分(08 章)

ロードアドレスを L とすると、テーブルの値 V(Thumb ビットを落とした偶数)に対応するファイル内オフセットは V - L である。 これが合っていれば、リセットハンドラのオフセットに実際のコードがあるはずで、逆アセンブルして確かめられる。

import struct
d = open("fw.bin","rb").read()
sp, reset = struct.unpack_from("<II", d, 0)
print("MSP =", hex(sp), " Reset =", hex(reset & ~1))
# reset の上位から load address を推定
load = (reset & 0xFFFF0000)          # ざっくり。実際は既知の値で
print("推定ロードアドレス ≈", hex(load))
Thumb ビットとロードアドレスハンドラのアドレスは全部奇数 = Thumb ビットCortex-M は Thumb 命令のみ。分岐先の最下位ビット 1 が「Thumb で実行」。表の値 0x080081C5 → 実コードは 0x080081C4(−1)ロードアドレスを突き止めるリセットハンドラの値(−1 の偶数)はイメージ内を指す。0x08008xxx なら 0x08008000 付近に焼かれているファイル内オフセット = アドレス − ロードアドレスロードアドレス L が正しければ、リセットハンドラのオフセット位置に実際のコードがある。逆アセンブル(16 章)で確かめられる。ブートローダの後ろのアプリは 0x08008000 + ヘッダ分(8 章)
ハンドラが奇数なのは Thumb ビット。ロードアドレスはリセットハンドラの値から推定

5. 割り込みハンドラの数からチップを推定する

ベクタテーブルの長さ(0x40 以降に並ぶ IRQ ハンドラの数)は、チップの割り込み源の数に対応する。 未使用の割り込みは、共通のデフォルトハンドラ(無限ループ)を指すことが多く、テーブルに同じアドレスが繰り返し現れる。

08008000: 20000400 080081c5 08008181 08008183
08008010: 08008185 08008187 08008189 00000000
08008020: 00000000 00000000 00000000 0800818b   ← 0 が並ぶのは予約領域
...
08008080: 0800a201 0800a201 0800a201 0800a3f5   ← 同じ 0800a201 が繰り返す = デフォルトハンドラ

「同じアドレスが繰り返し出てくる行」が見えたら、そこがデフォルトハンドラを指す未使用割り込みで、テーブルはそのあたりまで。 繰り返されるアドレス(デフォルトハンドラ)は、たいてい B .(自分へ無限ジャンプ、0xE7FE)という 2 バイトである。

6. コードとデータを見分ける

ベクタテーブルの後ろは、コードとデータが混在する。逆アセンブラ(16 章)に任せるのが本筋だが、目視でも当たりはつく。

コードとデータを見分けるThumb コードpush {lr}(B5xx)/ pop {pc}(BDxx)/ bl(4B)が頻出リテラルプールコードの合間に 32bit の定数・ポインタが島状に文字列.rodata に固まる。strings で位置繰り返す同じアドレス未使用割り込みのデフォルトハンドラ = B .(E7FE)0x0800xxxxフラッシュ内のポインタ(コード・データ)0x2000xxxx / 0x4000xxxxRAM のポインタ / 周辺レジスタRAM ダンプのスタックには、関数の戻りアドレス(0x0800xxxx の奇数)が点在する拾えば呼び出しの履歴が復元できる(17 章のスタックトレース)
コード中に点在する 0x0800/0x2000/0x4000 のポインタはリテラルプール。繰り返すアドレスは未使用割り込み

7. RAM ダンプの読み方

RAM(0x20000000 付近)のダンプは、止まった瞬間の状態である。フラッシュと違い、内容は実行のたびに変わる。

スタック上で 0x0800xxxx の奇数値を探すと、それは関数の戻りアドレスの候補である。addr2line(ELF があれば)や逆アセンブラで、どの関数から呼ばれたかをたどれる。

8. ESP32・RISC-V の場合

9. 手を動かす

ベクタテーブルを読む


この章のポイント