[Astro] #182 ブラウザだけで動く EXE・DLL 解析ツール — 構造を読んで、機械語まで逆アセンブルする

[Astro] #182 ブラウザだけで動く EXE・DLL 解析ツール — 構造を読んで、機械語まで逆アセンブルする

はじめに

前回は、音量をそろえるツール、Audio Gain を作りました。

今回は、Inspector シリーズの新作です。PDF、Office、WASM、AST、Font と作ってきて、次は、Windows の実行ファイル(EXE・DLL)にしました。名前は PE Inspector です。PE は、Windows の実行ファイルの形式(Portable Executable)のことです。

目的は、自分で作った EXE が、どこまで読めるかを知ることでした。パッカーで固めたファイルや、わざと壊したファイルは、対象にしていません。読める範囲で読むだけです。

ファイルは、実行しません。サーバーにも、送られません。バイト列として読むだけです。

画面

概要

PE Inspector の概要タブ。セクションの表と、インポートした関数の一覧

ファイルを開くと、まず「概要」が出ます。バージョン情報、PDB のパス、セクションとエントロピー、インポート、エクスポート、リソース、署名の有無などが、1 画面に並びます。位置のある行は、クリックすると、右の16進ダンプの該当位置へ飛びます。

文字列

文字列タブ。場所の絞り込みで、コード以外の場所が選ばれている

ファイルの中に入っている文字列を、一覧にします。長さ、種類(ASCII、UTF-8、UTF-16)、場所(どのセクションか)で、絞り込めます。

関数と逆アセンブル

関数タブの一覧と、右の逆アセンブル。call の行に、呼び先の関数が出ている

左の「関数」タブで関数を選ぶと、右に、その関数の命令が並びます。call の行から、呼び先の関数へ移れて、「← 戻る」で戻れます。

F1 ヘルプ

F1 キーで、使い方が出ます。ほかのツールと同じです。

F1 キーで、使い方が出ます。ほかのツールと同じです。

作ったもの

  • EXE・DLL(.exe .dll .sys .ocx .pyd など)を、ドロップか選択で開く。x64、x86(PE32)、ARM64 のヘッダーを読む
  • 「概要」: 要約を 1 画面で。クリックで、16進ダンプの該当位置へ
  • 「構造」: DOS ヘッダーから各セクションまで、項目ごとに、値・位置・日本語の説明。クリックで、該当バイトが光る
  • ファイル全体の地図: 場所ごとのエントロピーと、領域(ヘッダー、セクション、署名、オーバーレイ)の色分け
  • 「文字列」: ASCII、UTF-8(日本語)、UTF-16
  • インポートした関数名から、その関数のアドレスが入る場所(IAT)と、関数名の文字列へのジャンプ
  • 「関数」: x64 の .pdata にある関数の一覧と、逆アセンブル
  • F1 ヘルプ

作り方

パーサーを先に作る

最初に、画面を作らず、パーサーだけを作りました。pe-parser.js は、DataView だけで動く、1 ファイルです。ブラウザでも、Node でも動きます。

読めるのは、次のものです。

  • DOS ヘッダー、COFF ヘッダー、オプショナルヘッダー(PE32 と PE32+)、データディレクトリ、セクション
  • インポート、遅延インポート、エクスポート
  • リソース(全件の一覧、マニフェスト、バージョン情報)
  • デバッグ情報(PDB のパス、GUID)、TLS、再配置、ロード構成
  • .pdata(x64 の関数の開始・終了位置の一覧)、Rich ヘッダー、署名の位置、.NET のヘッダー

壊れたファイルでも、例外は投げません。ok: false とエラーの説明を返して、読めた所までは、結果に入れます。

RVA(メモリに読み込んだときの位置)から、ファイル上の位置への変換は、Python の pefile と同じ丸め規則にしました。ローダーの挙動に合わせるためです。

pefile と突き合わせる

パーサーが正しいかは、自分で読み返しても、分かりません。そこで、pefile と突き合わせました。

