技術記事

Delphiにおける信頼できないXLSXファイルのZIP EOCD検証

xlsxファイルはZIPアーカイブであり、ZIPには単一の権威ある目次が存在しない。Delphi・C++Builder向けHotXLS Excel Libraryは、その曖昧さを攻撃対象領域として扱う:そのend of central directoryパーサーは、4つの独立した相互チェックが一致した場合にのみ候補レコードを受理するので、ZIPコメント内に隠された偽造ディレクトリは決して勝てない

これを具体的にするシナリオはありふれている。サーバーが顧客からのスプレッドシートアップロードを受け付ける。ファイルはウイルススキャンを通過し、スプールディレクトリに書き込まれ、あなたのDelphiサービスがそれを開いて3つの列を取り出す。すべて正常に見えるが、スキャナーとあなたのパーサーは、アーカイブが何を含んでいたかについて合意していなかった。スキャナーは1つのメンバー集合を列挙し、あなたのローダーは同じバイトから異なるメンバー集合を列挙していた。どちらも通常の意味でバグっているわけではない。彼らは単にZIP形式における曖昧さを2つの異なる方向に解決しただけであり、攻撃者はそうなるようバイトを選んだのだ

ZIPアーカイブに関する真実は実際どこに存在するのか

それはまさに末尾に、end of central directoryレコードと呼ばれる22バイトの構造体の中に存在する。ZIPファイルは前から後ろへ読まれるものではない:各メンバーは自身の圧縮データの直前にローカルファイルヘッダーを持つが、権威あるインデックスは中央ディレクトリであり、それはファイル末尾付近にある一連のレコードで、すべてのエントリの名前とそのローカルヘッダーのオフセットを与える。中央ディレクトリを見つけるには、まずEOCDを見つけなければならない。EOCDこそが、ディレクトリがどこから始まりいくつのレコードを保持しているかを述べているからだ。HotXLSはこれをTEndOfCentralDirectoryRecordとしてモデル化しており、そのフィールドはディスク上のレイアウトに1対1でマッピングされている:オフセット4にFDiskNumber、6にFStartDisk、8にFThisDiskEntries、10にFTotalEntries、12にFSizeOfCD、16にFOffsetOfStartCD、20にFCommentLenだ。その合計がコンストラクタで4*3 + 5*2として計算されるFMinSizeだ。その後にアーカイブコメントが続き、最大65535バイトの任意のコンテンツを持てるので、FMaxSizeは65557になり、レコードは固定位置にないことを意味する。探しに行かなければならないのだ

なぜEOCDシグネチャを後方にスキャンするだけでは不十分なのか

あなたがスキャンしている4バイトPK\005\006は、アーカイブコメントの中、圧縮データの中、あるいは攻撃者が意図的に追加した2つ目のEOCDの中に合法的に現れ得るからだ。後方に歩いて最初に出会ったシグネチャで止まるパーサーは容易に誘導可能だ:末尾近くにおとりのEOCDを配置すれば素朴なパーサーはそれに従い、異なる順序でスキャンする、あるいはファイル内の最後のシグネチャを正典として扱うパーサーは本物に従う。これはZIP曖昧性攻撃のファミリーであり、その見返りはまさに上で述べた分裂だ:スキャンエンジンと消費側アプリケーションが1つのファイルから異なるエントリ集合を見ることになる

