技術記事

DelphiでPDF辞書を安全に解析する:名前トークン

Pos('/Length', Dict)のような部分文字列検索は、PDFの辞書を読み取るための正しいツールではありません。なぜなら、PDFの名前キーはプレフィックス(接頭辞)を共有することがあるからです。例えば、/Length/Length1のプレフィックスであり、/Encrypt/EncryptMetadataのプレフィックスです。ISO 32000-1 §7.3.5では、名前はデリミタ(区切り文字)または空白文字でのみ終了するトークンとして定義されているため、キーの直後のバイトがこれらの文字のいずれかである場合にのみ、キーが見つかったとみなされます。この1つのチェックを怠った辞書リーダーは、完全に有効なファイルから最終的に誤った値を読み取ることになります

このことを教えてくれたバグは、字句解析器(レクサー)の問題のようにはまったく見えませんでした。適合性スキャンでFontFileストリームが破損していると報告され始めました。解凍されたフォントプログラムが断片であり、テーブルの途中で切断されていたのです。ファイルはすべてのビューアで問題なく開けました。ディスク上のストリームデータは無傷でした。根本的な原因は、共有辞書リーダーの1行にありました。Pos('/Length', ...)が、ISO 32000-1 Table 127に従ってFontFileストリーム辞書が保持する標準キーである/Length1に一致し、リーダーが/Length1の後の整数をストリーム長として取得してしまったのです。その特定のファイルを書き出したプログラムは、たまたま/Lengthの前に/Length1をシリアル化していました。§7.3.7に従って辞書のエントリは順序付けされていないため、これは完全に許容される動作です。結果として、ストリームは無効なバイト数で切り捨てられ、それを処理するすべての下流チェックは何もエラーを出さずに失敗しました

なぜ部分文字列マッチングがPDF辞書の解析を破壊するのか?

部分文字列マッチングが破綻するのは、PDFの名前空間が意図的に設計されたプレフィックスファミリーで満ちており、辞書エントリの順序が指定されていないためです。ISO 32000-1のTable 127は、埋め込みフォントストリーム用に/Length1/Length2/Length3を定義しており、これらはすべて同じ辞書の/Lengthの隣に存在します。暗号化辞書では、トレーラー内の/Encryptと、その内部の/EncryptMetadataがペアになっています。短いキーはさらに厄介です。Key = 'N'のときにPos('/' + Key, ...)として構築されたルックアップは、/Name/Numsに喜んでマッチしてしまいます。これらの衝突のどれも、不正な形式のファイルを必要としません。/Lengthの前に/Length1を配置するライターは完全に仕様に準拠しています。つまり、部分文字列のバグは破損した入力に対する堅牢性の欠如ではなく、有効な入力に対する正確性の欠如なのです

この障害モードは、音もなく発生するタイプでもあります。誤った/Lengthは例外を発生させず、ストリームが実際に占める長さよりも短い、あるいは長いバイトスライスを返すだけです。そのスライスがフォントサブセットのチェック、CMapの解析、またはメタデータスキャンに供給された場合、コンシューマはゴミデータを見ることになり、通常は何も報告しません。なぜなら、不完全なzlibストリームは単純に展開に失敗し、コードは次の処理に進むからです。私たちはまさにこのクラスの不具合をリリースしてしまい、ISO 32000-1の§7.2〜§7.3の箇条書きの監査を実施して、すべてのPosスタイルのキー検索を疑わしいとフラグを立てた後、共有リーダーのv2.14.3でこれを修正しました

ISO 32000-1 §7.3.5が実際に定義する名前とは何か

セクション 7.3.5 は短く正確です。名前オブジェクトはソリダス(スラッシュ)の後に通常の文字シーケンスが続くものであり、トークンは最初のデリミタ(区切り文字)または空白文字で終了します。デリミタは、8つの括弧文字に加えてソリダスとパーセント記号(( ) < > [ ] { } / %)であり、空白はヌル、タブ、改行、フォームフィード、復帰、およびスペースです(§7.2.2〜§7.2.3)。この終了ルールがすべてです。/Length1は「/Lengthの後に1が続くもの」ではなく、単一の分割不可能なトークンであり、PascalでLengthOneLengthが異なる識別子であるのとまったく同じです。生のバイト検索でキーを見つけるリーダーは、終了ルールが削除された状態でレクサーを再実装しているようなものです

以下は、その欠陥の本質的な部分を簡略化したものです。このバージョンはコンパイルされ、ライターが/Lengthを最初に配置したファイルに対するテストには合格しますが、そうでないライターが作成したファイルではストリームを破損させます

// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

トークン全体の一致:キーの直後のバイトをチェックする

正しい述語は§7.3.5から直接導かれます。候補となる一致が本物のキーであるのは、その直後の文字がデリミタ、空白、またはバッファの最後である場合のみです。それ以外は、単にプレフィックスを共有しているだけの長い名前であるため、探索を諦めるのではなく、その先まで探索を続ける必要があります。私たちのリーダーでの修正は、生のPos検索をすべて、このルールに基づいて構築された単一の共有ルーチンに置き換えました

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