サンプルは、16 個です。Rust 製の ruff.exe(26 MB)、OpenBLAS の DLL(20 MB、エクスポート 1 万件超)、Pillow と NumPy の .pyd、pip のランチャー(x86、x64、ARM64)を使いました。

比べたのは、ヘッダーの全項目、データディレクトリ、セクション(エントロピーは 1e-9 の精度)、インポート、遅延インポート、エクスポート、リソース全件、バージョン情報、デバッグ、TLS、再配置、例外情報の件数、ロード構成、Rich ヘッダー、チェックサム、オーバーレイです。結果は、2,214 項目すべて一致でした。

自分の WiredFrame.exe でも、142 項目で比べて、142 項目が一致しました。

壊れたファイルへの耐久テスト(fuzz)も、別に作りました。切り詰め、ランダムな書き換え、ヘッダー周辺の集中的な書き換え、巨大な値の注入を、4,200 回です。例外で落ちたのは、0 回でした。

構造と16進ダンプをつなぐ

左の項目をクリックすると、右の16進ダンプの該当バイトが光ります。そのために、各項目の「ファイル上の位置・長さ・値・説明」を持つ一覧を、パーサーの結果から作っています。

確かめ方は、一覧の位置のバイトを、もう一度読み直して、パーサーの値と一致するかを見ることです。16 ファイルで、2,176 件が一致しました。

16進ダンプは、見えている行だけを描画します。26 MB の ruff.exe でも、軽く動きます。

ファイル全体の地図

16進ダンプの上に、ファイル全体を横一列にした地図を置きました。上段は、場所ごとのエントロピー(0〜8 の「ばらつき」)で、暗いと規則的、明るいと、ばらばらです。7.2 を超えると、琥珀色になります。圧縮、暗号化、画像などの可能性があります。下段は、領域の色分けです。

エントロピーは、1 マスの先頭 4,096 バイトだけで、目安として計算しています。26 MB でも、数十ミリ秒です。別の実装で計算し直して、992 件が一致しました。

WiredFrame.exe では、.rsrc が 7.98 でした。画像などが圧縮されているためと思われますが、中身は、確かめていません。

文字列を拾う

strings コマンドと、同じことをするだけに見えて、作ってみると、考えることがありました。

ASCII は、4 文字以上の連なりを拾います。UTF-8 と UTF-16 は、日本語(かな・漢字・全角)を含むものを拾います。ただし、機械語のバイト列が、偶然、文字に見えることが多くて、そのまま拾うと、ゴミだらけになります。

  • UTF-8 の ASCII 以外の文字は、かな・漢字・全角が 2 文字以上あり、ASCII 以外の文字の半分以上が、それであるときだけ採ります
  • UTF-16 は、2 バイト境界に並んだものだけを見て、ASCII 以外を含むものは、かなが 4 割以上のときだけ採ります(漢字だけの並びは、機械語が偶然そう見えることが多いので、採りません)

GNU の strings と突き合わせて、16 ファイルで 67 万件以上が一致し、不一致は 0 件でした。ASCII は strings -a、UTF-16 は strings -el と比べました。日本語は、埋め込んだ合成ファイルで確かめました。

さらに、実行コードのセクションには、機械語の断片ばかりが出ます。WiredFrame.exe では、全部で 143,665 件のうち、多くが AWAVWSH のような機械語の断片でした。そこで、既定の「場所」を「コード以外の場所」にして、実行コードのセクションを除きました。4,456 件になって、.rdata の文字列が、読めるようになりました。「すべての場所」を選べば、全部が見られます。

ただし、Rust のプログラムは、文字列の最後に終端(0)を付けず、.rdata に隙間なく並べます。そのため、DataFreestandingInvalidData... のように、つながった 1 行になります。区切りは、バイト列だけでは分かりません。直せません。

インポート関数から、位置へ飛ぶ

概要の「インポート」で、関数名をクリックすると、その関数のアドレスが入る場所(IAT)へ飛びます。

IAT のスロットには、ファイルの中では、関数名の表(ヒント 2 バイトと、名前と、終端の 0)を指す値が入っています。実行時に、Windows が、本物のアドレスで上書きします。説明欄の「→ 関数名の実物へ」を押すと、その名前の文字列へ飛びます。

