技術記事

DelphiでのPDFデコード爆弾: HotPDFフィルターチェーン予算

OOMキラーがプロセスを止めるまでサービスプロセスを固定してしまう20KBのPDFは、あなたのコードのバグではなく解凍爆弾だ。Delphi・C++Builder向けのネイティブVCL PDFコンポーネントであるHotPDFは、DecodeBudgetBytesでこれを縛る。これはデフォルトで268435456バイトのフィルターチェーン単位の上限であり、すべてのデコード段階を1つの共有予算に課金する

ワーカープロセスを食い尽くした20KBのファイル

この事故の様相はいつも同じだ。サムネイルをレンダリングするキューワーカーがアップロードを拾い、常駐メモリが2秒足らずで12GBを超え、プロセスがスタックトレースもなく消える。ファイルは20KBだ。1ページ、1コンテンツストリーム、5つのエントリを持つ/Filter配列がある。その配列の名前はすべて仕様が定義するフィルターであり、すべての段階がエラーなくデコードされ、ファイルの中には不正な形式のバイトは何もない。それがこのクラスの入力を厄介にしている理由だ:拒否すべき壊れたバイトが存在しないのだ

これは単一フィルターを正しくデコードすることとは別の問題だ。LZWDecode/DecodeParmsプレディクタを正しく処理することはそれ自体独立したテーマであり、読み込んだドキュメントに対するLZW、プレディクタ、DecodeParmsの解説で扱っている。ここではすべてのデコーダーはすでに正しい。失敗するのは、正しいデコーダーを5つ連続して実行し、誰も合計を数えていないときに何が起きるかという点だ。ISO 32000-1 §7.4は、/Filterが単一の名前でも名前の配列でもよく、配列は順に適用される、つまり最初のエントリから適用されることを明記している。1つの段階がどれだけ入力を膨張させてよいかについては何も述べていないし、チェーン全体の合計についても何も述べていない。ASCIIHexDecode段階はおおよそ入力を半分にするだけなので、無害に聞こえる。ゼロバイトの連続に対するFlateDecode段階は数千倍の比率に達する。それらを連鎖させると、算術は乗算的になる:20KBが20MBになり、20GBになる。それぞれの個別のステップは合法なストリームの準拠したデコードだ

なぜフィルター単位の上限はデコード爆弾を止められないのか

フィルター単位の上限は、/Filter配列の各要素で再武装されるからだ。256MiBの段階単位の上限を持つ5段階のチェーンは1.25GiBを許可し、最後の段階も、それ以前の4段階が何を生成したかにかかわらず、完全に新しい許容量から始まる。上限は誠実に施行されているが、重要な何も制約していない。HotPDFはv2.447.0以前にまさにこの形をしていて、それと並んでもう1つの隙間があった。LZW伸長器はMaxOutputBytes上限を持ち、画像プレディクタパスは自分の行を独自に管理していたので、この2つはローカルに制限されていた。FlateDecodeASCIIHexDecodeASCII85DecodeRunLengthDecodeには上限がまったくなかった:それぞれが、入力が尽きるかアロケータが音を上げるまでTMemoryStreamに書き込み続けていた。だから敵対的なチェーンには2通りの抜け道があった。完全に無防備なフィルターを使うか、防備されたフィルターを使ってその数を単に増やすかだ

素朴な修正が見落とす3つ目の細部がある。あなたが気にすべき数値は、最終的にデコードされた出力のサイズではない。それはピークであり、そのピークは通常、中間バッファに存在する。適度な4MBのコンテンツストリームで終わるチェーンが、第3段階で8GBを割り当て、まったく妥当そうに見えるものを返してくることがある。結果の長さを事後にチェックしても、プロセスを殺したその割り当てについては何も分からない

フィルターチェーンごとに1つの予算トラッカー

HotPDF v2.447.0の修正は、会計処理を段階単位ではなくチェーン全体にまたがるようにすることだ。各フィルターチェーンは1つのTHPDFDecodeBudgetTrackerを構築し、すべてのデコーダーは実際のターゲットをラップするTHPDFBudgetWriteStreamを通じて書き込む。このラッパーは1バイトを転送する前にBudget.Consume(Count)を呼び出すので、拒否はターゲットストリームがまだ古いサイズのうちに発生する。その順序こそがすべての要点だ:バッファがすでに成長したあとに実行されるチェックは、防御ではなく診断にすぎない

// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // read the source, do not copy it
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter names the stage and bumps FilterCount; the wrapper
    // stream calls Budget.Consume before writing into NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

ローカルの上限がなくなったのではなく、共有予算の投影になった。LZW段階は今やDecoder.MaxOutputBytes := Budget.RemainingBytesを設定するので、そのプライベートな上限は独立した許容量ではなく、チェーンに残っているものになる。画像プレディクタ段階はBeginFilterで開始し、割り当てる前に自らの行の必要量をConsumeで課金する。つまりプレディクタの出力は、それに供給した汎用フィルターと同じ予算に課金される。これは特に画像パスで重要になり、フィルターチェーンとプレディクタが1つの操作の2つの半分になっている状況であり、読み込んだドキュメントからデコードフィルターを通じて画像を抽出するで扱っている

予算が拒否したとき、呼び出し側には何が見えるのか

