プログラミング
RubyでCodemastersのBIGFアーカイブフォーマットをリバースエンジニアリングする
Reverse-engineering Codemasters' BIGF archive format in Ruby (davidslv.uk)
要約
この記事は、Web開発で一般的なRuby言語が、バイナリファイルフォーマットのリバースエンジニアリングにも強力なツールとなり得ることを示しています。著者は、Codemastersのゲームで使用されているBIGFアーカイブフォーマットの解析にRubyのString#unpackメソッドとバイト操作能力を活用した経験を詳述しています。AIとの協力を通じて、Rubyの標準ライブラリと直感的な文字列操作が、低レベルのバイナリ解析タスクにどのように適しているかを実証しています。
全文翻訳
Rubyでバイナリファイルフォーマットをリバースエンジニアリングすると言えば、人々はC言語や、structを使ったPython、あるいはKaitaiを思い浮かべるでしょう。Rubyを思い浮かべる人は誰もいません。RubyはWebアプリケーションやDSL、そして快適さのための言語であり、大衆の想像の中では、2003年のレーシングゲームから浮動小数点数をバイト単位で抜き出すためのものではありません。その大衆の想像は間違っています。CodemastersのBIGFアーカイブフォーマット――TOCA Race DriverのAIデータを格納するコンテナ――のリーダーは、純粋で依存関係のないRubyで書かれており、4つの異なるゲームのアーカイブを読み取ります。それがどのようにして生まれたのか、正直に言うと、これは最初から最後までAIを使ったリバースエンジニアリングでした。私(人間)が指示を出し、何を信頼するかを決め、すべての主張をバイトと比較して検証し、モデル(AI)がコードをドラフトし、標準ライブラリの隅々を思い出し、私がテストする仮説を提案しました。以下に続くのは、その共同作業を本当に楽しいものにしたRubyの部分です。Rubyの文字列はバイトバッファであり、String#unpackは、すぐそこにある、小さくて高速なバイナリパーサーです。
文字列はバイトです
最初に理解すべきことは、Rubyの文字列は「テキスト」ではないということです。それはエンコーディングラベルが付いたバイトのシーケンスです。バイナリモードでファイルを読み取ると、生のバイトが得られ、それはインデックス付けやスライスが文字列のように可能です。
data = File.binread("aib.big") # ファイル全体をASCII-8BIT文字列として
data[0, 4] # => "BIGF" — 最初の4バイト
data.bytesize # => 3448832
File.binreadが鍵です。これはファイルをバイナリ(ASCII-8BIT / BINARYエンコーディング)として読み取るため、UTF-8の解釈が0x80以上のバイトを台無しにすることはありません。そこから、data[offset, length]でバイト範囲を切り出し、data.index(needle, from)でファイル内のどこかにマジックナンバーやマーカーを見つけます。それはすでにパーサーの大部分です。
unpack: あなたがすでに持っているバイナリデコーダー
主力となるのはString#unpack(およびその単一値の兄弟であるunpack1)です。フォーマット文字列のディレクティブを渡すと、バイトをデコードします。ここで作業の90%を占めた2つのディレクティブは次のとおりです。
V — リトルエンディアンの符号なし32ビット整数。BIGFのすべてのカウント、ブロックインデックス、オフセット、およびサイズはVです。
e — リトルエンディアンの単精度浮動小数点数(32ビット)。AIデータはこれらの配列です。レーシングラインの座標、制御値、パディング。
data[4, 4].unpack1("V") # => 39 — エントリ数、u32 LEとして
data[12, 16].unpack("e4") # => [0.0, 0.0, 137.0, 0.0] — 4つのfloat32
エンディアンネスはディレクティブにあり、それがすべてです。Vはリトルエンディアンのu32、Nはビッグエンディアンです。eはリトルエンディアンのfloat、gはビッグエンディアンです。CodemastersのPCゲームはリトルエンディアンなので、ここではVとeが使われています。(後でXbox 360のファイル、ビッグエンディアンのPowerPCを見たとき、Nとgを使うことになります。フォーマット文字列だけが変わります。)
unpackはインタプリタ内でC言語で実装されているため、数百千の浮動小数点数をデコードしても遅くはありません。ここでは「スクリプト言語」のペナルティを払う必要はありません。
コンテナのウォーク
BIGFはヘッダー、ディレクトリ、データセクションで構成されています。
ヘッダーチェックは1行で済みます。
MAGIC = "BIGF".b
raise "not a BIGF archive" unless data[0, 4] == MAGIC
その.bは注釈に値します。それは文字列リテラルのバイナリコピーを返します。そのため、ソースファイルのエンコーディングに関係なく、バイトごとの比較が行われます。すべてのバイナリ定数にそれを使用します。
BIGFには2つのディレクトリレイアウトがあります。1つは固定24バイトレコードのフラットテーブルです――char name[16]; u32 size; u32 offset――これは教科書的なunpackループです。
count = data[4, 4].unpack1("V")
base = data[8, 4].unpack1("V") # ヘッダーから読み取られたデータセクションのベース(仮定ではない!)
off = 0x24 # レコードは0x20ヘッダー + 4バイトのパディングカウントの後に始まる
count.times do
rec = data[off, 24]
name = rec[0, 16].split("\x00").first.to_s # NUL終端の名前フィールド
size, offset = rec[16, 8].unpack("V2") # 一度に2つの整数を取得
members << Entry.new(name: name, offset: base + offset, size: size)
off += 24
end
ここで3つの小さなRubyの利便性が実際に役立っています。rec[0, 16].split("\x00").firstは、固定幅のNULパディングされたC文字列をRuby文字列に変換します。unpack("V2")は一度に2つの整数を取得します(カウントサフィックス)。そして、苦労して得た詳細ですが、baseは0x800であると常に仮定するのではなく、0x08のヘッダーフィールドから読み取られます。なぜなら、実際のファイルを1,371個測定した結果、常にそうではないことがわかったからです。
もう1つのレイアウトは可変長です。名前が0x44 00 00 00マーカーと混在しています。そこでString#indexが輝きます。拡張子をスキャンし、前のNULまで戻って名前の開始を見つけ、その直後にマーカーを探します。
while (idx = data.index(".aib", pos)) && idx < limit
s = idx
s -= 1
while s.positive? && data.getbyte(s - 1) != 0 # NULまで戻る
name = data[s...(idx + 4)] # ...マーカーとブロックインデックスが名前に続く...
pos = idx + 4
end
end
getbyteはサブ文字列を割り当てることなく単一のバイトを整数として読み取ります。これは、タイトな逆方向スキャンでまさに必要なものです。
レコードのデコード
メンバーを切り出すのは単なるスライスです。data[entry.offset, entry.size]――そしてRubyのスライスは安全です。ファイルの終端を超えたバイトを要求しても、短い文字列かnilが得られるだけで、クラッシュすることはありません。
AIプロファイル内では、16バイトごとに4つのfloat32があり、パーサーはビットパターンで各レコードを分類します。
SENTINEL = "\x3f\x3f\x3f\x3f".b.unpack1("e") # => 0.7470588…(パディング値)
KTAG_MAGIC = "\x0c\x00\x00\x00\x08\x00\x00\x00".b
def classify(bytes)
return [:ktag, bytes[8, 4].unpack1("e")] if bytes[0, 8] == KTAG_MAGIC
a, b, c, d = bytes.unpack("e4")
if [a, b, c, d].all? { |x| (x - SENTINEL).abs < 1e-5 }
then :pad
elsif a.zero? && b.zero? && c.zero? && d.zero?
then :zero
elsif b.zero? && d.zero?
then :scalar # (v,0,v,0)
elsif [a,b,c,d].all? { |x| coordish?(x) }
then :path # (x,y,x,y)
else :other
end
end
そのSENTINEL行は小さな喜びです。誰も計算機で「0x3f3f3f3fはどの浮動小数点数か?」を調べる必要はありませんでした。Rubyに4バイト(0.7470588…)をアンパックさせることで教えてもらいました。分類子はほとんど文章のように読め、それはあなたが特定しようとしているフォーマット仕様の文章である場合に重要です。
1つの実際の落とし穴はcoordish?にあります。浮動小数点数として読み取られた16バイトレコードの中には、デノーマル数やNaNがあります。RubyのFloat#nan?とマグニチュードチェックがそれをきれいに処理します――しかし、NaNに対してはx == xが偽になることを覚えておく必要があります。そのため、ガードは単純な比較ではなく、!x.nan? && x.abs < 1e30 && …となります。(RuboCopでさえ、x == xのトリックを書くとnan?に誘導されます。)
なぜRubyなのか、特に
これを終えて、バイナリREタスクにおけるRubyの利点は具体的です。
文字列をバッファとして扱うこととスライス機能により、ナビゲーションが人間工学的になります。カーソルオブジェクトや読み取り/シークの儀式はなく、data[off, len]だけです。unpackは、すべての整数と浮動小数点数の幅とエンディアンネスに対して1文字の語彙を持つ、完全で高速なCバックエンドのバイナリデコーダーです。依存関係はゼロです。リーダー全体が標準ライブラリです。5年後に見知らぬ人のマシンで実行する必要がある研究ツールは、APIがその後変更されたgemに依存すべきではありません。仕様のように読めます。レコードを分類するコードが頭に入るのに十分短い場合、そのコードはフォーマットのドキュメントとなり、それがリバースエンジニアリングの目的そのものです。REPLがループを閉じます。実際作業中、File.binreadと1行のunpackを使ったirbは、「0x5c00には何があるか?」と尋ねる最も速い方法であり、考えが終わる前に答えを得られます。
落とし穴は少なく、すべてバイナリランドにとどまることに関するものです。binreadで読み込み、.bでバイナリ定数を書き込み、エンディアンネスディレクティブを正しく設定し(N/gではなくV/e)、1つの値が必要な場合は配列の代わりにunpack1を使用し、NaNを尊重して扱います。それらのどれもRubyのせいではありません。それらは単にバイナリというものです。
2003年のレーシングゲームのAI、4バイトのマジックナンバー、オフセットのテーブル、そして数百千のリトルエンディアン浮動小数点数――すべてが20行の標準ライブラリRubyで読み取られました。has_many :commentsに使われる言語は、完璧な逆アセンブラのノートブックであることが判明しました――そして、疲れることなく次の16バイトをアンパックし続けるAIとペアになると、高速なノートブックになります。
どこを見るか
完全なリーダーはオープンソースです――String#unpackが2つのテーブルレイアウトと4つのゲームで活用されています。
リポジトリ: github.com/davidslv/bigf (MIT)
コンテナパーサー: l