確かめ方は、飛び先のバイトを、セクション表を直接引いて読み直して、関数名と一致するかを見ることです。16 ファイルの 2,033 件で、名前・ヒント・長さ・終端が、すべて一致しました。

逆アセンブル

最後が、逆アセンブルです。機械語を命令に直す部分は、自分では書かず、Capstone という、逆アセンブラのライブラリを使いました。WASM になっているものを、ブラウザで動かします。

npm には、2 つありました。capstone-wasm(約 1.8 MB)と、@alexaltea/capstone-js(約 3.3 MB)です。どちらも、同じ関数・同じ結果でした。ただ、前者は、配布元にライセンスの記載がなかったので、ライセンスが BSD と明記されている後者にしました。圧縮転送で約 1 MB です。「関数」を押して、初めて読み込みます。ほかのタブの速さには、影響しません。

関数の範囲は、x64 の .pdata から取ります。WiredFrame.exe には、35,193 件ありました。名前は、出ません。名前は PDB という別のファイルにあって、EXE には、入っていないためです。名前が付くのは、エントリポイントと、DLL のエクスポートだけです。

命令の行には、注釈を付けます。

  • call qword ptr [rip + 0xe048] のような行は、「次の命令のアドレス + 変位」で参照先を出して、IAT のスロットなら、→ KERNEL32.dll!LoadLibraryA のように、API 名を付けます
  • call 0x140001030 のような、直接の呼び出しは、飛び先が関数の開始位置なら、→ 関数 にして、クリックで移れるようにします

検算は、objdump(binutils)と突き合わせました。x64 の 12 ファイルから、3,313 関数、241,564 命令を、等間隔に選んで、比べました。

比べたもの件数不一致
命令の区切り(アドレスの並び)3,313 関数0
命令名241,564 命令0
[rip+X] の参照先15,675 件0
call / jmp の飛び先16,604 件0

API 名の注釈は、7,505 件が付きました。

ただし、関数の範囲の中にジャンプ表などのデータが混ざる 14 関数は、命令の区切りが、ずれる所の手前までしか一致していません(そこから先は、データを命令として読んだ別々の解釈になるためです)。上の表の件数は、その 14 関数を、「データ混在」として別に数えたうえでの結果です。

比べる途中で、分かったことが 2 つありました。どちらも、間違いではなく、表記の違いです。Capstone は、wait と fnstcw を 2 命令に分けて、objdump は、fstcw の 1 命令にまとめます。また、objdump は、rex.W や data16 のようなプレフィックスを、命令名の位置に出します。比べるときだけ、そろえました。

詰まったこと

化ける文字列と、検算の読み違い

文字列を最初に作ったとき、.text の機械語が、2 バイトの UTF-8 の文字や、ランダムな漢字の UTF-16 に見えて、大量に混ざりました。上の判定ルールは、その結果です。

検算の側が間違っていたことも、2 回ありました。逆アセンブルの検算を最初に書いたとき、objdump の出力のアドレスに 0x が付くのを読み違えて、全件が不一致になりました。パーサーの間違いではなく、検算の書式の読み違いでした。不一致が出たら、まず、検算の側を疑って、生の出力を見ることにしました。

もう 1 つは、IAT のジャンプです。パーサーの内部の変数(セクションのアドレス)が、結果に残っていると思い込んで書いたら、検算で、全ファイルが 0 件になりました。結果からは、消してありました。別の小さなモジュールに、同じ変換を組み直しました。

宣言前の変数が、警告に化けた

パーサーで、宣言より前に変数を使う間違いを、2 回しました。2 回目は、例外で落ちずに、警告のメッセージに化けたため、落ちないことを確かめる耐久テストでは、見つかりませんでした。pefile との突き合わせで、49 件の不一致が出て、初めて分かりました。テストは、「正しさ」と「耐久」の 2 種類が要ります。

壊れたヘッダーで、13.7 秒

ヘッダーが「セクションが 27,142 個ある」と主張するファイルで、セクションごとのエントロピー計算に、13.7 秒かかりました。総バイト数、インポート関数の数、セクションの数に上限を入れて、0.1 秒になりました。