スタックの一番下では、拒否はEHPDFDecodeBudgetErrorを発生させる。それより上では、答えは呼び出し元のAPIがすでに持っていた契約次第だ。Falsenilで失敗を報告していた高レベルの読み取りメソッドは、そのままそれを続ける。文書化されたブール値の結果を例外に変えることは、すでに不正な形式の入力を正しく処理していた呼び出し側を壊してしまうからだ。読み込んだページのコンテンツパスは意図的な例外だ:切り詰められたコンテンツストリームが単に空のページとしてレンダリングされるのを許すのではなく、EHPDFDecodeBudgetErrorを再発生させる。この設計は、単なるFalseそれ自体では曖昧であることを意味するので、予算はそれと並んで診断レコードを公開する:THotPDF.GetLastDecodeBudgetInfoは、そのインスタンスが最後にデコードしたチェーンの状態を返す

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // tighter than the default
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

これらのフィールドを一緒に読むと、2つの攻撃の形が分離される。PeakStageBytesDecodedBytesに近い場合、1つの段階がすべての被害を出しており、単一の高比率フィルターを見ていることになる。PeakStageBytesDecodedBytesのごく一部でFilterCountが高い場合、個々の段階はどれも法外ではなく、チェーンが積み重なって上限を超えたことになり、これはまさにフィルター単位の上限では見えないケースだ。ハンドラーに書き込んでおく価値のある注意点が1つある:GetLastDecodeBudgetInfoは、そのインスタンスが少なくとも1つのフィルターをデコードするまでFalseを返すので、それからのFalseはドキュメントがクリーンだった証拠にはならない

予算がどこでリセットされるか、そしてゼロが正直な答えになるとき

DecodeBudgetBytesは1つのストリームチェーンを縛るものであり、1つのドキュメントを縛るものではない。その境界は意図的だが読み違えやすい。すべてのコンテンツストリーム、すべての埋め込みファイル、すべてのクロスリファレンスストリーム、すべてのオブジェクトストリームは、それぞれ新しい256MiBから始まる。4000ページのドキュメントは、したがって上限いっぱいを使い切る4000回の独立したチャンスを持ち、オブジェクトストリームはさらにその数を増やす。それぞれが多くのオブジェクトを保持する圧縮コンテナそのものだからだ。詳しくはオブジェクトストリームとインクリメンタル更新に関するノートで説明している。実際の要件がプロセス全体のメモリに対する上限であるなら、この特性はその一部の入力であって全体ではなく、ジョブレベルまたはコンテナレベルの上限の背後に置くべきだ

ゼロは無制限を意味し、それは抜け道ではなく正当な設定だ。入力を所有している場合に設定しよう:自社のシステムが生成したドキュメントに対するアーカイブ再処理パイプライン、または600dpiカラースキャンの単一のチェーンが本当にどんな上限よりも多くを必要とするラスタライズステップなどだ。負の値は即座にERangeErrorで拒否される。負の予算には筋の通った意味がなく、黙ってクランプすることは設定ミスを隠すことになるからだ

// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0;              // explicit unlimited

// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Configuration mistakes fail loudly instead of clamping
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

数値を選ぶことは、普段払われるより多くの注意に値する。低すぎる予算は自傷的な障害になるからだ。既存のコーパスをデフォルト設定で実行し、すべてのチェーンについてPeakStageBytesDecodedBytesを記録し、観測された最大値に実際の余裕を持たせて上限を設定しよう。安全そうに聞こえるからという理由で選んだ丸い数字は、正当な大きなスキャンを最悪のタイミングで拒否してしまい、その失敗はログの中で攻撃とまったく同じに見えるだろう

もう起こらなくなったコピー

すべての段階を予算ラッパーに通すようにルーティングしたことは、結果としてチェーンをより高価にするのではなくむしろ安価にした。ストリームがフィルターを持つ場合、最初の段階は今やエンコードされたバイトをスクラッチバッファへコピーせず、ソースストリームを直接読み込むようになり、そこから先は同時に生きているバッファは2つだけになる:現在の入力と書き込まれている段階出力だ。生のコピーは必要な2つのケースで生き残っている:フィルターをまったく持たないストリームと、呼び出し側が最後のエンコーディングを保持したいと望む画像だ。どちらも呼び出し側が所有し独立してシークできるストリームを返すからだ。防備されていないバージョンのこのコードは、より多く割り当て、より少なく制限していた。これは両者の間によくある関係だ。ただし、率直に言っておくべきことがある:これは任意のPDFを読み込んでも安全にするものではない。これは1つの特定の、非常に安価なサービス拒否のベクトル、つまり小さなファイルがネストしたフィルターを通じて大きな割り当てを買うベクトルを閉じるだけだ。バイト会計における整数オーバーフローは別途防御されており、オフセットを内部的に信頼せずに敵対的なドキュメントをパースするという広範な問題は別の分野だ。デコード予算はいくつかある上限のうちの1つであり、その価値は、ファイルに触れる前に1つのプロパティから設定できるという点にある

チェーンごとの予算、その診断レコード、そしてそれが保護する読み込み済みドキュメントのデコードパスは、外部の解凍依存関係を設定したりパッチしたりすることなく、コンポーネント自体の一部として出荷される。DelphiやC++Builderのサービス内で信頼できないPDF入力をどう縛るかを検討しているなら、HotPDF Delphi PDFコンポーネントのページに、これらの上限が適用される読み込み済みドキュメントツールキットが列挙されている