TEndOfCentralDirectoryRecord.Parseは確かに後方にスキャンする。startscanを最後のバイトに設定し、endscanlsize - FMaxSizeかゼロにクランプし、境界をまたぐシグネチャが決して見逃されないよう3バイト重複する256バイトのバッファでウィンドウを歩く。違いはヒットしたときに何が起きるかだ。シグネチャを見つけることはCandidateオフセットを生むだけだ。HotXLSはその後、そのオフセットの22バイトを読み、ReadEOCDでパースし、結果のフィールドがFOffsetEOCDが割り当てられる前に、それらが記述していると主張するファイルと内部的に整合していることを要求する

Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
  SetLength(RecordBuf, FMinSize);
  inputstream.Position := Candidate;
  if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
  begin
    ReadEOCD(RecordBuf[0], 0);
    if (Candidate + FMinSize + FCommentLen = lsize) and
       (FDiskNumber = 0) and (FStartDisk = 0) and
       (FThisDiskEntries = FTotalEntries) and
       (Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
    begin
      FOffsetEOCD := Candidate;
      Result := FOffsetEOCD;
      Exit;
    end;
  end;
end;

この述語は、偽造が同時に満たさなければならない4つの別々の主張として読むべきだ。Candidate + FMinSize + FCommentLen = lsizeは、宣言されたコメント長がファイルの末尾ちょうどに到達することを要求しており、これがコメント内おとりトリックを殺すものだ:本物のコメントの内側に埋め込まれた偽のEOCDは、自分自身の後に続くすべてのバイトを同時に説明することができない。FDiskNumber = 0FStartDisk = 0は、正当なxlsxが一度も使ったことがなく、混乱させるためだけに細工されたアーカイブに存在するマルチディスクスパニングフィールドを拒否する。FThisDiskEntries = FTotalEntriesは、あるパーサーが1つのフィールドから、別のパーサーがもう一方のフィールドからループのサイズを決めるという分割カウントトリックを拒否する。そしてInt64(FOffsetOfStartCD) + FSizeOfCD = Candidateは、中央ディレクトリがEOCDが始まるちょうどの場所で終わることを要求するので、ディレクトリはファイルの他の場所にある無関係なブロブを指すことができない。最後のこの1つにおけるInt64キャストが重要だ:両方のオペランドは32ビットであり、幅拡張がなければ、細工されたペアがラップアラウンドして算術的にテストを満たしながらどこも指さないという結果になり得る

ローカルヘッダーは中央ディレクトリと一致しなければならない

EOCDのチェックはどのディレクトリが権威あるものかを固定するが、それはまだ、そのディレクトリが個々のメンバーについて真実を語っている保証にはならない。すべてのエントリはZIPファイル内で2度記述される——中央に1度、そして自身のローカルヘッダーに1度——そしてこの2つの記述を一致させるものはこの形式には何もないので、中央ディレクトリを信頼するリーダーとローカルヘッダーを信頼するリーダーは、1つのアーカイブから異なるコンテンツを抽出できてしまう。TZipEntry.ParseLocalHeaderFCdFile.LocalFileHeaderOffsetにあるローカルヘッダーをパースし、2つのコピーをフィールドごとに比較することでこの隙間を塞いでいる。不一致の種類ごとに異なる負のコードを返す:正規化されたエントリ名、圧縮方式、汎用目的ビットフラグ、そしてデータディスクリプタフラグがクリアされている場合はCRC32と両方のサイズだ。そのフラグが設定されている場合、ローカルのコピーはゼロであってよい。実際の値は後続のディスクリプタに存在するからだが、ゼロでないローカルの値はそれでも一致しなければならない。最後のチェックは、そのエントリのデータがファイルの末尾を越えて実行されてしまうものを拒否する。Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize)inputstream.Sizeと比較する。どの失敗もTCentralDirectory.Parseから非1の結果として伝播し、TZipArchive.OpenArchiveはそれをCan't open zip archiveに変換する——半信半疑のアーカイブオブジェクトを渡すのではなく。あるファイルがどのシートを含んでいるかだけを知りたい場合、フルパースの前にその検証を実行するのは安価であり、軽量シート検査パスがセルデータを実体化することなくそれを正確に提供する

バイト自体が嘘をついていたらどうなるか

構造的な一致は、ペイロードについては何も語らない。だからHotXLSはすべてのエントリストリームをTZipVerifiedStreamでラップし、呼び出し側が読み取るにつれて宣言されたサイズとCRC32を強制する。これは意図的に事後チェックではない:宣言された非圧縮サイズが4KBでありながらギガバイトに膨張する解凍爆弾は、被害が出たあとではなく4KBの地点で止まる。ラッパーは各読み取りを残りの宣言バイト数にクランプし、ソースが早く枯渇したらZIP entry ended before its declared sizeを発生させ、完了時にもう1バイト分を探り何か残っていればZIP entry exceeds its declared sizeを発生させ、最後にVerifyCompleteで実行中のCRC32を比較し、ZIP entry uncompressed size mismatchまたはZIP entry CRC32 mismatchを発生させる

if Count > 0 then
begin
  Result := FSource.Read(Buffer, Count);
  if Result <= 0 then
    raise Exception.Create('ZIP entry ended before its declared size');
  FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
  Inc(FPosition, Result);
end
else
  Result := 0;

if FPosition = FExpectedSize then
begin
  if FSource.Read(Probe, 1) <> 0 then
    raise Exception.Create('ZIP entry exceeds its declared size');
  VerifyComplete;
end;

計画に入れておく価値のある帰結が1つある。このストリームは設計上、前方専用だ。現在位置以外へのシークはZIP entry stream is forward-onlyを発生させ、サイズ照会が機能し続けるようsoEndにオフセットゼロを与える場合だけ例外的に許される。それが信頼できない入力にとって正しいトレードオフだ。巻き戻せるストリームはCRC会計を打ち負かせるストリームだからだが、それはシーク可能なストリームを期待する消費側コードが自身のバッファを必要とすることも意味する。同じ前方専用の規律がストリーミングダイレクトリーダーの土台になっている。これは、アップロードされたワークブックがメモリに常駐させたくないほど大きい場合に手を伸ばすべきAPIだ

