HotXLSが書き出すExcel 5.0/95(BIFF5)のXOR難読化ワークブックがExcel 16で開けるのは、3つのディテールが[MS-OFFCRYPTO]と厳密に一致するときだけです。FILEPASSの鍵はCreateXorKey_Method1(password)であること。16バイトのXOR配列はXorRor(1ビット右ローテート)で組み立てること。そして各バイトはXorArrayIndex = (stream offset + record length) mod 16を使うこと。HotXLSはインデックスをv2.384.47で、鍵とローテーションをv2.384.54で直しました。それ以前は、パスワード保護付きのBIFF5ファイルがHotXLSでは問題なく開き、Excelでは開けなかったのです
この最後の一文が、話の全体を縮小したものです。同じ誤解を共有するリーダーとライターは互いに完全に一致します。往復テストはグリーンのままで、本当に大事な唯一のコンシューマーだけがノーと言う。Excel 16は2回ノーと言いました。メッセージは2種類で、それぞれがスキームの異なるレイヤーを指していました。この記事では、Excelがチェックする順にそのレイヤーをたどります。HotXLSを使うにせよ自前のBIFFリーダーを書くにせよ、役立つバイトレベルのディテール付きで
BIFFのXOR難読化は実際には何を保存するのか
BIFFのXOR難読化がファイルに保存するのは2つの16ビットワードだけで、あとはすべてパスワードから再計算されます。FILEPASSレコード($002F)はワークブックグローバルのBOFの直後に置かれ、BIFF5ファイルでは本体は正確に4バイト、XOR鍵の後にパスワード検証子が続きます。saltもアルゴリズム識別子も、RC4やAESのスキームが持つような暗号化された検証子ブロブもありません
この2つのワードから、リーダーは3つのものを組み立て直します:
- 検証子。パスワードバイトの16ビットハッシュで、
$CE4BとXORしたものです。保存されたワードと比較することがパスワードチェックで、それしかありません - XOR鍵。[MS-OFFCRYPTO] §2.3.7.2の
CreateXorKey_Method1が作る16ビット値で、2つの定数テーブル(15ワードのInitialCodeと105ワードのXorMatrix)に駆動されます - XOR配列。16バイト構成で、パスワードバイトを固定の16バイトパッドで埋め、それぞれを鍵の下位バイト(偶数位置)か上位バイト(奇数位置)とXORし、最後に1ビット右ローテートします
レコードヘッダーは平文のままです。スキームが免除する少数のレコード全体もそうで、BOF、FILEPASS、INTERFACEHDRがそれに当たります。それ以外のレコード本体はバイトごとに変換されます。5ビット左ローテートしてから、16バイト配列の1要素とXORする。[MS-OFFCRYPTO] §2.3.7.3がDecryptData_Method1として綴る復号はその鏡像で、XORが先、それから5ビット右ローテートです
HotXLSでは、このどれにも直接触れません。パスワードを設定し、フォーマットを選べば、SaveAsがFILEPASSを出力してストリームを変換します:
uses
SysUtils, lxHandle;
procedure SaveLegacyProtectedBook(const FileName: string);
var
Wb: IXLSWorkbook;
begin
Wb := TXLSWorkbook.Create;
Wb.Sheets.Add.Name := 'Ledger';
Wb.Sheets[1].Range['A1', 'A1'].Value := 'Account';
Wb.Sheets[1].Range['B1', 'B1'].Value := 1250.75;
// BIFF5が対応するのはXOR難読化のみ。xletAutoでもこれが選ばれる
Wb.EncryptionType := xletXor;
// ASCIIかつ15文字以内にすること(後述)
Wb.EncryptionPassword := 'secret';
if Wb.SaveAs(FileName, xlExcel5) <> 1 then
raise Exception.Create('BIFF5 save failed');
end;
検証子が一致しているのに、なぜExcelはパスワードが間違っていると言うのか
Excelがパスワードを拒否するのは、保存された鍵を信頼しないからです。ExcelはCreateXorKey_Method1で入力されたパスワードから鍵を導出し、FILEPASSの鍵ワードと比較します。鍵が別物のファイルは、検証子が正しくてもパスワードチェックに落ちるのです。仕様は鍵をパスワードの出力として記述していて、自由なパラメータではありません。Excel 16はその読みを強制します
v2.384.54より前のHotXLSライターは、鍵ワードをランダムな2バイトで埋めていました。紙の上では無害に見えます。検証子こそが文書化されたパスワードチェックであり、配列はファイルが宣言した鍵が何であれそこから組み立てられるからです。HotXLS自身はそのファイルを問題なく読めました。リーダーがFILEPASSの鍵を与えられたものとして受け取っていたからです。ところが同じファイルと正しいパスワードを渡されたExcel 16は、パスワードが正しくないと答えました。v2.384.54以降は鍵が導出されるので、パスワードsecretのFILEPASSは常に鍵$014Dと検証子$DAA7を持ちます。仕様の独立実装とクロスチェック済みの値です
2つのテーブルが揃えば、導出そのものは短く済みます。パスワードを後ろからたどり、各バイトのビット6を左シフトしながら7回見て、ビットが立つたびにXorMatrixの1要素をXORで混ぜます。以下は仕様のアルゴリズムを再現し、HotXLSの実装とも一致する原理のスケッチです。HotXLSのAPIではありません:
// [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1の原理スケッチ
// CreateXorArray_Method1も同様(説明用。HotXLSのAPIではない)
type
TXorArray = array [0..15] of Byte;
function DemoCreateXorKey(const Password: AnsiString): Word;
const
InitialCode: array [0..14] of Word = ($E1F0, $1D0F, $CC9C, $84C0, $110C,
$0E10, $F1CE, $313E, $1872, $E139, $D40F, $84F9, $280C, $A96A, $4EC3);
XorMatrix: array [0..104] of Word = (
$AEFC, $4DD9, $9BB2, $2745, $4E8A, $9D14, $2A09,
$7B61, $F6C2, $FDA5, $EB6B, $C6F7, $9DCF, $2BBF,
$4563, $8AC6, $05AD, $0B5A, $16B4, $2D68, $5AD0,
$0375, $06EA, $0DD4, $1BA8, $3750, $6EA0, $DD40,
$D849, $A0B3, $5147, $A28E, $553D, $AA7A, $44D5,
$6F45, $DE8A, $AD35, $4A4B, $9496, $390D, $721A,
$EB23, $C667, $9CEF, $29FF, $53FE, $A7FC, $5FD9,
$47D3, $8FA6, $0F6D, $1EDA, $3DB4, $7B68, $F6D0,
$B861, $60E3, $C1C6, $93AD, $377B, $6EF6, $DDEC,
$45A0, $8B40, $06A1, $0D42, $1A84, $3508, $6A10,
$AA51, $4483, $8906, $022D, $045A, $08B4, $1168,
$76B4, $ED68, $CAF1, $85C3, $1BA7, $374E, $6E9C,
$3730, $6E60, $DCC0, $A9A1, $4363, $86C6, $1DAD,
$3331, $6662, $CCC4, $89A9, $0373, $06E6, $0DCC,
$1021, $2042, $4084, $8108, $1231, $2462, $48C4);
var
Len, I, Bit, Element: Integer;
Ch: Byte;
begin
Result := 0;
Len := Length(Password);
if Len > 15 then
Len := 15; // キーが見るのは先頭15バイトまで
if Len = 0 then
Exit;
Result := InitialCode[Len - 1];
Element := $68; // XorMatrixの末尾の要素
for I := Len downto 1 do
begin
Ch := Ord(Password[I]);
for Bit := 1 to 7 do
begin
if (Ch and $40) <> 0 then
Result := Result xor XorMatrix[Element];
Ch := Byte(Ch shl 1);
Dec(Element);
end;
end;
end;
function XorRor(B, KeyByte: Byte): Byte;
begin
B := B xor KeyByte;
Result := Byte((B shr 1) or (B shl 7)); // 1ビット右ローテート
end;
procedure DemoCreateXorArray(const Password: AnsiString; out Arr: TXorArray);
const
PadArray: TXorArray = ($BB, $FF, $FF, $BA, $FF, $FF, $B9, $80,
$00, $BE, $0F, $00, $BF, $0F, $00, $00);
var
Key: Word;
Len, I: Integer;
begin
Key := DemoCreateXorKey(Password);
Len := Length(Password);
if Len > 16 then
Len := 16;
for I := 0 to Len - 1 do
Arr[I] := Ord(Password[I + 1]);
for I := Len to 15 do
Arr[I] := PadArray[I - Len];
for I := 0 to 15 do
if Odd(I) then
Arr[I] := XorRor(Arr[I], Byte(Key shr 8))
else
Arr[I] := XorRor(Arr[I], Byte(Key and $FF));
end;
secretの場合、この結果できるのは1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DFという配列です。自前のリーダーをテストするなら便利なフィクスチャになります
鍵が正しくても、なぜ壊れたファイルができるのか
XOR配列が逆方向にローテートされていると、鍵が正しくても壊れたファイルになります。[MS-OFFCRYPTO]は配列のステップをXorRor、つまり1ビット右ローテートと定義しており、2ビット左にローテートした配列は、レコード本体をすべてノイズに復号します。鍵を直したことで、Excel 16はパスワードプロンプトを通過し、今度は別のエラー、ファイルに問題があり開けないという報告へ直行しました
古いHotXLSのコードは、配列の各バイトを2ビット左にローテートしていました。いくつかのBIFF実装に出回っている形です。HotXLSは両側で同じローテートを使っていたため、自分のリーダーは気づきもしませんでした。Excel 16はもうExcel 5.0/95ファイルを保存できず、BIFF8の保存でもXORを提供しないので、差分を取れるネイティブなExcelサンプルが存在しません。証拠は逆向きから集めるしかありませんでした。平文のBIFF5ストリームを1つ書き、8通りの方法で再エンコードし、Excel 16に各バリアントを開かせるのです。8つのバリアントは、3つの独立な選択の組み合わせでした:
| 選択肢 | オプションA | オプションB |
|---|---|---|
| 配列のローテート | XorRor(1ビット右) | 2ビット左 |
| 配列インデックス | (offset + record length) mod 16 | offset mod 16 |
| バイト変換の順序 | 5ビット左ローテート、その後XOR | XOR、その後5ビット左ローテート |
Excel 16が開けたのは8つのうち正確に2つでした。XorRor+ローテートしてからXOR+レコード長インデックスのバリアントと、見た目だけ違うもう1つです。2ビット左ローテート+XORしてからローテート+同じインデックスのバリアントは、同じ関数の変装です。ローテートはXORに分配されるので、rol5(p xor rol2(b))はrol5(p) xor rol7(b)に等しく、8ビット値では7ビット左ローテートは1ビット右ローテートです。要するにrol5 ∘ rol2 = ror1で、だからこそ2ビット左ローテートの配列は単独ではもっともらしく見えます。逆の変換順序と組み合わせれば正しいのです。仕様の順序と組むと、変換されるバイトをすべて壊します
同じ実験が2つ目の疑問も片付けました。インデックスからレコード長を落としたバリアントはすべて失敗しました。HotXLSが1リリース前に、仕様のテキストだけを根拠に採用していたインデックスルールの裏取りになったのです
XorArrayIndexはバイトごとにどう計算されるのか
あるバイトのXorArrayIndexは、ワークブックストリーム内のオフセットに、そのバイトが属するレコード全体のデータ長を足したものを16で割った余りです。したがってインデックスはレコードごとにレコード依存の値から再始動し、レコード内ではバイトごとに1ずつ増えます。仕様の擬似コードは入力をFileOffsetとData.Lengthと名付けており、これをレコード開始オフセットだけと誤読しやすい。そしてその誤読こそ、HotXLSがv2.384.47まで出荷していたものです
インデックスがExcelと揃うかどうかは、3つのディテールで決まります:
- 4バイトのレコードヘッダーは決して変換されませんが、ストリーム上の位置は占有します。だからレコードの最初の本体バイトは、ヘッダーオフセット + 4の位置にあります
- 長さの項はレコードデータ全体の長さであって、実際に変換されたバイト数ではありません
- BOUNDSHEETは部分的に平文です。最初の4バイト、シートBOFのストリームオフセットである
lbPlyPosは読めるままなので、パーサーがシートを特定できます。この4バイトは変換からスキップされますが、オフセットとレコード長の両方に数えられます
まとめると、レコードごとの変換は数行で済みます。繰り返しますが、これはルールのスケッチであって、呼ぶ必要のあるものではありません:
function Rol8(B: Byte; N: Integer): Byte;
begin
Result := Byte((B shl N) or (B shr (8 - N)));
end;
// レコード本体をインプレースで難読化する。BodyPosはBody[0]の
// ストリームオフセット、つまりレコードヘッダーオフセット+4。PlainPrefixは
// BOUNDSHEETでは4、BOF / FILEPASSでは全長、ほとんどのレコードでは0
procedure DemoObfuscateRecord(var Body: array of Byte; RecordLength: Word;
PlainPrefix: Integer; BodyPos: LongWord; const Arr: TXorArray);
var
I: Integer;
begin
for I := PlainPrefix to RecordLength - 1 do
Body[I] := Rol8(Body[I], 5) xor
Arr[(BodyPos + LongWord(I) + RecordLength) mod 16];
end;
// 読み込みは鏡像:B := Body[I] xor Arr[...];
// その後5ビット右ローテート、つまりRol8(B, 3)
v2.384.47より前、HotXLSのリーダーはインデックスをストリーム位置だけから計算し、ライターは平文バイトオフセットを使っていました。どちらもレコード長を無視しており、これもまた両側が互いに一致して、他の誰とも一致しない形です。独立に書かれたデコーダーはv2.384.47の出力を正しく読み、それより古い出力をゴミとして読みました。そして8バリアントのExcel 16テストが、後になってこのルールを本物のターゲットに対して確認しました
古いHotXLSバージョンが書いたXORファイルはどうなるのか
HotXLSは、v2.384.54より前に自分が書いたXORファイルを、FILEPASSの鍵をチェックすることで読み続けられます。保存された鍵がパスワードから導出した鍵と等しければ、リーダーは仕様どおりのXorRor配列を組み立てます。異なっていれば、そのファイルを古いHotXLSのファイルと扱い、2ビット左ローテートの配列を組み立て直します。Excelが書いたファイルは常に導出鍵を持つので、常に仕様の経路を通ります
この判定は、失敗率が正確に分かっているヒューリスティックです。ランダムな鍵がたまたま導出鍵と等しい古いファイルは、間違った配列で読まれることになり、その確率は65,536分の1です。フォールバックが切り替えるのは配列のローテートだけです。インデックスルールは切り替わらないので、救われるのはv2.384.47からv2.384.53の間に書かれたファイルです。その期間のBIFF5 XORファイルをまだ持っているなら、現在のHotXLSで開いて再保存すれば、Excelが受け入れるファイルになります
古い新しいに関係なく、すべてのファイルに当てはまるパスワードのディテールが2つあります:
- 長さ。
CreateXorKey_Method1が読むのはパスワードの先頭15バイトだけで、これが仕様の上限です。HotXLSはその上限を鍵に適用し、検証子と配列は通常どおり全長と16バイトのルールのまま、両側で一貫させています。Excel自身もこのフォーマットでは15文字を超えるパスワードを拒むので、15を実質の最大と考えてください - 文字セット。HotXLSはパスワードをシステムのANSIコードページ経由でバイトへ変換します。仕様は各UTF-16文字の下位バイトを取ると記述しており、ASCIIなら一致します。非ASCIIパスワードで保護されたExcelサンプルがない以上、それ以外については正解が存在しません。だからXORファイルのパスワードはASCIIに限定してください
読み込み側では、TXLSWorkbook.OnPasswordを使えば、OpenがFILEPASSレコードに出会ったときにパスワードを尋ねられます。イベントはTXLSPasswordEventで、var PassWord: WideStringとvar Retry: Booleanを持ちます。RetryをTrueにすると再試行でき、リトライは最大3回です:
procedure TImportForm.WorkbookPassword(Sender: TObject;
var PassWord: WideString; var Retry: Boolean);
var
S: string;
begin
S := '';
Retry := InputQuery('Protected workbook', 'Password:', S);
PassWord := S;
end;
procedure TImportForm.ImportLegacyFile(const FileName: string);
var
Wb: IXLSWorkbook;
Rc: Integer;
begin
Wb := TXLSWorkbook.Create;
Wb.OnPassword := WorkbookPassword;
Rc := Wb.Open(FileName);
// -1003:パスワードが必要だが未指定。-1005:パスワードが間違っている
if Rc <> 1 then
raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;
パスワードが分かっているなら、Open(FileName, APassWord)はイベントをまるごとスキップします
XOR難読化に十分な安全性はあるのか
BIFFのXOR難読化は暗号化ではなく、やる気のある読み手には何も守りません。パスワードチェックは16ビットの検証子、鍵は16ビット、16バイトの配列はストリーム全体で繰り返します。だからBIFFレコードの中身が予測できるなら、パスワードなしで配列のバイトが暴露されます。HotXLSがXORを書くのは、Excel 5.0/95ファイルに他の選択肢がないからにすぎません。今日この種のファイルを作る理由は、より新しいものを何も読めないレガシーコンシューマーです
クラシックエンジンはスキームをTXLSWorkbook.EncryptionTypeで選択し、保存フォーマットとの組み合わせは厳しくチェックされます:
xletAuto(デフォルト)はxlExcel97にはRC4 CryptoAPIを、xlExcel5にはXORを書きます。Excel自身が各フォーマットで書いていたものと一致しますxletXorはBIFF5でのみ有効です。xlExcel97では、黙ってフォールバックする代わりに保存が例外を上げますxletRC4とxletRC4CryptoAPIはBIFF8専用で、BIFF5の保存でこれらを要求しても例外が上がります
RC4も古い方式で、相互運用させるための詳細は正しいパスワードでもExcelが暗号化ワークブックを拒否する理由で扱っています。受信者がXLSXを読めるなら、XLSXエンジンを使ってください。TXLSXWorkbook.SaveAsEncryptedAgileはAgile Encryption(10万回のスピンカウントでSHA-512のパスワードハッシュ化、AES-256-CBC)を書きます。Excel 2010以降がデフォルトで書くフォーマットです。SaveAsEncryptedはより古いAES-128のStandard Encryptionを書きます。両者のトレードオフはDelphiでAESを使いXLSXファイルを暗号化するに、読み込み側はHotXLSでAgile暗号化Excelファイルを読むにあります
クイックリファレンス:Excel 16が受け入れるBIFF XOR難読化
- FILEPASS(
$002F)はグローバルBOFの後に続きます。BIFF5では本体は4バイト、鍵の後に検証子です - 鍵 = [MS-OFFCRYPTO] §2.3.7.2に基づく
CreateXorKey_Method1(password)。決してランダムにしない。secretなら$014Dです - 配列 = パスワードバイト + パッド。偶数位置は鍵の下位バイト、奇数位置は上位バイトとXORし、その後XorRor(1ビット右ローテート)
- 1バイトの暗号化:5ビット左ローテート、その後XOR。復号:XOR、その後5ビット右ローテート(§2.3.7.3)
- XorArrayIndex = (ストリームオフセット + レコードデータ長) mod 16。ヘッダーと平文プレフィックスもオフセットに数えます
- BOUNDSHEETは最初の4バイトを平文のまま保ちます。BOF、FILEPASS、INTERFACEHDRは全体が平文です
- パスワードはASCII、15文字以内
- HotXLS:インデックスはv2.384.47で修正、鍵とXorRorはv2.384.54で修正。それより古いHotXLSのXORファイルは鍵の不一致で検出します
- 本物の保護には、最低でもBIFF8のRC4 CryptoAPI、あるいはXLSXのAgile Encryptionを
HotXLSは、BIFF5とBIFF8のパスワード保護、XLSXのStandard EncryptionとAgile Encryption、読み込み側のパスワードコールバックを、1つのDelphiとC++Builderライブラリで処理します。上述の相互運用のディテールもライブラリが引き受けます。エディション、プラットフォーム、トライアルダウンロードは、HotXLS Delphi spreadsheet componentの製品ページをご覧ください