あなたがワークブックを作成してパスワードで暗号化し、そのファイルを同僚に渡してExcelで開かせるとします。Excelはパスワードを要求し、同僚が入力すると、Excelはそれを承認します。ここまでは暗号化が正しく機能しているように見えます。しかしその後、Excelはファイルが破損しているため開けないというダイアログを表示するか、無意味なセルばかりのシートを開きます。パスワードは正しかったのに、ファイルは破損しています。これはOffice暗号化における最も混乱を招く失敗モードです。パスワードが正しいと伝える部分とデータを保持する部分が、2つの異なる処理によって保護されているため、一方を正しく処理できても、もう一方が保証されるわけではないからです。
ここで説明する2つのバグは、まさにこのような形をしていました。いずれの場合も、検証処理(verifier)は成功したものの、本体(body)が失敗しており、そのため本来存在しないパスワードや鍵導出のバグを追跡することになっていました。本当の欠陥は、パッケージのバイトデータがどのように変換されたかという、下流の処理にありました。これら2つの不具合は独立しており、一方はAESパス、もう一方はRC4パスですが、診断が困難であるという課題を共有しているため、なぜ半分だけ正しい結果が最も解読しにくいのかを確認する価値があります。
パスワードの通過が本体の整合性を何も証明しない理由
現代の暗号化されたXLSXが使用するフォーマットはECMA-376標準暗号化(Standard Encryption)であり、2つの暗号化されたオブジェクトが並んで格納されます。1つはEncryptionVerifier(暗号化検証用オブジェクト)で、ランダムな値とそのハッシュ値を保持する小さなブロックで、パスワードから導出された鍵で暗号化されています。もう1つはEncryptedPackage(暗号化されたパッケージ)で、同じ鍵で暗号化されたワークブックのzipコンテナ全体です。検証用のオブジェクトが存在する理由は、リーダーが数メガバイトの本体の処理に労力を費やす前に、パスワードを確認できるようにするためです。検証用データを復号し、ランダムな値をハッシュ化し、保存されているハッシュと比較して、一致すればパスワードが正しいと判断します。
罠は、検証用データとパッケージが、別々のバッファに対して別々の呼び出しによって暗号化される点です。正しく導出された鍵は、パッケージがその後どうなろうとも、検証用データを正しく復号します。したがって、鍵導出が正しくてもパッケージの変換が誤っている場合、Excelは検証用データからパスワードを承認しつつ、本体で失敗します。症状は「正しいパスワード、壊れたファイル」として表示されるため、調査の矛先はパスワードパスに向かいますが、そこは壊れていない部分です。同様の分離はレガシーなRC4のケースでも機能します。すなわち、検証ハッシュが最初にチェックされ、本体がずれて非同期になってもそのチェックは影響を受けません。
バグ1:CBCではなくECBによるAES暗号化
[MS-OFFCRYPTO] §2.3.4.15は、標準暗号化がパッケージをECB(Electronic Codebook)モードのAESで暗号化することを規定しています。パディングされたパッケージのすべての16バイトブロックは、同じ鍵で独立して暗号化されます。ブロック間のチェーン処理(連鎖)はなく、初期化ベクトル(IV)もありません。これは、通常ECBが回避される現代の基準から見ると珍しい選択ですが、相互運用性を確保する上で仕様を勝手に推測することは許されません。ExcelはパッケージをECBとして復号するため、ジェネレータ(生成器)もそれをECBとして暗号化しなければ、両者は一致しません。
バグは、パッケージが、すべてゼロの初期化ベクトルを使用したCBCモードのAESで暗号化されていたことでした。これが「ほぼ動作」してしまう理由であり、「ほぼ」こそが最も厄介な状態です。CBCでは、最初のプレーンテキストブロックは暗号化の前にIVとXOR(排他的論理和)されます。IVがすべてゼロの場合、そのXOR処理は何も変更しないため、ゼロIV付きCBCの最初のブロックは、ECBとまったく同じ暗号文を生成します。2番目のブロック以降、CBCは前の暗号文ブロックを次のブロックにフィードするため、最初のブロック以降のすべてのブロックがECBから乖離していきます。
これを構造に重ね合わせてみましょう。パッケージのレイアウトは、開始位置に8バイトのリトルエンディアンの長さプレフィックスを配置するため、Excelが最も初期にチェックするファイル部分は最初の1〜2ブロック内に配置されています。最初のブロックが偶然一致することは、最も初期の検証が通過する一方で、それ以降のすべてのブロックがノイズに復号されることを意味します。モードが特定できれば、修正は難しくありません。すなわち、各16バイトブロックをECBで暗号化し、チェーン処理を停止します。エンジン内では、XlsEncryptStdPackageがパディングされたバッファを16バイトステップで走査し、それぞれに対して、検証ブロックですでに使用されているのと同じプリミティブであるAESEncryptECB128Blockを呼び出します。ソースコードのループには、ルールが明快に記述されています。ゼロIVを持つCBCは最初のブロックのみECBと一致するため、残りのパッケージはゴミデータに復号され、Excelによって拒否されてしまいます。
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('report.xlsx');
// SaveAsEncrypted serializes the workbook, then runs the
// ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
// package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
raise Exception.Create('Encryption failed');
finally
Book.Free;
end;
end;
バグ2:RC4のキー再生成が非同期にずれる
レガシーな.xlsパスはRC4 CryptoAPIスキームを使用しており、そのルールは本質的に異なります。[MS-OFFCRYPTO] §2.3.6は、暗号が1024バイトのブロック境界ごとに再生成(re-keyed)されることを規定しています。ストリームは1024バイト of blockに分割され、ブロック番号0、1、2などに対して新しいRC4キーが導出され、各ブロック内でキーストリームはバイトごとに連続して消費されます。2つの不変条件を同時に維持する必要があります。境界ごとにキーを再生成すること、およびブロック内でギャップ(隙間)なしにキーストリームを消費することです。RC4はストリーム暗号であるため、そのキーストリームは単一の整列されたシーケンスであり、引き出されるn番目のバイトは、それまでに引き出されたバイト数によって決定されます。復号は同じシーケンスに対する同じXOR処理であるため、生成側と消費側は、全く同じ位置で全く同じバイトを引き出す必要があります。
そこにすべての困難があります。ストリーム暗号には再同期(resynchronization)がありません。キーストリームの1バイトでも無駄にすると、それ以降のすべてのバイトが誤ったキーストリームバイトとXORされ、エラーが自動的に修正されることはありません。エラーはブロックの最後までカスケード(連鎖)し、実行位置が一度ズレてしまうと、それ以降のすべてのブロックに波及します。ここのバグはまさにそれを引き起こしていました。ブロックカウンターがマイナス1というセンチネル値から開始され、スキップルーチンはカウンターがすでに現在のブロックと一致していると仮定していました。そのセンチネルから開始したことで、キーを再生成し、消費されるべきではなかった丸ごと1024バイトのブロックのキーストリームを実行し、その過程で残りのカウントを負にしました。その時点でデクリプタは完全に1ブロック分位相がずれていました。検証用データはこれらすべてを行う前にチェックされるため承認され、パスワードが正しいように見える一方で、すべてのデータセルがゴミデータになっていました。
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
// CanReadEncrypted checks the Compound File (OLE2) signature so
// you can branch before attempting a normal Open. OpenEncrypted
// routes plain files to Open and handles the encrypted container.
if Book.CanReadEncrypted('legacy.xls') then
Book.OpenEncrypted('legacy.xls', 'S3cret!')
else
Book.Open('legacy.xls');
// read cells here
finally
Book.Free;
end;
end;
凍結された仕様との相互運用はバイト単位での一致
両方のバグは同じ根本的な原則に帰着し、設計の選択肢をどう評価するかを変えるため、単独で述べる価値があります。出力の消費者が変更不可能な固定された外部プログラムである場合、暗号モードやキー再生成の間隔は、最適化や簡略化が許される実装の詳細ではありません。それらは通信プロトコルの契約の一部です。Excelは、それらの選択が適切かどうかにかかわらず、ECBで復号し、1024バイトの境界でキーを再生成します。あなたの唯一の役割は、その正確な手順に従って元データに復号できるバイト列を生成することです。より近代的なモード、無害に見えるIV、自然に感じられる場所から開始するカウンターなどはすべて、読み取り側が期待するものから逸脱した瞬間に欠陥になります。凍結された仕様に対する相互運用は「近似的」ではありません。バイト単位で完全に一致しているか、あるいは破損しているかのどちらかです。
これが、検証用データ単体では貧弱な動作テスト(スモークテスト)にしかならない理由でもあります。検証用データは鍵の導出が機能していることを教えてくれますが、それは必要条件であっても十分条件からは程遠いです。暗号化されたファイルを開いてパスワードの承認のみを確認するテストは、本体が読み取れない状態であっても成功を報告します。実際のテストでは、パッケージを復号して復元されたバイト列を元の入力と比較するか、ワークブックを暗号化および復号のループに通してセルを読み戻します。検証用データはパスワードを証明するだけであり、暗号化を証明できるのは本体だけです。
保護されたワークブックを読み書きするサポートされた方法
公開されているAPIはわずかです。パスワード保護された現代のワークブックを書き出すには、TXLSXWorkbookに入力するか開いて、ファイル名とパスワードを指定してSaveAsEncryptedを呼び出します。これにより、ワークブックがシリアライズされ、最初の修正が解決した標準暗号化パイプラインが実行され、成功時には1が返されます。読み取るには、CanReadEncryptedを呼び出して、ファイルが暗号化された複合ファイルコンテナであるかどうかをテストし、分岐します。OpenEncryptedは暗号化されたパスを処理し、通常のファイルに対してはOpenにフォールバックします。また、パスワード付きのOpenを直接利用することもできます。上記で説明したモード処理やキー再生成ループはこれらの呼び出しの下に配置されており、あなたはパスワードとファイル名を提供するだけで、エンジンが代わりに仕様を満て処理します。
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('quarterly.xlsx');
Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
// Reopen on the consumer side
Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
finally
Book.Free;
end;
end;
保護された出力の形状、EncryptionInfoストリーム、検証用ブロック、およびパッケージレイアウトについては、AES保護されたXLSX出力のチュートリアルでカバーされています。シートレベルのロックという個別の問題や、保護がページ設定や印刷とどのように対話するかについては、保護、ページ設定、および印刷に関する記事を参照してください。どちらも、このブログの他の場所で扱われている読み込み、書き込み、およびレンダリングAPIと並んで、DelphiおよびC++Builder向けのHotXLS spreadsheet componentの一部として提供されている、ここで解説した暗号化パスをベースにしています。