割り当てる前のリソース制限

lxZipArchive内の3つの定数は、単一のアーカイブがプロセスに何をさせられるかを制限しており、TZipEntries.Addは中央ディレクトリがまだ読まれている間、1バイトのエントリデータにも触れる前にそれらを適用する。ZipMaxEntryUncompressedSizeは1メンバーを1GiBに、ZipMaxTotalUncompressedSizeはアーカイブ全体を4GiBに制限し、10000のZipMaxCompressionRatioは、宣言された展開が1万倍を超えるあらゆるdeflateエントリを、非ゼロの非圧縮サイズとゼロの圧縮サイズという退化ケースとともに拒否する。エントリ名は同じ呼び出しの中でCanonicalZipEntryNameを通過し、これは埋め込まれたNUL文字、コロン、そして..のパスセグメントをInvalid ZIP entry nameで拒否し、セグメントを小文字化・正規化するので、大文字小文字や冗長な区切り文字だけが異なる2つのメンバーは、互いを黙って覆い隠すのではなくDuplicate ZIP entry nameとして衝突する

ZIP層の上での多層防御

ZIP層はいくつかの層のうちの1つであり、HotXLSが攻撃者制御の構造をパースするあらゆる場所でこのパターンが繰り返される。最も明確な例はBIFFの数式パーサーにある:TXLSFormula.GetTranslatedtMemFuncトークンを通じて再帰するので、レガシー.xls内の細工されたrgceトークンストリームは任意に深くネストしてスタックを枯渇させ得る。ゲートは1つの定数、MaxTranslateDepth = 256であり、推測ではなく既知の上流の事実に照らして選ばれている。Excelは数式のネストを64に制限しているので、256は4倍の余裕を残しており、本物のスプレッドシートが生成した数式を拒否することは決してないが、それでも悪意のあるストリームをスタックが尽きるずっと前に終了させる

const
  MaxTranslateDepth = 256;
begin
  isOuter := FTranslateDepth = 0;
  if isOuter then
    ResetPendingArrays;
  Inc(FTranslateDepth);
  try
    if FTranslateDepth > MaxTranslateDepth then
    begin
      Result := nil;
      Exit;
    end;

このゲートが例外を発生させるのではなくnilを返すことに注意してほしい。本物であるには深すぎる数式は構文木を生成せず、周囲のパースは続き、ワークブックはそれでも読み込まれる。その非対称性は意図的であり、自分自身の限界においても真似する価値がある:リソース枯渇を止めるために存在する上限は、ドキュメントを中断するのではなく、それが止められる最小単位を劣化させるべきだ。同じ考え方は、計算層を拡張する際にも当てはまる。数式エンジンのカスタム関数APIを通じて自分自身のハンドラーを登録するなら、呼び出し側がすでにチェック済みだと想定するのではなく、それら自身の引数と再帰の上限を与えよう

これらのチェックが買ってくれないもの

境界について正確であるべきだ。4つのEOCD相互チェックはアーカイブインデックスを曖昧さのないものにするので、HotXLSと他の準拠したリーダーは同じファイルを同じエントリ集合に解決する。それらはそのエントリ集合が無害であるかどうかについては何も語らない。ローカルヘッダーの一致は「2つの見え方」トリックを止めるものであり、一貫して記述された悪意のあるペイロードを止めるものではない。検証済みストリームは切り詰め、オーバーフロー、破損を止めるものであり、完璧に整形式でありながら想定しなかった何かをエンコードしているXMLパーツを止めるものではない。そしてこれらのどれもマクロには触れない:構造的に非の打ち所がないワークブックの中のVBAプロジェクトは、それでもVBAプロジェクトであり、それを保持するか、取り除くか、拒否するかの判断は、ZIPリーダーではなくあなたのポリシー層に属する

その代わりに得られるものは、クリーンな失敗境界だ。信頼できないxlsxは、そのメンバーが宣言されたサイズとチェックサムに一致する1つの曖昧さのないアーカイブとして開くか、それが破ったその特定の不変条件を名指しするメッセージとともに例外を発生させるかのどちらかであり、あなたのサービスは推測するのではなく例外を根拠に隔離できる。ZIPリーダーとその上のパーサー階層は、Delphi・C++Builder向けHotXLS Excelコンポーネントの一部として出荷されており、パースを行うマシンにExcelもOLEオートメーションも必要としない。その不在自体が、アップロードされたファイルが到達し得るものを意味のある形で縮小している