未文書化データベースのストレージフォーマットのリバースエンジニアリング
Reverse engineering the storage format for an undocumented database (blog.glazer.ee)
要約
本記事では、既存のツールで正しく解析できなかったCronosProデータベースファイル(CroBank.dat、CroIndex.dat、CroStru.dat)の解析プロセスを解説します。特に、バージョン固有のニュアンスにより解析が困難なCronosProフォーマットのバイナリファイルを、CSVのような機械可読フォーマットに変換する作業に焦点を当てています。Codexを活用し、Cronodumpコードベースを改良して、破損していると見なされたデータベースの解析を改善しました。このプロセスでは、ファイルの構造理解、難読化されたスキーマの復旧、パーサーの仮定の修正、および解析値が正しいスキーマ列の下に表示されることの検証が含まれます。
全文翻訳
CronosダンプからCSVへ:レガシーデータベースフォーマットの復旧
私たちは常に様々なデータセットを扱っており、それらをデータレイクに正規化しています。最近、CronosProデータベースファイル(CroBank.dat、CroIndex.dat、CroStru.dat)を受け取りましたが、既存のツールでは正しく解析できなかったため、破損していると考えられていました。
CronosProフォーマットに携わったことのある人なら誰でも、その生データファイルがCSVのような機械可読フォーマットに変換するのが非常に難しいことを知っています。CronosダンプコンバーターであるCronodump alephdata/cronodump にはかなりの労力が注がれていますが、データベースファイル構造のバージョン固有のニュアンスのため、常に100%機能するわけではありません。この問題に取り組むため、Codexを使用してダンプ構造を分析し、Cronodumpのコードベースを改善して、破損しているとされたデータベースの解析を修正しました。
Cronodumpコンバーターはフォーマットの大部分を読み取ることができましたが、この特定のダンプを正しくデコードすることはできませんでした。ファイルがどのように組み合わさっているかを理解し、難読化されたスキーマを復旧し、いくつかのパーサーの仮定を修正し、解析された値が正しいスキーマ列の下に表示されていることを検証する必要がありました。
この記事では、Cronosに関する事前の知識が一切ない状態から、このプロセスを最初から説明します。
Cronosとは何か? #
Cronos、別名CronosProは、プロプライエタリなデスクトップデータベースおよび情報管理システムです。歴史的にロシアおよびその他の旧ソ連諸国で、レジストリ、検索可能なアーカイブ、ドキュメントコレクション、内部情報システムを構築するために使用されてきました。
A Cronosインストールは、従来のデータベースとは少し異なる用語を使用します。
| 従来の用語 | Cronosの用語 |
|---|---|
| Database | Bank |
| Table | Base |
| Record | Record |
| Field | Field |
| Record ID | System Number |
A Cronosデータベースは通常、いくつかの関連するバイナリファイルで表されます。
| ファイル | 目的 |
|---|---|
CroStru.dat および CroStru.tad | データベース構造:テーブル、フィールド、フォーム、その他の定義 |
CroBank.dat および CroBank.tad | 実際のレコード |
CroIndex.dat および CroIndex.tad | 検索インデックス |
CroSys.dat および CroSys.tad | Cronosインストールが認識しているデータベースに関する情報 |
| 追加ファイル | フォーム、数式、辞書、埋め込みドキュメント |
.dat ファイルにはデータが含まれています。対応する .tad ファイルはディレクトリとして機能し、ソフトウェアに各 .dat ファイル内のレコードの場所を伝えます。
有用なメンタルモデルは次のとおりです。
CroStru -> データベースの外観を説明
CroBank -> 実際の行が含まれる
CroIndex -> Cronosがそれらの行を見つけるのを助ける
この区別が復旧の中心となりました。レコードデータ自体は読み取り可能でしたが、それを解釈するためのスキーマは保護されていました。
「正規化」とはどういう意味か? #
この記事では、正規化はリレーショナルデータベースの第一、第二、第三正規形への正規化を意味しません。
ここでは、プロプライエタリなバイナリデータベースをポータブルでレビュー可能な表現に変換することを意味します。
Cronosバイナリファイル
|
v
デコードされたテーブルとフィールド
|
v
正しく型付けされ、整列された値
|
v
UTF-8 CSVファイル
目標は単に文字列を抽出することではありませんでした。結果は以下のような関係を維持する必要がありました。
- テーブルとレコード
- 列名と値
- 日付とその意味
- テキストとその元の文字エンコーディング
- 内部フィールドの位置
- 埋め込みファイル参照
間違ったヘッダーの下に読み取り可能な値を含むCSVは、明白なエラーよりも悪い可能性があります。なぜなら、意味論的に破損していても有効に見える可能性があるからです。
既存のパーサーの選択 #
私たちはオープンソースのCronodumpパッケージから始めました。これは主に2つのコマンドを提供します。
crodumpはCronosファイルを検査し、その内部構造を出力します。croconvertはデータベースをCSV、PostgreSQL SQL、またはHTMLにエクスポートします。
典型的なダンプの場合、変換は次のように簡単です。
croconvert --csv /path/to/cronos-dump
内部的に、パッケージはいくつかのジョブを実行します。
.tadファイルを読み取ってレコードを特定します。.datファイルから対応するバイトを取得します。- 必要に応じてレコードを解凍します。
- KODテーブルを使用して保護されたレコードをデコードします。
CroStruからスキーマを読み取ります。- そのスキーマを使用して
CroBankレコードをフィールドに分割します。 - 結果のテーブルをCSVに書き込みます。
リポジトリのドキュメントでは、Cronosフォーマットの一部はまだ完全にリバースエンジニアリングされていないと明示的に述べられています。パーサーは多くのデータベースをサポートしていますが、異常なバージョン固有の詳細については、まだ調査が必要な場合があります。
最初の失敗 #
コンバーターは CroStru.dat の読み取り中に失敗しました。
これにより、次のような基本的な質問にまだ答えられませんでした。
- いくつのテーブルが存在するか?
- それらの名前は何か?
- 各テーブルにはいくつのフィールドが含まれているか?
- どのフィールドが名前、日付、識別子、またはファイル参照か?
- 各値は出力のどこに属するか?
大きな CroBank.dat ファイルには実際のレコードが含まれていましたが、有効なスキーマがないと、実質的にバイナリマーカーで区切られた値のストリームでした。
状況は次のようになりました。
CroStru.dat -- デコード失敗 --> 信頼できる列定義なし
|
v
CroBank.dat --------------------> 値を安全に解釈できない
何かを解析しようとする前に、すべての主要ファイルのヘッダーを検査しました。
ダンプはCronos 01.11 フォーマットを使用しており、Cronos v4に関連付けられていました。コンポーネントフラグは重要な詳細を明らかにしました。
CroStruはKODエンコードされていました。CroBankは圧縮されていましたが、KODエンコードされていませんでした。CroIndexは圧縮されていましたが、KODエンコードされていませんでした。
これにより、問題の範囲が大幅に縮小しました。
保護されたレコードデータの数十ギガバイトをデコードする必要はありませんでした。比較的少ないスキーマファイルを復旧するだけで済みました。スキーマが読み取り可能になれば、通常のパーサーははるかに大きなレコードファイルを解釈できました。
圧縮と保護は異なるレイヤー #
混同しやすい2つの概念を分離することは有用です。
圧縮はデータをより少ないスペースで占めるように変更します。
元のバイト -> 圧縮 -> より小さいエンコードされたバイト
KOD保護は、バイト値を置換テーブルを使用して変更します。
元のバイト -> 位置依存の置換 -> 保護されたバイト
ファイルは次のいずれかになります。
- 圧縮もKODエンコードもされていない
- 圧縮のみ
- KODエンコードのみ
- 圧縮とKODエンコードの両方
このダンプのファイルはすべて同じ組み合わせを使用していたわけではありません。
これは重要でした。なぜなら、cronodump のクラッキング方法の1つは、圧縮された CroBank および CroIndex レコードの予測可能なバイトからKODテーブルを学習できると仮定しているからです。ここでは、それらのファイルはKODエンコードされていなかったため、その仮定は適用されませんでした。
KODテーブルとは何か? #
Cronos v4以降は、KODテーブルまたはKOD S-boxと呼ばれる256エントリのバイト置換テーブルを変更することでデータベースを保護できます。
最も単純なレベルでは、置換テーブルは次のように述べています。
暗号化されたバイト 0x00 -> デコードされた値 X
暗号化されたバイト 0x01 -> デコードされた値 Y
...
暗号化されたバイト 0xFF -> デコードされた値 Z
Cronosは別の複雑さを追加します。デコードはバイトの位置とレコード番号にも依存します。
概念的には、アルゴリズムは次のようになります。
plaintext[i] = KOD[ciphertext[i]] - i - record_number (mod 256)
ここで:
ciphertext[i]は格納されているバイトです。KOD[...]は置換を実行します。iはレコード内の位置です。record_numberは別のシフトを提供します。- 算術は256でラップアラウンドします。
実際の結果として、1バイトの違いでも、それに続くすべてのデコードが変更されます。
これは、KODテーブルが妥当なものとして復旧した後でも、1つのスキーマレコードが読み取れないままだった理由を後に説明しました。
既存のクラッキングツールは何をするか #
cronodump には、2つの関連する復旧オプションが含まれています。
crodump --strucrack ...
crodump --dbcrack ...
これらは、パスワードの総当たりではなく、Cronosファイルの統計的特性を使用します。
strucrack #
バイナリスキーマには多くの繰り返し値が含まれています。特に、長さ、フラグ、パディング、整数で使用されるゼロバイトが多く含まれます。
strucrack は、暗号化されたバイトをその効果的な位置ごとにグループ化し、最も一般的な結果がプレーンテキストのゼロを表すと仮定します。十分なスキーマデータがあれば、これによりKODテーブルの多くが明らかになる可能性があります。
dbcrack #
圧縮されたレコードには認識可能なヘッダーがあります。適切なデータベースでは、それらのヘッダーの予測可能なバイトを使用して、CroBank および CroIndex からのKODマッピングを推測できます。
これらは両方ともヒューリスティックです。ソースが期待するパターンの十分な量を含んでいる場合に機能します。
私たちのケースでは:
CroStruには、strucrackがすべてのエントリを確実に復旧するのに十分な証拠が含まれていませんでした。CroBankおよびCroIndexはKODエンコードされていなかったため、dbcrackは適用できませんでした。
また、小さなソフトウェアバグもありました。クラッキングパスは、必須の compact プロパティなしで一時的な引数オブジェクトを作成しました。それを追加することでヒューリスティックを実行できるようになりました。
cargs.compact = args.compact
それでもテーブルを自動的に復旧することはできませんでしたが、プログラムエラーから実際のデータ復旧の問題に移行しました。
関連するアップストリーム作業 #
cronodump リポジトリには、プルリクエスト #22 に関連付けられた、erdgeist-strucrack-ambigous-kods という名前の未マージのブランチが含まれています。
その作業は、同じ広範な弱点に対処しています。strucrack は、すべてのKODエントリを自信を持って決定するのに十分な証拠を持っていない場合があります。
プルリクエストは、インタラクティブな復旧ワークフローを追加します。
- 推定された各KODエントリの信頼度を追跡する。
- 未解決のままのエントリをマークする。
- 重複マッピングを検出する。
- 部分的にデコードされたスキーマデータを印刷する。
BankNameやUSERINFOのような可能性のある文字列を検索する。- オペレーターが個々の修正を提供できるようにする。
- オペレーターが特定のレコード位置に既知のプレーンテキストを提供できるようにする。
strucrackがcroconvertから呼び出される方法を改善する。
意図されたプロセスはおおよそ次のようになります。
自動ヒューリスティックを実行
|
v
部分的にデコードされたテキストを検査
|
v
可能性のある単語を認識
|
v
既知のプレーンテキストヒントを提供する
|
v
残りのKODエントリを解決する
私たちの状況では、KODの曖昧さは最初の問題に過ぎなかったため、さらなる変更が必要でした。アップストリームブランチは以下に対処していないようでした。
- Cronos v4インラインレコードヘッダーがデコードをシフトさせた。
- レコード内の隠された物理フィールド。
- ドキュメント化されたテキストフィールドタイプの誤った処理。
したがって、私たちは同じ一般的なアイデアを使用しましたが、割り当て方法を自動化しました。
より良い統計参照の構築 #
より小さな読み取り可能なCronosコンポーネントが、メインダンプと並行して利用可能でした。cronodump リポジトリには、既知のフィールドタイプを持つテストデータベースも含まれていました。
これらは、有効なデコードされたCronos構造がどのように見えるかの例を提供してくれました。
私たちはそれらのスキーマをコピーして、未知のデータベースが同一であると仮定しませんでした。代わりに、それらを使用してCronosスキーマレコードの一般的なバイト頻度を推定しました。
例えば、有効なスキーマデータには以下が含まれる傾向があります。
- 多くのゼロバイト
- 小さなバイナリ整数
- 長さプレフィックス付きの名前
- 繰り返し構造マーカー
- ASCIIプロパティ名
- Windows-1251テキスト
- 繰り返しフィールド定義レイアウト
未知のスキーマの各暗号化バイトについて、私たちは知っていました。
- その暗号文値
- そのバイト位置
- そのレコード番号
各可能なKODマッピングについて、プレーンテキストバイトが生成されるものを計算し、そのバイトが参照分布に従ってどれほど妥当であるかをスコアリングできました。
これにより、スコアテーブルが生成されました。
可能なKOD出力
0x00 0x01 0x02 ... 0xFF
暗号文 0x00 スコア スコア スコア ... スコア
暗号文 0x01 スコア スコア スコア ... スコア
...
暗号文 0xFF スコア スコア スコア ... スコア
なぜこれが割り当て問題なのか #
KODテーブルは順列でなければなりません。
つまり:
- 各暗号文バイトは正確に1つのマッピングを持ちます。
- 2つの暗号文バイトが同じKOD値にマッピングされることはありません。
0x00から0xFFまでのすべての値が1回出現する必要があります。
各バイトについて独立して最もスコアの高い値を選択するだけでは不十分でした。いくつかの暗号文バイトが同じ出力を選択し、無効なテーブルを生成する可能性があります。
代わりに、一意性を強制しながら、最良の全体的なマッピングセットを選択する必要がありました。
これは古典的な割り当て問題です。SciPyのハンガリアンアルゴリズムの実装を使用して解決しました。
from scipy.optimize import linear_sum_assignment
rows, columns = linear_sum_assignment(-scores)
linear_sum_assignment はコストを最小化するため、スコアを否定して最大尤度割り当てを選択しました。
結果は完全な256バイトのKOD順列でした。この時点で、私たちは証明されたものではなく、候補キーを持っていました。
「読み取り可能に見える」のではなく構造的な検証 #
読み取り可能な出力だけでは証拠としては弱いです。間違った置換が偶然、文字、数字、または馴染みのある断片を生成することがあります。
候補KODテーブルを期待されるCronos構造に対して検証しました。
- レコードは有効なタイプマーカーで始まっていたか?
- 長さプレフィックス付きの名前は、レコードを超えずに読み取れたか?
Bank、BankId、BankNameのような既知のキーが表示されたか?BaseNNNエントリは有効なテーブル定義を指していたか?- テーブルIDとフィールド数は妥当だったか?
- 参照されたスキーマレコードは一貫してデコードされたか?
- Windows-1251テキストは意味のあるフィールド名にデコードされたか?
- 最終的なKODテーブルは真の256バイト順列だったか?
これらのチェックの1つを除くすべてのスキーマレコードが合格し、次の発見につながりました。
最初のスキーマレコードには追加ヘッダーがあった #
最初の CroStru レコードは、Cronos v4のフラグ値 0x08 を使用していました。
格納されたデータは、12バイトの拡張ヘッダーで始まっていました。
uint64 extent_offset
uint32 extent_length
パーサーは、ゼロ以外のフラグをすべて、レコードバイトが直接デコード可能であることを意味すると解釈しました。そのため、ヘッダーとペイロードを一緒にKODデコーダーに送信しました。
KODデコードがバイト位置に依存することを思い出してください。
plaintext[i] = KOD[ciphertext[i]] - i - record_number
12個のヘッダーバイトを含めることは、12個の不要な文字を生成しただけではありません。ペイロードバイトのすべての位置 i を変更しました。
その結果、レコード全体が12のオフセットでデコードされました。
状況は次のようでした。
格納されたレコード:
[12バイトの拡張ヘッダー][暗号化されたスキーマペイロード]
パーサーが仮定したこと:
[ 暗号化されたスキーマペイロード ]
^ デコードはここから始まる
正しい解釈:
[このヘッダーをスキップ][暗号化されたスキーマペイロード]
^ デコードはここから始まる
パーサーは、v4の 0x08 表現を認識し、拡張ヘッダーを読み取り、宣言されたペイロードのみをデコードするように調整されました。
elif not flags or (self.isv4() and flags == 0x08):
if self.use64bit:
next_offset, extent_length = struct.unpack("<QL", data[:12])
payload_offset = 12
ヘッダーを削除した後、レコードは次のように始まりました。
03 04 42 61 6e 6b
バイトレベルでは、それは意味します。
03 スキーマレコードマーカー
04 後続の名前の長さ
42 61 6e 6b ASCIIテキスト "Bank"
これは、拡張処理と復旧されたKODテーブルの両方が正しいことの強力な構造的証拠でした。
デコードの失敗が常に間違ったキーを示すとは限らないという教訓でした。時には正しいデコーダーが間違った境界で適用されているだけです。
テーブルの復旧 #
CroStru が正しくデコードされると、パーサーは論理データベース構造を再構築できました。
データベース定義
|
+-- テーブル定義
| |
| +-- フィールド名
| +-- フィールドタイプ
| +-- 物理位置
| +-- 最大長
|
+-- 別のテーブル定義
|
+-- フォームおよびその他のメタデータ
復旧されたスキーマには、通常のデータテーブルと関連するファイルテーブルが含まれていました。
これにより、コンバーターは匿名バイトシーケンスから名前付き列を持つレコードに移行できました。
最初のCSVエクスポートが開始されましたが、サンプリングすると別の問題が明らかになりました。
テキストが16進数としてエクスポートされた #
CSVは正しい列数でしたが、多くのテキスト値は次のようになりました。
c1 c8 d7 20 c2 ce cb ce c4 c8 cc c8 d0
これらは破損したバイトではなく、16進数で表されたWindows-1251テキストでした。
Cronosは一般的にキリル文字をWindows-1251で格納します。パーサーの以前の安全変更により、不明なフィールドタイプは任意のバイナリデータをテキストとしてデコードするのではなく、16進数として保持されていました。これはドキュメント化されていないタイプには賢明でしたが、既知のテキストタイプにも影響していました。
修正は、ドキュメント化されたタイプ1、2、および3を明示的に処理することでした。
elif self.typ in (1, 2, 3):
self.content = data.rstrip(b"\x00").decode("cp1251", "ignore")
その後、変換パスは次のようになりました。
Cronos CP1251バイト
|
v
Python Unicodeテキスト
|
v
UTF-8 CSV
不明なバイナリタイプは、引き続きロスレスで16進数としてエクスポートされました。この修正後、名前やその他のテキスト値は読み取り可能になりましたが、一部は間違ったヘッダーの下に表示されました。
なぜ読み取り可能な値だけでは不十分だったのか #
単純化されたテーブル定義を考えてみましょう。
フィールドAは物理位置2を持つ
フィールドBは物理位置4を持つ
フィールドCは物理位置7を持つ
元のパーサーは、これらが3つの表示されている定義であるため、最初の3つの格納された値に対応すると仮定しました。
フィールドA <- 格納された値1
フィールドB <- 格納された値2
フィールドC <- 格納された値3
その仮定は間違っていました。
Cronosレコードには、通常の表示定義を持たない内部または隠しフィールドが含まれる場合があります。フィールド定義の idx2 値は、シリアル化されたレコード内の実際の位置を指定します。
正しいマッピングは次のとおりでした。
フィールドA <- 格納された値2
フィールドB <- 格納された値4
フィールドC <- 格納された値7
値1、3、5、6はエクスポートされなかったにもかかわらず、消費する必要がありました。
それらのギャップを考慮しないと、CSVは表面的には有効に見えました。
- 名前は読み取り可能だった。
- 日付は日付のように見えた。
- 識別子は識別子のように見えた。
しかし、値は無関係な列の下にシフトされていました。これは、出力サンプリングが不可欠である種類の誤りです。
物理フィールド位置の尊重 #
レコードパーサーは、現在の物理フィールド位置を追跡するように変更されました。
各表示定義を読み取る前に、定義の idx2 位置に到達するまで隠しフィールドを消費します。
source_index = 1
for field_definition in table_definition[1:]:
while source_index < field_definition.idx2 and not reader.eof():
read_field_data(reader)
source_index += 1
value = read_field_data(reader) if not reader.eof() else b""
source_index += 1
Cronosは 0x1b で始まる複雑なフィールド値もサポートしているため、フィールド消費はそれらの動作を維持する必要がありました。
def read_field_data(reader):
if reader.testbyte(0x1b):
reader.readbyte()
size = reader.readdword()
return reader.readbytes(size)
return reader.readtoseperator(b"\x1e")
この変更後、境界付きサンプルは期待される関係を示しました。
識別子列 -> 識別子のような値
名前列 -> 人間の名前
住所列 -> 住所
日付列 -> 有効な日付
カテゴリ列 -> カテゴリ値
結果 #
最終的なCSV変換は以下を組み合わせました。
- オープンソースの
cronodumpパッケージ - プルリクエスト #22 の曖昧なKOD作業に関連するアイデア
- 統計的なKOD再構築
- ハンガリアンアルゴリズムを使用したグローバル割り当て
- 正しいCronos v4インライン拡張処理
- Windows-1251からUnicodeへの変換
- 物理フィールド位置の追跡
- 境界付き意味論的検証