pefile の上限

OpenBLAS の DLL(エクスポート 10,064 件)だけ、最初に不一致が出ました。原因は、pefile の安全装置で、名前を読むのを 8,192 件で打ち切るためでした。超えた分の名前が None になっていました。生のバイト列から直接読んで、第三者として確かめると、パーサーの出力が、10,064 件すべて一致しました。

共通レイアウトの中に収める

最初の画面は、自分で作った仮のヘッダー・フッターの中でテストしていたので、本物のサイトに入れると、ヘッダーが、アプリの上に重なりました。ほかのアプリのページ(vrm-to-mmd.astro)を見て、上下 50px の余白と、height: calc(100vh - 100px) に直しました。共通レイアウトは、見てから作るべきでした。

hidden 属性で隠したつもりの要素が、display を指定した CSS に負けて、ドロップ用の表示が、出っぱなしになることもありました。

実機で確認できたこと

Windows の Chrome で、自作の WiredFrame.exe(10.7 MB、PE32+、x64)を、開きました。解析は、50 ミリ秒ほどでした。

  • 概要、構造、文字列の各タブが、動きました
  • 文字列は、「コード以外の場所」で、全 143,665 件が 4,456 件になり、.rdata の文字列が読めました
  • インポートの関数名から、IAT のスロットへ飛べました(LoadLibraryA、EncodePointer)。「関数名の実物へ」のリンクも、出ました
  • 「関数」タブに、35,193 件の関数が出ました
  • 関数を開いて、命令が並び、call から呼び先へ移って、「← 戻る」で戻れました

ヘッドレスの Chromium と、pefile、strings、objdump では、次のことを確認しました。

  • パーサーは、pefile と 2,214 項目一致、WiredFrame.exe は 142 項目一致
  • 壊れたファイル 4,200 回で、例外が 0 回
  • 文字列は、strings と一致
  • 逆アセンブルは、objdump と一致(上の表)

未確認のこと・制限

  • WiredFrame.exe で、API 名の注釈(→ KERNEL32.dll!…)が出る行は、見ていません。サンプルでは、7,505 件が付いています
  • 日本語の文字列は、合成のファイルで確かめました。自作の EXE では、見ていません。ヨーロッパ言語のアクセント付きの文字は、拾いません。漢字だけの UTF-16 も、拾いません
  • 遅延インポートは、手元のサンプルに無く、名前までは確かめていません
  • ほかの自作アプリ(Lookover、Tote など)は、開いていません
  • x86(32bit)と ARM の逆アセンブルは、未対応です(.pdata の形式が違うか、ありません)
  • 関数の範囲に、ジャンプ表などのデータが混ざる関数が、14 件ありました。そこから先は、命令として読めない部分が出ます。画面には、読めなかったバイト数を出しますが、ジャンプ表の見分けは、していません
  • 1 つの関数は、先頭の 256 KB までです
  • 自分の関数どうしの呼び出しは、アドレスだけです。名前がありません
  • Safari と Firefox、スマホは、確かめていません。スマホ向けの専用設計は、していません
  • パッカーで圧縮・暗号化されたファイルや、わざと壊した構造は、対象外です
  • 証明書(署名)の中身の検証は、していません。位置と大きさだけです

おわりに

「EXE を開く」だけに見えて、ヘッダーの規則、RVA とファイル上の位置のずれ、機械語が文字に見えてしまうこと、逆アセンブルの表記の違いと、考えることが、たくさんありました。

作ってみて、よく分かったのは、検算の大切さでした。pefile や objdump のような、長く使われているものと突き合わせると、自分の間違いと、相手の安全装置や表記の違いを、切り分けられます。

PDB のパスには、ビルドしたマシンのフォルダ名(ユーザー名など)が、残っていることがあります。概要に出して、公開する前に気にしたい項目として、注意書きを付けました。

次は、命令が指す文字列を、命令の横に出して、どの関数が、どのメッセージを使うかを読めるようにしたいと思っています。今は、v1.0 です。