メインコンテンツまでスキップ

障害解析のためのアセンブリ言語

クラッシュしたアプリケーションを調査するとき、ソースコードやローカル変数を十分に参照できるとは限りません。逆アセンブルを読むと、CPUがどのアドレスを読み書きしようとしたか、どの条件で分岐したか、どの関数を呼び出そうとしたかを確認できます。

このシリーズでは、アセンブリでプログラムを書くことよりも、WindowsのネイティブアプリケーションをWinDbgなどで調査するための読み方に重点を置きます。対象はx86、x64、ARM64(AArch64)です。ARM64ECやマネージドコードのJIT固有の呼び出し規約は対象外とします。

読む順序​

記事調査で分かること
汎用レジスター停止位置、ポインター、演算結果、条件フラグをどこで確認するか
データ操作命令メモリーを何バイト読み書きするか、アクセス先をどう計算するか
算術演算とビット操作加減算、符号拡張、桁あふれ、シフトをどう解釈するか
制御フロー命令比較結果からどちらへ分岐するか、ループをどう読むか
スタック操作と関数呼び出し戻りアドレス、退避レジスター、スタック上のローカル変数をどう探すか
呼び出し規約実際の入口・関数途中のダンプから、引数をどこまで復元できるか
C/C++のコードと命令の対応配列、構造体、仮想関数などを逆アセンブルでどう見分けるか

本シリーズの表記​

  • x86・x64はWinDbgやVisual Studioで使われるIntel構文で記載します。通常、左側が書き込み先です。
  • ARM64も原則として左側が書き込み先ですが、strなどは先頭のレジスターの値をメモリーへ書き込みます。
  • 0x20と20hはどちらも16進数の32を表します。ARM64の#は即値を示し、#32は10進数の32です。
  • プロンプト付きの出力は、共通のC++サンプルをビルドし、CDBで取得した実測例です。抜粋では省略範囲を明記しています。命令形式の比較表や、明示した計算例は説明用です。
  • コードブロックは、x86・x64にx86asm、ARMにarmasmを使用します。当サイトではx86asmをPrismのnasm定義の別名として登録しています。これは表示上のハイライト指定であり、WinDbgの構文や対象CPUを変更するものではありません。
  • Windowsでは、x86のポインターは4バイト、x64・ARM64のポインターは8バイトです。intとlongはこれらの環境でいずれも4バイトです。

CPUが定める命令の動作と、WindowsのABIが定める引数の渡し方は別のものです。また、同じC/C++ソースでも、最適化設定やコンパイラーによって命令列は変わります。

例外発生位置を確認する​

例外情報を含むユーザーモードダンプでは、まず例外コンテキストを選んでからレジスターと命令を確認します。

.exr -1
.ecxr
r
u
k

.exr -1は例外レコード、.ecxrは例外発生時のレジスターコンテキスト、rはレジスター、uは逆アセンブル、kはコールスタックを表示します。例外コンテキストを持たないハングダンプでは、対象スレッドを選んでその停止位置を調べます。

本シリーズで取得したx86の実例では、frame_probeの__debugbreak()で保存したダンプを再度開き、.ecxrの後に次の状態を確認できました。

0:000> r eip,esp,ebp,eax,ecx,edx
Last set context:
eip=003f1163 esp=02effd28 ebp=02effd34 eax=00000042 ecx=0000008f edx=0000000b

この例は意図的なブレークポイント停止であり、アクセス違反ではありません。命令ポインターはint 3を指し、レジスターには引数そのものではなく計算途中の値も残っています。ソース、命令、ダンプの対応は呼び出し規約で説明します。

アクセス違反なら、例外レコードのアクセス種別とアドレスに加え、障害命令の転送幅と実効アドレスを照合します。データ操作命令とバッファ境界の確認を参照してください。

実測例を自分の環境で確認する​

共通のsample.cppをダウンロードできます。記事ごとに別の処理を手書きアセンブリで作るのではなく、このソースから生成された命令を比較しています。各例はMSVC 19.51.36256、CDB 10.0.28000.2526で取得しました。

Visual Studioの対象アーキテクチャのC++ビルド環境で、次のようにコンパイルします。実行ファイルとPDBが混在しないよう、アーキテクチャごとに作業ディレクトリを分けます。

cl /nologo /W4 /WX /O2 /Ob0 /MT /Zi /Fesample.exe /Fosample.obj /Fdsample.pdb sample.cpp /link /DEBUG /INCREMENTAL:NO /OPT:NOREF /OPT:NOICF

インライン化は無効ですが、その他の最適化は有効です。x86のフレームを説明する今回の追加実測では、さらに/Oy-を付け、フレームポインターを保持しています。 過去の取得例とは配置やアドレスが異なります。/OPT:NOREFは未参照関数の除去、/OPT:NOICFは同一関数の統合を抑止する指定です。これらは比較用の条件で、通常のアプリケーションに必須のビルド設定ではありません。

CDBを実行ファイルとPDBのディレクトリから起動すると、最初の停止位置で関数を表示できます。次は入力例です。

cdb.exe -y . sample.exe
uf sample!read_element
uf sample!scaled_bytes
uf sample!positive_or_zero
uf sample!call_pair
q

取得環境はWindows on ARMで、x86はx86版CDB、x64・ARM64はARM64版CDBを使用しました。x64版サンプルの命令を読む際は.effmach amd64でモードを指定しています。ネイティブ側のARM64コードも含まれるため、プロセス内のすべての命令を同じモードで読めるわけではありません。

引数付きでsample.exe frameをCDBから起動すると、引数とローカル変数を調べるframe_probeへ進みます。途中に__debugbreak()を含むデバッグ用の実行モードです。入口と途中でダンプを保存して再度開く手順、および今回のx64エミュレーションダンプでのコンテキスト選択上の制約は、呼び出し規約を参照してください。

読み違いを避けるために​

  • 調査対象の命令セットを確認します。64ビットWindows上でも、WOW64プロセスはx86コードです。ARM64版Windowsでも、対象がネイティブARM64とは限りません。
  • レジスターは、選択したスレッドとコンテキストに属します。別のスレッドや呼び出し元フレームの値と混同しないことが重要です。
  • 停止中の命令が実行される前なのか、例外を起こした命令なのかを区別します。例外の種類によっては、命令の一部の処理が済んでいることもあります。
  • 通常のダンプは実行履歴ではありません。前の命令が表示できても、その経路を実際に通ったことまでは証明できません。
  • シンボルが不一致の場合や必要なメモリーがダンプに含まれない場合は、変数やポインターを十分に復元できません。読み取り不能を、そのまま実行時のメモリー破損と判断しないようにします。

参考資料​