このループ内の2つの詳細が重要です。第一に、最初のプレフィックスの衝突で失敗を返さずに探索を続けます。なぜなら、/Length1 120 /Length 4076は合法的な順序であり、本物のキーはその先にあるからです。第二に、バッファの最後のケースは終端文字としてカウントされます。辞書の断片は名前の直後で合法的に終了し得るからです。自身のコードで監査する価値のあるより微妙な点として、検索文字列にソリダスが含まれていない場合、一致部分の左側にも同じルールが適用されます。さもないと、Pos('Length', ...)/PieceLengthの内部にヒットしてしまう可能性があります。上記のように検索文字列の先頭に/を付けてアンカーすることは、/自体が直前のトークンを終了させるデリミタであるため、左端を正しく処理します

悪意のあるPDFはどのようにしてパーサーのバグをギガバイト単位のメモリ割り当てに悪用できるのか?

不正な形式のファイルや悪意のあるファイルは、辞書の整数値がメモリ割り当てサイズに頻繁に使用されるため、これらの字句解析の誤りをリソース枯渇へとエスカレートさせます。私たちの監査では、オブジェクトストリームの展開において、まさにこの形状の連鎖が見つかりました。ObjStm辞書の/Nエントリはストリームが保持する圧縮オブジェクトの数を示し、展開コードはそれによってサイズ指定された配列に対してSetLengthを呼び出していました。しかし、整数パーサーは失敗時にoutパラメータを未初期化のままにしつつ、関数を返していました。そのため、数値ではない/NSetLengthに未初期化のスタック値を渡してしまいました。そこにあるランダムな正の整数は、信頼することにまだ同意していないドキュメントをスキャンするだけで、わずか数バイトの破損入力によって誘発されるギガバイト単位のメモリ割り当て要求を意味します

この修正には2つの独立した部分があり、どちらも一般化できます。まず、パーサーは失敗時に未初期化メモリではなく、明示的な0を返すようになりました。そして、コンシューマは計算なしで/Nを信頼しなくなりました。/Firstの前のObjStmヘッダー領域は、圧縮されたオブジェクトごとにオブジェクト番号とオフセットという整数のペアを格納しており、各ペアは区切り文字を含めて少なくとも4バイトを占有します。したがって、宣言されたヘッダーサイズに対してFirstVal div 4 + 1を超える/Nは物理的に不可能であり、割り当てが行われる前に拒否されます。この境界チェックに必要なコストは比較1回のみであり、すでに手元にあるデータから導き出されます。これこそが探すべきパターンです。任意の定数ではなく、ファイル自体が証明する上限です

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

さらに2つの制限値が私たちのリーダーの防御境界を完成させており、どちらもv2.12.0で導入されました。ストリームリーダーは、結果バッファを割り当てる前に、ファイル全体よりも大きい/Lengthを拒否します。ストリームがそれを含めるコンテナより大きくなることはあり得ないため、このチェックには誤検知がありません。そして、インフレート(解凍)処理では展開後の出力を256 MiBに制限しており、これにより数キロバイトの入力が無制限に拡張される古典的なzlib爆弾を無効化します。この制限値は実際のPDFストリームに対して十分に寛大でありつつ、最悪のシナリオでも存続可能です。これら3つすべてに共通するテーマは同じです。ファイルが宣言するすべてのサイズは主張にすぎず、パーサーはメモリを割り当てる前に、測定可能な何かに対してその主張を検証します。同じ監査体制は、1レイヤー下のバインディング境界にも適用されます。これについては、DelphiにおけるPDFium ABIの堅牢化とメモリ安全性で説明されています

トークン全体ルールだけでは不十分な場合

上記のルーチンを過信しないための率率直な制限事項です。トークン全体の一致はキーの特定を解決しますが、辞書の範囲に対するフラットなバイト検索では、一致した部分が入れ子になった辞書、リテラル文字列、またはコメントの内部にあるかどうかを判別できません。広すぎる範囲を渡すと、ページオブジェクトに対するFindDictKeyがその/Resourcesサブ辞書内のキーにヒットしてしまう可能性があります。私たちのリーダーは、まず範囲を単一のオブジェクト本体に制限し、文字列やコメントのコンテキストは別の未解決の監査項目として扱います。部分文字列の安全性は梯子の一段にすぎず、梯子全体ではありません。相互参照の一貫性はそれ自体の専門分野であり、オブジェクトおよびxrefストリームの検証で説明されています。また、作成者不明のドキュメントに対するより広範な脅威カタログは、PDFのセキュリティリスクの監査に記載されています

DelphiやLazarusで手書きの辞書リーダーを保守している場合、このインシデントからのチェックリストは短いものです。コードベース内のすべてのPos('/をgrepし、ヒットしたものを1つのトークン全体ヘルパーにルーティングします。キーが関与するプレフィックスファミリーをリストアップします(/Length/Encrypt/N/Type1に対する/Typeなどはすべて実際のファイルに現れます)。次に、SetLengthGetMem、またはコピーループに到達するすべての整数を確認し、何がそれを制限しているかを問いかけます(ファイルサイズ、ヘッダーから導出された上限、または何もないか)。ここで説明する解析レイヤーは、私たちのPDFium Componentの基盤であり、バイトレベルのリーダーとレンダリングエンジンが接触するすべてのドキュメントで相互にチェックを行っています